Skip to content

Commit 1bddad4

Browse files
feat(server): remote authenticated MCP recall endpoint (POST /v1/mcp)
Adds the "secure authenticated MCP link" a user pastes into Claude Code (or any MCP client) to recall their cloud memory from Server Beta: claude mcp add --transport http claude-mem <base>/v1/mcp \ --header "Authorization: Bearer cm_..." Design — reuse, not rebuild: - Auth: the existing `readAuth` (requirePostgresServerAuth, scope memories:read). No new auth; the cm_ API key's team (and project scope) bound the recall. - Storage: the existing PostgresObservationRepository.search / .listByProject, the same code path as /v1/search and /v1/context. Identical data, identical guards. - Transport: MCP streamable-HTTP (SDK 1.29), stateless — one transport + server per request, so it slots into Express 5 with no session state. `src/server/mcp/recall-mcp-server.ts` is a pure factory (storage injected as a RecallBackend) exposing the read-only tools search / context / recent. The route in ServerV1PostgresRoutes supplies a team-scoped backend. Mutating tools are intentionally omitted — a pasted recall link is read-only. Tests: tests/server/mcp/recall-mcp-server.test.ts drives a real MCP client over an in-memory transport (no Postgres) — tool listing, arg forwarding/clamping, context packing, and backend failures surfacing as tool errors. 7/7 pass; full tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3fe0725 commit 1bddad4

3 files changed

Lines changed: 306 additions & 0 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
//
3+
// Remote-recall MCP server factory.
4+
//
5+
// Builds a low-level MCP `Server` exposing the read tools (`search`, `context`,
6+
// `recent`) over an injected `RecallBackend`. The backend is the only seam to
7+
// storage, so this factory is pure and unit-testable without Postgres — the
8+
// route layer (ServerV1PostgresRoutes) supplies a backend already scoped to the
9+
// authenticated API key's team (and honoring any project scope).
10+
//
11+
// This is the same recall surface the stdio MCP server exposes via
12+
// ServerBetaClient (`/v1/search`, `/v1/context`), so a hosted MCP link and the
13+
// local CLI read identical data. The mutating tools are intentionally absent:
14+
// a pasted recall link is read-only.
15+
16+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
17+
import {
18+
CallToolRequestSchema,
19+
ListToolsRequestSchema,
20+
type CallToolResult,
21+
type Tool,
22+
} from '@modelcontextprotocol/sdk/types.js';
23+
24+
export interface RecallBackend {
25+
// Returns serialized observations (already shaped by serializeObservation),
26+
// scoped to the caller's team. Throws if `projectId` is outside the key's scope.
27+
search(args: { projectId: string; query: string; limit: number }): Promise<unknown[]>;
28+
recent(args: { projectId: string; limit: number }): Promise<unknown[]>;
29+
}
30+
31+
const SEARCH_LIMIT = { default: 20, max: 100 };
32+
const CONTEXT_LIMIT = { default: 10, max: 50 };
33+
const RECENT_LIMIT = { default: 20, max: 100 };
34+
35+
const TOOLS: Tool[] = [
36+
{
37+
name: 'search',
38+
description:
39+
'Full-text search your claude-mem memory for a project. Returns matching observations (most relevant first).',
40+
inputSchema: {
41+
type: 'object',
42+
properties: {
43+
projectId: { type: 'string', description: 'Project to search within.' },
44+
query: { type: 'string', description: 'Search query.' },
45+
limit: { type: 'integer', minimum: 1, maximum: SEARCH_LIMIT.max },
46+
},
47+
required: ['projectId', 'query'],
48+
},
49+
},
50+
{
51+
name: 'context',
52+
description:
53+
'Like search, but also returns a concatenated context string ready to inject into a prompt.',
54+
inputSchema: {
55+
type: 'object',
56+
properties: {
57+
projectId: { type: 'string', description: 'Project to search within.' },
58+
query: { type: 'string', description: 'Search query.' },
59+
limit: { type: 'integer', minimum: 1, maximum: CONTEXT_LIMIT.max },
60+
},
61+
required: ['projectId', 'query'],
62+
},
63+
},
64+
{
65+
name: 'recent',
66+
description: 'List the most recent observations for a project (newest first).',
67+
inputSchema: {
68+
type: 'object',
69+
properties: {
70+
projectId: { type: 'string', description: 'Project to list.' },
71+
limit: { type: 'integer', minimum: 1, maximum: RECENT_LIMIT.max },
72+
},
73+
required: ['projectId'],
74+
},
75+
},
76+
];
77+
78+
function clampLimit(raw: unknown, spec: { default: number; max: number }): number {
79+
if (typeof raw !== 'number' || !Number.isFinite(raw)) return spec.default;
80+
return Math.min(Math.max(1, Math.trunc(raw)), spec.max);
81+
}
82+
83+
function requireString(args: Record<string, unknown>, key: string): string {
84+
const value = args[key];
85+
if (typeof value !== 'string' || value.trim().length === 0) {
86+
throw new Error(`"${key}" is required`);
87+
}
88+
return value;
89+
}
90+
91+
function jsonResult(payload: unknown): CallToolResult {
92+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
93+
}
94+
95+
/**
96+
* Build a read-only recall MCP server bound to `backend`. The caller owns the
97+
* transport (stdio in the CLI, streamable-HTTP in Server Beta).
98+
*/
99+
export function createRecallMcpServer(backend: RecallBackend, version: string): Server {
100+
const server = new Server(
101+
{ name: 'claude-mem', version },
102+
{ capabilities: { tools: {} } },
103+
);
104+
105+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
106+
107+
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
108+
const name = request.params.name;
109+
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
110+
try {
111+
if (name === 'search') {
112+
const observations = await backend.search({
113+
projectId: requireString(args, 'projectId'),
114+
query: requireString(args, 'query'),
115+
limit: clampLimit(args.limit, SEARCH_LIMIT),
116+
});
117+
return jsonResult({ observations });
118+
}
119+
if (name === 'context') {
120+
const observations = await backend.search({
121+
projectId: requireString(args, 'projectId'),
122+
query: requireString(args, 'query'),
123+
limit: clampLimit(args.limit, CONTEXT_LIMIT),
124+
});
125+
const context = observations
126+
.map((o) => (o as { content?: unknown }).content)
127+
.filter((t): t is string => typeof t === 'string' && t.length > 0)
128+
.join('\n\n');
129+
return jsonResult({ observations, context });
130+
}
131+
if (name === 'recent') {
132+
const observations = await backend.recent({
133+
projectId: requireString(args, 'projectId'),
134+
limit: clampLimit(args.limit, RECENT_LIMIT),
135+
});
136+
return jsonResult({ observations });
137+
}
138+
throw new Error(`Unknown tool: ${name}`);
139+
} catch (error) {
140+
const message = error instanceof Error ? error.message : String(error);
141+
return { isError: true, content: [{ type: 'text', text: message }] };
142+
}
143+
});
144+
145+
return server;
146+
}
147+
148+
export const RECALL_MCP_TOOLS = TOOLS;

