Skip to content

feat(mcp): stdio MCP server that starts with nothing - #121

Open
nbkdoesntknowcoding wants to merge 1 commit into
mainfrom
feat/mcp-stdio-server
Open

feat(mcp): stdio MCP server that starts with nothing#121
nbkdoesntknowcoding wants to merge 1 commit into
mainfrom
feat/mcp-stdio-server

Conversation

@nbkdoesntknowcoding

Copy link
Copy Markdown
Owner

An agent should be able to discover what a server can do before it authenticates. Today it cannot — the only MCP endpoint is streamable-HTTP behind OAuth 2.1, so tools/list from a bare container is a 401.

Why this can't live in the API

Importing apps/api/src/mcp/server.ts reaches all of this before any transport exists:

dependency file what it does at import
env validation apps/api/src/config/env.ts process.exit(1) on the first missing variable
ioredis + BullMQ apps/api/src/queue/embeddings.ts:25 opens a Redis connection
ioredis + BullMQ apps/api/src/queue/pdf-generation.ts:16 opens a Redis connection
ioredis apps/api/src/mcp/tools/query-embedding.ts:28 opens a Redis connection
Postgres pool apps/api/src/db/index.ts:6 builds a postgres.js pool

Reached by:

mcp/server.ts → mcp/auth.ts → config/env.ts
mcp/server.ts → mcp/tools/index.ts → mcp/tools/record-decision.ts → lib/decisions.ts → queue/embeddings.ts
mcp/server.ts → mcp/tools/dev/index.ts → db/index.ts

Measured, not assumed: a bare import of that module with no env exits 1 listing 14 required variables; with stub env it opens three TCP connections to :6379 1.3 s in and emits an unhandled ioredis error.

What this adds

packages/mcp-stdio imports none of the above.

  • initialize / tools/list — served offline from tools.generated.json, captured at build time from the real createMcpServer() over an in-memory transport.
  • tools/call — forwarded over HTTPS to a workspace (MNEMA_API_URL + MNEMA_API_KEY).

Everything needing a service is on the call path, established on first use. A missing dependency fails one call with a stated cause and a reason code on stderr — stdio.not_configured, stdio.upstream_unreachable, stdio.upstream_http_error, stdio.upstream_bad_payload, stdio.tool_error — never a boot failure and never a silent catch.

A build-time capture is a second copy of the truth, so apps/api/src/tests/mcp-stdio-manifest.test.ts regenerates it and fails on drift (pnpm --filter @boppl/api mcp:manifest to refresh).

__ui_probe is excluded from the manifest — it is a development probe, registered unconditionally in mcp/server.ts and therefore live in the hosted tools/list, and it should not appear in a public directory listing. Left registered upstream; removing it is a separate decision.

Dockerfile

Root Dockerfile, multi-stage. Builds only this package — its single dependency is the MCP SDK, not the ~90 production deps of apps/api. --frozen-lockfile --ignore-scripts so nothing reaches the network mid-install; unprivileged user; no EXPOSE, no HEALTHCHECK (a probe writing to stdout would corrupt the JSON-RPC stream).

node:22-slim, not node:20-slimpackage.json declares engines.node >= 22 and .nvmrc pins 22.

Verified

With no environment at all (env -i, PATH only):

EXIT CODE: 0
env seen by the process: PATH, __CF_USER_TEXT_ENCODING
stderr: [mnema-mcp] ready — 45 tools; tools/call is unconfigured (set MNEMA_API_KEY); introspection works regardless
initialize → {"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"mnema","title":"Mnema","version":"1.0.0"}}
tools/list → 45 tools

Re-run from a tree mirroring the image layout (source directory removed, pnpm symlinks preserved) — identical result. Also: pnpm --filter @boppl/mcp-stdio build/typecheck/lint clean, pnpm --filter @boppl/api build clean, manifest drift test 3/3.

Not verified: docker build has not been run — there is no container runtime on the authoring machine. The image needs one real build before this is relied on.

🤖 Generated with Claude Code

An agent should be able to discover what a server can do before it
authenticates. Today it cannot: the only MCP endpoint is streamable-HTTP behind
OAuth 2.1, so `tools/list` is a 401, and a directory crawler in a bare container
sees a server that refuses to introduce itself.

The Fastify API cannot serve that discovery, because importing
apps/api/src/mcp/server.ts reaches, before any transport exists:

  mcp/server.ts -> mcp/auth.ts -> config/env.ts
      process.exit(1) on the first missing variable
  mcp/server.ts -> mcp/tools/index.ts -> mcp/tools/record-decision.ts
      -> lib/decisions.ts -> queue/embeddings.ts        ioredis dials on import
  mcp/server.ts -> mcp/tools/dev/index.ts -> db/index.ts  postgres.js pool

(Three eager ioredis clients in total: queue/embeddings.ts, queue/pdf-generation.ts,
mcp/tools/query-embedding.ts. Observed as three TCP connects 1.3s into a bare
import, followed by an unhandled ioredis error event.)

So packages/mcp-stdio imports none of it:

  initialize / tools/list  answered offline from tools.generated.json, captured
                           at build time from the real createMcpServer() over an
                           in-memory transport
  tools/call               forwarded over HTTPS to a workspace

Everything needing a service is on the CALL path, established on first use. A
missing dependency fails one call with a stated cause and a reason code on
stderr (stdio.not_configured, stdio.upstream_unreachable, stdio.upstream_http_error,
stdio.upstream_bad_payload, stdio.tool_error) — never a boot failure, never a
silent catch.

A build-time capture is a second copy of the truth, and second copies rot, so
mcp-stdio-manifest.test.ts regenerates and diffs it. __ui_probe is excluded from
the manifest: it is a development probe registered unconditionally in
mcp/server.ts and live in the hosted tools/list, and it should not appear in a
public directory listing. Left registered upstream — removing it is a separate
decision.

Dockerfile at the repo root builds only this package (its one dependency is the
MCP SDK, not the ~90 production deps of apps/api), installs with a frozen
lockfile and --ignore-scripts so nothing reaches the network mid-install, and
runs as an unprivileged user with no port and no healthcheck — a probe writing
to stdout would corrupt the JSON-RPC stream. node:22-slim rather than
node:20-slim: package.json declares engines.node >= 22.

Also fills in root package.json description/keywords/repository/license/homepage,
which were absent.

Verified with no environment at all (env -i, PATH only):
  initialize -> serverInfo {"name":"mnema","title":"Mnema","version":"1.0.0"}
  tools/list -> 45 tools
  exit code 0
Re-run from a tree mirroring the image layout (source removed, pnpm symlinks
preserved) with the same result. `docker build` itself is NOT yet run — no
container runtime on the authoring machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants