Skip to content
Open
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
80 changes: 80 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# ── Mnema MCP server (stdio) ──────────────────────────────────────────────────
#
# A self-contained MCP server that starts with NOTHING: no database, no Redis,
# no environment variables, no sidecars. `docker run -i` it and drive the
# protocol on stdin/stdout.
#
# docker build -t mnema-mcp .
# docker run -i --rm mnema-mcp
#
# `initialize` and `tools/list` are answered offline from a manifest captured at
# build time (packages/mcp-stdio/src/tools.generated.json). `tools/call` needs a
# workspace and is configured at run time — its absence degrades a call, never
# the boot:
#
# docker run -i --rm -e MNEMA_API_KEY=... -e MNEMA_API_URL=... mnema-mcp
#
# For the full self-hosted product (API + web + Postgres + Redis + workers),
# this is the wrong file — use docker-compose.yml. This image is the MCP
# endpoint alone.
#
# node:22-slim, NOT node:20-slim: package.json declares `engines.node >= 22` and
# .nvmrc pins 22. A 20 base would ship a runtime the repo itself says it does
# not support.

# ── Build stage ───────────────────────────────────────────────────────────────
FROM node:22-slim AS builder

RUN corepack enable && corepack prepare pnpm@10.23.0 --activate

WORKDIR /app

# Workspace manifests first, so a source-only edit reuses the install layer.
# Every workspace member's package.json must be present or --frozen-lockfile
# considers the lockfile out of date, even when filtering to one package.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/mcp-stdio/package.json ./packages/mcp-stdio/
COPY packages/schema/package.json ./packages/schema/
COPY packages/shared/package.json ./packages/shared/
COPY apps/api/package.json ./apps/api/
COPY apps/web/package.json ./apps/web/

# --ignore-scripts: no package may run a postinstall in this build. Several
# transitive deps of the wider workspace fetch binaries from the network when
# allowed to (playwright browsers, most visibly). The stdio server needs none of
# them, and a build that reaches the network mid-install is not reproducible.
# --filter ...: the MCP server depends only on @modelcontextprotocol/sdk; the
# other ~90 production deps of apps/api are never installed.
RUN pnpm install --frozen-lockfile --ignore-scripts --filter @boppl/mcp-stdio...

COPY tsconfig.base.json ./
COPY packages/mcp-stdio/ ./packages/mcp-stdio/

RUN pnpm --filter @boppl/mcp-stdio build

# Re-resolve to production dependencies only, dropping typescript and @types.
RUN pnpm install --frozen-lockfile --ignore-scripts --prod --filter @boppl/mcp-stdio...

# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM node:22-slim AS runner

WORKDIR /app
ENV NODE_ENV=production

# The compiled server, its shipped tool manifest, and the SDK. Nothing else —
# no source, no toolchain, no pnpm.
#
# The workspace path is preserved exactly: pnpm links a package's node_modules
# entries to ../../node_modules/.pnpm/... with RELATIVE symlinks. Flattening
# packages/mcp-stdio/node_modules to /app/node_modules would leave every one of
# them pointing one directory above the image root, and the server would fail to
# resolve the SDK at startup.
COPY --from=builder --chown=node:node /app/node_modules/.pnpm ./node_modules/.pnpm
COPY --from=builder --chown=node:node /app/packages/mcp-stdio ./packages/mcp-stdio

# Unprivileged by default; nothing is written to disk at run time.
USER node

# No HEALTHCHECK and no EXPOSE on purpose: this process speaks stdio, has no
# port, and a probe that wrote to stdout would corrupt the JSON-RPC stream.
ENTRYPOINT ["node", "packages/mcp-stdio/dist/index.js"]
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ in `.env` — bring your own keys. Both stay disabled until set.
- **[Connect an AI client](./docs/connect/)** — [Claude](./docs/connect/claude.md), [ChatGPT](./docs/connect/chatgpt.md), [Cursor](./docs/connect/cursor.md), [Windsurf](./docs/connect/windsurf.md), [Antigravity](./docs/connect/antigravity.md)
- **[Embed Mnema in your own app](./docs/connect/api-integration.md)** — REST API + an API key
- **[REST API reference](./docs/api/)** — every public endpoint, auth, scopes, examples
- **[Run the MCP server over stdio](./docs/connect/stdio.md)** — zero-configuration `docker run -i`, for desktop clients and offline tool discovery

