diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6577132 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b375c18..7411318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..875b052 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 30bfa97..5daddff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker/pin-everything.mjs b/docker/pin-everything.mjs new file mode 100644 index 0000000..1e2f73e --- /dev/null +++ b/docker/pin-everything.mjs @@ -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 "" -- > 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 -- \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' } } });