src/server/routes/v1/ServerV1PostgresRoutes.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { PostgresObservationRepository } from '../../../storage/postgres/observa
2020
import { logger } from '../../../utils/logger.js';
2121
import { requirePostgresServerAuth } from '../../middleware/postgres-auth.js';
2222
import { requestIdMiddleware } from '../../middleware/request-id.js';
23+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
24+
import { createRecallMcpServer, type RecallBackend } from '../../mcp/recall-mcp-server.js';
2325
import type { ActiveServerBetaQueueManager } from '../../runtime/ActiveServerBetaQueueManager.js';
2426
import type { ServerBetaQueueManager } from '../../runtime/types.js';
2527
import { PostgresServerSessionsRepository } from '../../../storage/postgres/server-sessions.js';
@@ -29,6 +31,10 @@ import { EndSessionService } from '../../services/EndSessionService.js';
2931

3032
const SOURCE_ADAPTER_DEFAULT = 'api';
3133

34+
declare const __DEFAULT_PACKAGE_VERSION__: string;
35+
const MCP_SERVER_VERSION =
36+
typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined' ? __DEFAULT_PACKAGE_VERSION__ : '0.0.0-dev';
37+
3238
export interface ServerV1PostgresRoutesOptions {
3339
pool: PostgresPool;
3440
queueManager: ServerBetaQueueManager;
@@ -926,6 +932,45 @@ export class ServerV1PostgresRoutes implements RouteHandler {
926932
}
927933
},
928934
));
935+
936+
// Remote authenticated MCP endpoint. The "secure MCP link" a user pastes
937+
// into Claude Code (or any MCP client) to recall their cloud memory:
938+
// claude mcp add --transport http claude-mem <base>/v1/mcp \
939+
// --header "Authorization: Bearer cm_..."
940+
// Same readAuth (memories:read) + team/project scoping as /v1/search, so it
941+
// reads identical data through identical guards. Stateless streamable-HTTP:
942+
// one transport + server per request, bound to this key's team.
943+
app.all('/v1/mcp', readAuth, this.asyncHandler(async (req, res) => {
944+
const teamId = this.requireTeamId(req, res);
945+
if (!teamId) return;
946+
const projectScope = req.authContext?.projectId ?? null;
947+
const repo = new PostgresObservationRepository(this.options.pool);
948+
const assertProjectAllowed = (projectId: string): void => {
949+
if (projectScope && projectScope !== projectId) {
950+
throw new Error('API key is scoped to a different project');
951+
}
952+
};
953+
const backend: RecallBackend = {
954+
search: async ({ projectId, query, limit }) => {
955+
assertProjectAllowed(projectId);
956+
const rows = await repo.search({ projectId, teamId, query, limit });
957+
return rows.map(serializeObservation);
958+
},
959+
recent: async ({ projectId, limit }) => {
960+
assertProjectAllowed(projectId);
961+
const rows = await repo.listByProject({ projectId, teamId, limit });
962+
return rows.map(serializeObservation);
963+
},
964+
};
965+
const server = createRecallMcpServer(backend, MCP_SERVER_VERSION);
966+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
967+
res.on('close', () => {
968+
void transport.close();
969+
void server.close();
970+
});
971+
await server.connect(transport);
972+
await transport.handleRequest(req, res, req.body);
973+
}));
929974
}
930975