## What's in the core (this repo) vs. licensed

Expand Down
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"consolidate:domain": "tsx src/scripts/consolidate-domain-workspace.ts",
"rekey:secretbox": "tsx src/scripts/rekey-secret-box.ts"
"rekey:secretbox": "tsx src/scripts/rekey-secret-box.ts",
"mcp:manifest": "tsx src/scripts/generate-mcp-manifest.ts"
},
"dependencies": {
"@ai-sdk/google": "^3.0.90",
Expand Down
144 changes: 144 additions & 0 deletions apps/api/src/scripts/generate-mcp-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Generate the static MCP tool manifest consumed by `@boppl/mcp-stdio`.
*
* ⭐ WHY THIS EXISTS. The stdio bridge must answer `initialize` and
* `tools/list` in a bare container — no Postgres, no Redis, no env vars. It
* cannot import the API to find out what the tools are: importing
* `mcp/server.ts` pulls in `config/env.ts` (which calls `process.exit(1)` when
* a variable is missing) and three eager ioredis clients that dial on import.
* So the catalogue is captured HERE, at build time, where those dependencies
* are allowed to exist, and shipped as data.
*
* The capture is a real MCP `tools/list` over an in-memory transport rather
* than a read of the spec constants: it is the same code path a client drives,
* so what ships is what the hosted server actually advertises — including the
* App tools that `createMcpServer` registers directly.
*
* pnpm --filter @boppl/api mcp:manifest
*
* Drift is caught by mcp-stdio-manifest.test.ts, which regenerates and
* compares. Regenerate whenever a tool is added, removed, or re-described.
*/
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';

// Placeholder env, set BEFORE the API module graph is imported. Nothing here is
// dialled: the generator never calls a tool, and it exits before ioredis
// finishes retrying. These values only have to satisfy the zod schema in
// config/env.ts — they are never used to reach a real service.
const FILLER = 'x'.repeat(48);
const STUB_ENV: Record<string, string> = {
DATABASE_URL: 'postgres://manifest:manifest@127.0.0.1:5432/manifest',
REDIS_URL: 'redis://127.0.0.1:6379',
WORKOS_COOKIE_PASSWORD: FILLER,
COLLAB_INTERNAL_SECRET: FILLER,
SECRETBOX_MASTER_KEY: FILLER,
API_INTERNAL_SECRET: FILLER,
JWT_SECRET: FILLER,
JWT_ISSUER: 'https://manifest.invalid',
JWT_AUDIENCE: 'https://manifest.invalid',
VOYAGE_API_KEY: 'manifest',
GEMINI_API_KEY: 'manifest',
OAUTH_ISSUER: 'https://manifest.invalid',
OAUTH_PRIVATE_KEY_PATH: '/dev/null',
OAUTH_PUBLIC_KEY_PATH: '/dev/null',
WORKOS_API_KEY: 'manifest',
WORKOS_CLIENT_ID: 'manifest',
MCP_BASE_URL: 'https://api.theboringpeople.in',
};
for (const [k, v] of Object.entries(STUB_ENV)) process.env[k] ??= v;

// ioredis dials on construction (queue/embeddings.ts, queue/pdf-generation.ts,
// mcp/tools/query-embedding.ts). Those sockets are irrelevant to a schema dump,
// but an unhandled 'error' event would still reach the console and could take
// the process down. Swallow ONLY connection-refused noise, and say so.
let suppressed = 0;
process.on('uncaughtException', (err: NodeJS.ErrnoException) => {
const msg = String(err?.message ?? err);
if (err?.code === 'ECONNREFUSED' || msg.includes('ECONNREFUSED')) {
suppressed++;
return;
}
throw err;
});

async function main(): Promise<void> {
const { createMcpServer } = await import('../mcp/server.js');
const { mcpConfig } = await import('../mcp/config.js');
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js');

// A caller with no dev tools and no project scoping — the default catalogue
// any ordinary client sees. Dev tools stay out on purpose: they are gated per
// token at runtime and must not appear in a public directory listing.
const server = createMcpServer({
user_id: '00000000-0000-0000-0000-000000000000',
tenant_id: '00000000-0000-0000-0000-000000000000',
email: 'manifest@invalid',
scopes: ['workspace:read', 'workspace:write'],
jwt_id: null,
devToolsEnabled: false,
project_id: null,
});

const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'manifest-generator', version: '1.0.0' });
await Promise.all([server.connect(serverSide), client.connect(clientSide)]);

