Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# The image installs truecopy from npm and only needs the pin helper — keep the
# build context tiny and reproducible rather than copying the working tree in.
*
!docker/pin-everything.mjs
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to **@askalf/truecopy** are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/), and this project
adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added
- **`Dockerfile`** — run `truecopy-mcp` as a container. Because the gate needs a
downstream server to gate, the image wraps the MCP reference server
(`@modelcontextprotocol/server-everything`) and pins its tools at build time
(`docker/pin-everything.mjs` captures the live `tools/list` → `truecopy add`),
so `tools/list` returns a real, **vetted** set over stdio instead of an empty
one. Lets MCP hosts that launch servers from an image (e.g. Glama) build and
introspect truecopy. Both package versions are pinned via build args for a
reproducible image and lock.

## [0.9.0] - 2026-07-17

### Added
Expand Down
39 changes: 39 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# truecopy-mcp as a container — for Glama and any host that launches an MCP
# server from an image.
#
# truecopy-mcp is a GATE, not a server: it sits in front of a downstream MCP
# server and filters its tools/list down to the pinned, unmodified, unpoisoned
# set. So a bare gate has nothing to introspect — every unpinned tool is dropped
# and tools/list comes back empty. This image therefore wraps the MCP reference
# server (@modelcontextprotocol/server-everything) and pins its tools at build
# time, so at runtime tools/list returns a real, *vetted* tool set — a live
# demonstration of the gate passing a clean server through untouched.
FROM node:22-slim

# @askalf/truecopy pulls @askalf/redstamp from GitHub, so git (+ CA certs for
# HTTPS) must be present when npm resolves the dependency tree.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# Pin both versions so the image — and the lock generated from it — are
# reproducible. Bump these together with a rebuild.
ARG TRUECOPY_VERSION=0.9.0
ARG EVERYTHING_VERSION=2026.7.4
RUN npm i -g @askalf/truecopy@${TRUECOPY_VERSION} \
@modelcontextprotocol/server-everything@${EVERYTHING_VERSION}

WORKDIR /app
COPY docker/pin-everything.mjs ./pin-everything.mjs

# Capture exactly the tools the downstream advertises and pin them into
# truecopy.lock. At runtime the same server advertises byte-identical tools, so
# the gate classifies them `vetted` and serves them; anything drifted or
# poisoned would be dropped before it reached the client.
RUN node pin-everything.mjs everything -- mcp-server-everything stdio > everything.json \
&& truecopy add everything.json --lock /app/truecopy.lock \
&& rm everything.json

# Enforce the pinned lock in front of the live server, over stdio.
ENTRYPOINT ["truecopy-mcp", "--lock", "/app/truecopy.lock", "--name", "everything", \
"--", "mcp-server-everything", "stdio"]
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ A silently-added, drifted, or poisoned tool is stripped from `tools/list` (the a

> **Windows / Git Bash:** MSYS auto-rewrites an argument that looks like a Unix absolute path before `truecopy` (a native node process) sees it — a bare `--lock /etc/truecopy.lock`, a scan source like `/srv/skill.json`, or the wrapped server's `/workspace` path can arrive mangled (e.g. prefixed with `C:/Program Files/Git/…`), so the lock isn't found or the wrong path is scanned. Prefix the run with `MSYS_NO_PATHCONV=1` and use drive-letter paths (`C:/…`), or run truecopy from PowerShell/cmd. Not a truecopy bug — the arg is rewritten before truecopy reads it.

**As a container** — the repo ships a [`Dockerfile`](Dockerfile) that runs `truecopy-mcp` in front of the MCP reference server ([`server-everything`](https://www.npmjs.com/package/@modelcontextprotocol/server-everything)) and pins its tools at build time, so `tools/list` returns a live, **vetted** set over stdio. Useful for MCP hosts that launch servers from an image (e.g. [Glama](https://glama.ai/mcp/servers)):

```bash
docker build -t truecopy-mcp . && docker run --rm -i truecopy-mcp
```

A gate with nothing pinned correctly drops *every* tool, so the image bakes a `truecopy.lock` for the wrapped server — point the `ENTRYPOINT` at your own downstream and lock to gate a real server.

**`truecopy guard`** — a launch gate. Verify the lock, then run a command only if it's clean:

```bash
Expand Down
39 changes: 39 additions & 0 deletions docker/pin-everything.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env node
// Build-time helper: launch the downstream MCP server, capture the exact tool
// set it advertises, and emit a truecopy manifest ({ name, tools }) on stdout.
// Piped into `truecopy add` so the image ships a truecopy.lock that pins those
// tools — at runtime the same server advertises byte-identical tools, so the
// gate serves them as `vetted` instead of dropping every unpinned tool.
//
// node docker/pin-everything.mjs "<name>" -- <server cmd...> > manifest.json
import { spawn } from 'node:child_process';
import readline from 'node:readline';

const argv = process.argv.slice(2);
const sep = argv.indexOf('--');
const name = (sep > 0 ? argv[0] : 'downstream');
const cmd = sep >= 0 ? argv.slice(sep + 1) : argv;
if (!cmd.length) { process.stderr.write('usage: pin-everything.mjs <name> -- <server cmd...>\n'); process.exit(2); }

const srv = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'inherit'] });
const rl = readline.createInterface({ input: srv.stdout });
const send = (m) => srv.stdin.write(JSON.stringify(m) + '\n');
const fail = (msg) => { process.stderr.write(`pin-everything: ${msg}\n`); srv.kill(); process.exit(1); };
const timer = setTimeout(() => fail('timed out waiting for tools/list'), 60000);

rl.on('line', (line) => {
let msg; try { msg = JSON.parse(line); } catch { return; } // ignore non-JSON banner lines
if (msg.id === 1) { // initialize acked → announce initialized, then ask for tools
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
send({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
} else if (msg.id === 2) {
clearTimeout(timer);
const tools = msg.result?.tools;
if (!Array.isArray(tools) || !tools.length) fail('downstream advertised no tools');
process.stdout.write(JSON.stringify({ name, tools }, null, 2) + '\n');
srv.kill();
process.exit(0);
}
});
srv.on('error', (e) => fail(`could not launch downstream: ${e.message}`));
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'truecopy-pin', version: '1' } } });