931976
private async auditRead(
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
//
3+
// Unit tests for the remote-recall MCP server factory. The factory is pure
4+
// (storage is injected as a RecallBackend), so these run with no Postgres —
5+
// they drive a real MCP Client over an in-memory transport, exactly how a
6+
// hosted client would, and assert tool listing, arg forwarding/clamping,
7+
// context packing, and that backend failures surface as tool errors (not
8+
// transport throws).
9+
10+
import { describe, it, expect } from 'bun:test';
11+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
12+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
13+
import { createRecallMcpServer, type RecallBackend } from '../../../src/server/mcp/recall-mcp-server.js';
14+
15+
interface Recorded {
16+
search: Array<{ projectId: string; query: string; limit: number }>;
17+
recent: Array<{ projectId: string; limit: number }>;
18+
}
19+
20+
function makeBackend(overrides: Partial<RecallBackend> = {}): { backend: RecallBackend; calls: Recorded } {
21+
const calls: Recorded = { search: [], recent: [] };
22+
const backend: RecallBackend = {
23+
search: async (args) => {
24+
calls.search.push(args);
25+
return [
26+
{ id: 'o1', content: 'alpha' },
27+
{ id: 'o2', content: 'beta' },
28+
];
29+
},
30+
recent: async (args) => {
31+
calls.recent.push(args);
32+
return [{ id: 'r1', content: 'recent-one' }];
33+
},
34+
...overrides,
35+
};
36+
return { backend, calls };
37+
}
38+
39+
async function connectClient(backend: RecallBackend): Promise<Client> {
40+
const server = createRecallMcpServer(backend, '9.9.9');
41+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
42+
const client = new Client({ name: 'test-client', version: '0' }, { capabilities: {} });
43+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
44+
return client;
45+
}
46+
47+
function textOf(result: { content: unknown }): string {
48+
const first = (result.content as Array<{ type: string; text?: string }>)[0];
49+
return first?.text ?? '';
50+
}
51+
52+
describe('createRecallMcpServer', () => {
53+
it('lists exactly the read-only recall tools', async () => {
54+
const client = await connectClient(makeBackend().backend);
55+
const { tools } = await client.listTools();
56+
expect(tools.map((t) => t.name).sort()).toEqual(['context', 'recent', 'search']);
57+
await client.close();
58+
});
59+
60+
it('search forwards args, clamps the limit, and returns observations', async () => {
61+
const { backend, calls } = makeBackend();
62+
const client = await connectClient(backend);
63+
const res = await client.callTool({
64+
name: 'search',
65+
arguments: { projectId: 'p1', query: 'hello', limit: 9999 },
66+
});
67+
expect(calls.search[0]).toEqual({ projectId: 'p1', query: 'hello', limit: 100 });
68+
expect(JSON.parse(textOf(res)).observations).toHaveLength(2);
69+
await client.close();
70+
});
71+
72+
it('context packs the observation contents into a joined string', async () => {
73+
const client = await connectClient(makeBackend().backend);
74+
const res = await client.callTool({ name: 'context', arguments: { projectId: 'p1', query: 'hi' } });
75+
expect(JSON.parse(textOf(res)).context).toBe('alpha\n\nbeta');
76+
await client.close();
77+
});
78+
79+
it('recent calls the recent backend with the default limit', async () => {
80+
const { backend, calls } = makeBackend();
81+
const client = await connectClient(backend);
82+
await client.callTool({ name: 'recent', arguments: { projectId: 'p2' } });
83+
expect(calls.recent[0]).toEqual({ projectId: 'p2', limit: 20 });
84+
await client.close();
85+
});
86+
87+
it('a missing required arg is a tool error, not a transport throw', async () => {
88+
const client = await connectClient(makeBackend().backend);
89+
const res = await client.callTool({ name: 'search', arguments: { projectId: 'p1' } });
90+
expect(res.isError).toBe(true);
91+
await client.close();
92+
});
93+
94+
it('a backend project-scope rejection surfaces as a tool error', async () => {
95+
const { backend } = makeBackend({
96+
search: async () => {
97+
throw new Error('API key is scoped to a different project');
98+
},
99+
});
100+
const client = await connectClient(backend);
101+
const res = await client.callTool({ name: 'search', arguments: { projectId: 'other', query: 'x' } });
102+
expect(res.isError).toBe(true);
103+
expect(textOf(res)).toContain('different project');
104+
await client.close();
105+
});
106+
107+
it('an unknown tool is a tool error', async () => {
108+
const client = await connectClient(makeBackend().backend);
109+
const res = await client.callTool({ name: 'nope', arguments: {} });
110+
expect(res.isError).toBe(true);
111+
await client.close();
112+
});
113+
});

0 commit comments

Comments
 (0)