const { tools } = await client.listTools();
await client.close();
await server.close();

if (tools.length === 0) {
// A zero-tool manifest would ship a server that looks alive and does
// nothing — the exact failure this file exists to prevent.
throw new Error('refusing to write an empty manifest: tools/list returned 0 tools');
}

// `__ui_probe` is a development probe for the MCP Apps protocol, registered
// unconditionally in mcp/server.ts and therefore live in the hosted
// tools/list. It is not a product capability and must not be advertised in a
// public directory listing. Excluded here rather than unregistered upstream:
// removing it from the server is a separate decision, not a packaging one.
const EXCLUDED = new Set(['__ui_probe']);
const published = tools.filter((t) => !EXCLUDED.has(t.name));
process.stderr.write(
`tools/list returned ${tools.length}; publishing ${published.length} ` +
`(excluded: ${tools.length - published.length ? [...EXCLUDED].join(', ') : 'none'})\n`,
);

const manifest = {
_comment:
'GENERATED by apps/api/src/scripts/generate-mcp-manifest.ts — do not edit by hand. ' +
'Run: pnpm --filter @boppl/api mcp:manifest',
serverName: mcpConfig.serverName,
serverTitle: mcpConfig.serverTitle,
serverVersion: mcpConfig.serverVersion,
instructions: mcpConfig.serverDescription,
tools: published
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((t) => ({
name: t.name,
...(t.title ? { title: t.title } : {}),
description: t.description ?? '',
inputSchema: t.inputSchema,
...(t.annotations ? { annotations: t.annotations } : {}),
})),
};

// Optional argv[2] lets the drift test write somewhere disposable and diff
// against the committed file, so the check exercises this exact generator
// rather than a reimplementation of it.
const out =
process.argv[2] ??
resolve(import.meta.dirname, '../../../../packages/mcp-stdio/src/tools.generated.json');
writeFileSync(out, JSON.stringify(manifest, null, 2) + '\n');
process.stderr.write(
`wrote ${manifest.tools.length} tools → ${out}` +
(suppressed ? ` (${suppressed} ECONNREFUSED events suppressed)\n` : '\n'),
);
process.exit(0);
}

void main();
60 changes: 60 additions & 0 deletions apps/api/src/tests/mcp-stdio-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* ⚠️ The shipped tool manifest must match the server that ships it.
*
* packages/mcp-stdio serves tools/list from a JSON file captured at build time,
* because it has to answer with no database and no environment. That buys
* offline introspection at the cost of a second copy of the truth — and a
* second copy rots. This regenerates the manifest with the real generator and
* fails if the committed file has drifted.
*
* When it fails, the fix is to regenerate, not to hand-edit the JSON:
* pnpm --filter @boppl/api mcp:manifest
*/
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';

const REPO_ROOT = resolve(import.meta.dirname, '../../../..');
const COMMITTED = join(REPO_ROOT, 'packages/mcp-stdio/src/tools.generated.json');
const GENERATOR = join(REPO_ROOT, 'apps/api/src/scripts/generate-mcp-manifest.ts');

describe('mcp-stdio tool manifest', () => {
it('is byte-identical to a fresh capture from the live server definition', () => {
const dir = mkdtempSync(join(tmpdir(), 'mnema-manifest-'));
const fresh = join(dir, 'tools.generated.json');
try {
execFileSync(join(REPO_ROOT, 'apps/api/node_modules/.bin/tsx'), [GENERATOR, fresh], {
cwd: REPO_ROOT,
// The generator supplies its own placeholder env. An inherited one with
// real values would not change the schemas, but it could change which
// optional integrations register — keep the capture deterministic.
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '' },
stdio: ['ignore', 'ignore', 'pipe'],
timeout: 120_000,
});
expect(readFileSync(fresh, 'utf8')).toBe(readFileSync(COMMITTED, 'utf8'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 180_000);

it('⚠️ ships no dev or probe tools — this file becomes a public directory listing', () => {
const manifest = JSON.parse(readFileSync(COMMITTED, 'utf8')) as { tools: { name: string }[] };
const names = manifest.tools.map((t) => t.name);
expect(names.length).toBeGreaterThan(0);
expect(names).not.toContain('__ui_probe');
expect(names.filter((n) => n.startsWith('__'))).toEqual([]);
});

it('every tool carries a description and an object input schema', () => {
const manifest = JSON.parse(readFileSync(COMMITTED, 'utf8')) as {
tools: { name: string; description: string; inputSchema: { type?: string } }[];
};
for (const t of manifest.tools) {
expect(t.description, `${t.name} has no description`).toBeTruthy();
expect(t.inputSchema?.type, `${t.name} input schema is not an object`).toBe('object');
}
});
});
94 changes: 94 additions & 0 deletions docs/connect/stdio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Run Mnema's MCP server over stdio

The hosted endpoint at `https://api.theboringpeople.in/mcp` speaks
streamable-HTTP and is behind OAuth 2.1. That is the right transport for
Claude, ChatGPT and other remote clients, but two things cannot use it:

- a desktop client that expects to **spawn a process** and talk on stdin/stdout;
- a directory or crawler that wants to **read the tool catalogue before
authenticating** — which is what discovery means.

`packages/mcp-stdio` covers both. It starts with nothing: no database, no
Redis, no environment variables.

```bash
docker build -t mnema-mcp .
docker run -i --rm mnema-mcp
```

That container answers `initialize` and `tools/list` immediately. Calling a tool
needs a workspace:

```bash
docker run -i --rm \
-e MNEMA_API_URL=https://api.theboringpeople.in \
-e MNEMA_API_KEY=mnema_api_... \
mnema-mcp
```

`MNEMA_API_KEY` comes from **Settings → Access** in your workspace. Point
`MNEMA_API_URL` at your own instance when self-hosting; it defaults to the
hosted API.

### Claude Desktop

```json
{
"mcpServers": {
"mnema": {
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "MNEMA_API_KEY", "mnema-mcp"],
"env": { "MNEMA_API_KEY": "mnema_api_..." }
}
}
}
```

## Why it is a separate process

The Fastify API cannot serve this, and the reason is worth stating plainly
because it is the whole design constraint.

Importing `apps/api/src/mcp/server.ts` pulls in, before any transport exists:

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

Reached by these chains:

```
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
```

So the stdio server imports **none** of it. Its `tools/list` is served from
`packages/mcp-stdio/src/tools.generated.json`, captured at build time from the
real `createMcpServer()` over an in-memory transport
(`apps/api/src/scripts/generate-mcp-manifest.ts`), and `tools/call` is forwarded
over HTTPS to a workspace. Everything that needs a service is on the call path,
established on first use.

A missing dependency therefore 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`).
It never stops the server from starting.

## Keeping the manifest honest

A build-time capture is a second copy of the truth, and second copies rot.
`apps/api/src/tests/mcp-stdio-manifest.test.ts` regenerates it and fails on any
drift. After adding, removing or re-describing a tool:

```bash
pnpm --filter @boppl/api mcp:manifest
```

> stdout carries the JSON-RPC stream and nothing else. Every diagnostic the
> stdio server writes goes to stderr — a stray `console.log` corrupts the
> protocol.
Loading
Loading