-
-
Notifications
You must be signed in to change notification settings - Fork 7.3k
feat(server): remote authenticated MCP recall endpoint (POST /v1/mcp) #3070
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1bddad4
f0672f2
5ee4b3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| // Remote-recall MCP server factory. | ||
| // | ||
| // Builds a low-level MCP `Server` exposing the read tools (`search`, `context`, | ||
| // `recent`) over an injected `RecallBackend`. The backend is the only seam to | ||
| // storage, so this factory is pure and unit-testable without Postgres — the | ||
| // route layer (ServerV1PostgresRoutes) supplies a backend already scoped to the | ||
| // authenticated API key's team (and honoring any project scope). | ||
| // | ||
| // This is the same recall surface the stdio MCP server exposes via | ||
| // ServerBetaClient (`/v1/search`, `/v1/context`), so a hosted MCP link and the | ||
| // local CLI read identical data. The mutating tools are intentionally absent: | ||
| // a pasted recall link is read-only. | ||
|
|
||
| import { Server } from '@modelcontextprotocol/sdk/server/index.js'; | ||
| import { | ||
| CallToolRequestSchema, | ||
| ListToolsRequestSchema, | ||
| type CallToolResult, | ||
| type Tool, | ||
| } from '@modelcontextprotocol/sdk/types.js'; | ||
|
|
||
| export interface RecallBackend { | ||
| // Returns serialized observations (already shaped by serializeObservation), | ||
| // scoped to the caller's team. Throws if `projectId` is outside the key's scope. | ||
| search(args: { projectId: string; query: string; limit: number }): Promise<unknown[]>; | ||
| recent(args: { projectId: string; limit: number }): Promise<unknown[]>; | ||
| } | ||
|
|
||
| const SEARCH_LIMIT = { default: 20, max: 100 }; | ||
| const CONTEXT_LIMIT = { default: 10, max: 50 }; | ||
| const RECENT_LIMIT = { default: 20, max: 100 }; | ||
|
|
||
| const TOOLS: Tool[] = [ | ||
| { | ||
| name: 'search', | ||
| description: | ||
| 'Full-text search your claude-mem memory for a project. Returns matching observations (most relevant first).', | ||
| inputSchema: { | ||
| type: 'object', | ||
| properties: { | ||
| projectId: { type: 'string', description: 'Project to search within.' }, | ||
| query: { type: 'string', description: 'Search query.' }, | ||
| limit: { type: 'integer', minimum: 1, maximum: SEARCH_LIMIT.max }, | ||
| }, | ||
| required: ['projectId', 'query'], | ||
| }, | ||
| }, | ||
| { | ||
| name: 'context', | ||
| description: | ||
| 'Like search, but also returns a concatenated context string ready to inject into a prompt.', | ||
| inputSchema: { | ||
| type: 'object', | ||
| properties: { | ||
| projectId: { type: 'string', description: 'Project to search within.' }, | ||
| query: { type: 'string', description: 'Search query.' }, | ||
| limit: { type: 'integer', minimum: 1, maximum: CONTEXT_LIMIT.max }, | ||
| }, | ||
| required: ['projectId', 'query'], | ||
| }, | ||
| }, | ||
| { | ||
| name: 'recent', | ||
| description: 'List the most recent observations for a project (newest first).', | ||
| inputSchema: { | ||
| type: 'object', | ||
| properties: { | ||
| projectId: { type: 'string', description: 'Project to list.' }, | ||
| limit: { type: 'integer', minimum: 1, maximum: RECENT_LIMIT.max }, | ||
| }, | ||
| required: ['projectId'], | ||
| }, | ||
| }, | ||
| ]; | ||
|
|
||
| function clampLimit(raw: unknown, spec: { default: number; max: number }): number { | ||
| if (typeof raw !== 'number' || !Number.isFinite(raw)) return spec.default; | ||
| return Math.min(Math.max(1, Math.trunc(raw)), spec.max); | ||
| } | ||
|
|
||
| function requireString(args: Record<string, unknown>, key: string): string { | ||
| const value = args[key]; | ||
| if (typeof value !== 'string' || value.trim().length === 0) { | ||
| throw new Error(`"${key}" is required`); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| function jsonResult(payload: unknown): CallToolResult { | ||
| return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] }; | ||
| } | ||
|
|
||
| /** | ||
| * Build a read-only recall MCP server bound to `backend`. The caller owns the | ||
| * transport (stdio in the CLI, streamable-HTTP in Server Beta). | ||
| */ | ||
| export function createRecallMcpServer(backend: RecallBackend, version: string): Server { | ||
| const server = new Server( | ||
| { name: 'claude-mem', version }, | ||
| { capabilities: { tools: {} } }, | ||
| ); | ||
|
|
||
| server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); | ||
|
|
||
| server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => { | ||
| const name = request.params.name; | ||
| const args = (request.params.arguments ?? {}) as Record<string, unknown>; | ||
| try { | ||
| if (name === 'search') { | ||
| const observations = await backend.search({ | ||
| projectId: requireString(args, 'projectId'), | ||
| query: requireString(args, 'query'), | ||
| limit: clampLimit(args.limit, SEARCH_LIMIT), | ||
| }); | ||
| return jsonResult({ observations }); | ||
| } | ||
| if (name === 'context') { | ||
| const observations = await backend.search({ | ||
| projectId: requireString(args, 'projectId'), | ||
| query: requireString(args, 'query'), | ||
| limit: clampLimit(args.limit, CONTEXT_LIMIT), | ||
| }); | ||
| const context = observations | ||
| .map((o) => (o as { content?: unknown }).content) | ||
| .filter((t): t is string => typeof t === 'string' && t.length > 0) | ||
| .join('\n\n'); | ||
| return jsonResult({ observations, context }); | ||
| } | ||
| if (name === 'recent') { | ||
| const observations = await backend.recent({ | ||
| projectId: requireString(args, 'projectId'), | ||
| limit: clampLimit(args.limit, RECENT_LIMIT), | ||
| }); | ||
| return jsonResult({ observations }); | ||
| } | ||
| throw new Error(`Unknown tool: ${name}`); | ||
| } catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return { isError: true, content: [{ type: 'text', text: message }] }; | ||
| } | ||
| }); | ||
|
|
||
| return server; | ||
| } | ||
|
|
||
| export const RECALL_MCP_TOOLS = TOOLS; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,8 @@ import { PostgresObservationRepository } from '../../../storage/postgres/observa | |
| import { logger } from '../../../utils/logger.js'; | ||
| import { requirePostgresServerAuth } from '../../middleware/postgres-auth.js'; | ||
| import { requestIdMiddleware } from '../../middleware/request-id.js'; | ||
| import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; | ||
| import { createRecallMcpServer, type RecallBackend } from '../../mcp/recall-mcp-server.js'; | ||
| import type { ActiveServerBetaQueueManager } from '../../runtime/ActiveServerBetaQueueManager.js'; | ||
| import type { ServerBetaQueueManager } from '../../runtime/types.js'; | ||
| import { PostgresServerSessionsRepository } from '../../../storage/postgres/server-sessions.js'; | ||
|
|
@@ -29,6 +31,10 @@ import { EndSessionService } from '../../services/EndSessionService.js'; | |
|
|
||
| const SOURCE_ADAPTER_DEFAULT = 'api'; | ||
|
|
||
| declare const __DEFAULT_PACKAGE_VERSION__: string; | ||
| const MCP_SERVER_VERSION = | ||
| typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined' ? __DEFAULT_PACKAGE_VERSION__ : '0.0.0-dev'; | ||
|
|
||
| export interface ServerV1PostgresRoutesOptions { | ||
| pool: PostgresPool; | ||
| queueManager: ServerBetaQueueManager; | ||
|
|
@@ -926,6 +932,45 @@ export class ServerV1PostgresRoutes implements RouteHandler { | |
| } | ||
| }, | ||
| )); | ||
|
|
||
| // Remote authenticated MCP endpoint. The "secure MCP link" a user pastes | ||
| // into Claude Code (or any MCP client) to recall their cloud memory: | ||
| // claude mcp add --transport http claude-mem <base>/v1/mcp \ | ||
| // --header "Authorization: Bearer cm_..." | ||
| // Same readAuth (memories:read) + team/project scoping as /v1/search, so it | ||
| // reads identical data through identical guards. Stateless streamable-HTTP: | ||
| // one transport + server per request, bound to this key's team. | ||
| app.all('/v1/mcp', readAuth, this.asyncHandler(async (req, res) => { | ||
| const teamId = this.requireTeamId(req, res); | ||
| if (!teamId) return; | ||
| const projectScope = req.authContext?.projectId ?? null; | ||
| const repo = new PostgresObservationRepository(this.options.pool); | ||
| const assertProjectAllowed = (projectId: string): void => { | ||
| if (projectScope && projectScope !== projectId) { | ||
| throw new Error('API key is scoped to a different project'); | ||
| } | ||
| }; | ||
| const backend: RecallBackend = { | ||
| search: async ({ projectId, query, limit }) => { | ||
| assertProjectAllowed(projectId); | ||
| const rows = await repo.search({ projectId, teamId, query, limit }); | ||
| return rows.map(serializeObservation); | ||
| }, | ||
| recent: async ({ projectId, limit }) => { | ||
| assertProjectAllowed(projectId); | ||
| const rows = await repo.listByProject({ projectId, teamId, limit }); | ||
| return rows.map(serializeObservation); | ||
| }, | ||
| }; | ||
| const server = createRecallMcpServer(backend, MCP_SERVER_VERSION); | ||
| const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); | ||
| res.on('close', () => { | ||
| void transport.close(); | ||
| void server.close(); | ||
| }); | ||
| await server.connect(transport); | ||
| await transport.handleRequest(req, res, req.body); | ||
| })); | ||
|
Comment on lines
+943
to
+973
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| } | ||
|
|
||
| private async auditRead( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| // Unit tests for the remote-recall MCP server factory. The factory is pure | ||
| // (storage is injected as a RecallBackend), so these run with no Postgres — | ||
| // they drive a real MCP Client over an in-memory transport, exactly how a | ||
| // hosted client would, and assert tool listing, arg forwarding/clamping, | ||
| // context packing, and that backend failures surface as tool errors (not | ||
| // transport throws). | ||
|
|
||
| import { describe, it, expect } from 'bun:test'; | ||
| import { Client } from '@modelcontextprotocol/sdk/client/index.js'; | ||
| import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; | ||
| import { createRecallMcpServer, type RecallBackend } from '../../../src/server/mcp/recall-mcp-server.js'; | ||
|
|
||
| interface Recorded { | ||
| search: Array<{ projectId: string; query: string; limit: number }>; | ||
| recent: Array<{ projectId: string; limit: number }>; | ||
| } | ||
|
|
||
| function makeBackend(overrides: Partial<RecallBackend> = {}): { backend: RecallBackend; calls: Recorded } { | ||
| const calls: Recorded = { search: [], recent: [] }; | ||
| const backend: RecallBackend = { | ||
| search: async (args) => { | ||
| calls.search.push(args); | ||
| return [ | ||
| { id: 'o1', content: 'alpha' }, | ||
| { id: 'o2', content: 'beta' }, | ||
| ]; | ||
| }, | ||
| recent: async (args) => { | ||
| calls.recent.push(args); | ||
| return [{ id: 'r1', content: 'recent-one' }]; | ||
| }, | ||
| ...overrides, | ||
| }; | ||
| return { backend, calls }; | ||
| } | ||
|
|
||
| async function connectClient(backend: RecallBackend): Promise<Client> { | ||
| const server = createRecallMcpServer(backend, '9.9.9'); | ||
| const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); | ||
| const client = new Client({ name: 'test-client', version: '0' }, { capabilities: {} }); | ||
| await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); | ||
| return client; | ||
| } | ||
|
|
||
| function textOf(result: { content: unknown }): string { | ||
| const first = (result.content as Array<{ type: string; text?: string }>)[0]; | ||
| return first?.text ?? ''; | ||
| } | ||
|
|
||
| describe('createRecallMcpServer', () => { | ||
| it('lists exactly the read-only recall tools', async () => { | ||
| const client = await connectClient(makeBackend().backend); | ||
| const { tools } = await client.listTools(); | ||
| expect(tools.map((t) => t.name).sort()).toEqual(['context', 'recent', 'search']); | ||
| await client.close(); | ||
| }); | ||
|
|
||
| it('search forwards args, clamps the limit, and returns observations', async () => { | ||
| const { backend, calls } = makeBackend(); | ||
| const client = await connectClient(backend); | ||
| const res = await client.callTool({ | ||
| name: 'search', | ||
| arguments: { projectId: 'p1', query: 'hello', limit: 9999 }, | ||
| }); | ||
| expect(calls.search[0]).toEqual({ projectId: 'p1', query: 'hello', limit: 100 }); | ||
| expect(JSON.parse(textOf(res)).observations).toHaveLength(2); | ||
| await client.close(); | ||
| }); | ||
|
|
||
| it('context packs the observation contents into a joined string', async () => { | ||
| const client = await connectClient(makeBackend().backend); | ||
| const res = await client.callTool({ name: 'context', arguments: { projectId: 'p1', query: 'hi' } }); | ||
| expect(JSON.parse(textOf(res)).context).toBe('alpha\n\nbeta'); | ||
| await client.close(); | ||
| }); | ||
|
|
||
| it('recent calls the recent backend with the default limit', async () => { | ||
| const { backend, calls } = makeBackend(); | ||
| const client = await connectClient(backend); | ||
| await client.callTool({ name: 'recent', arguments: { projectId: 'p2' } }); | ||
| expect(calls.recent[0]).toEqual({ projectId: 'p2', limit: 20 }); | ||
| await client.close(); | ||
| }); | ||
|
|
||
| it('a missing required arg is a tool error, not a transport throw', async () => { | ||
| const client = await connectClient(makeBackend().backend); | ||
| const res = await client.callTool({ name: 'search', arguments: { projectId: 'p1' } }); | ||
| expect(res.isError).toBe(true); | ||
| await client.close(); | ||
| }); | ||
|
|
||
| it('a backend project-scope rejection surfaces as a tool error', async () => { | ||
| const { backend } = makeBackend({ | ||
| search: async () => { | ||
| throw new Error('API key is scoped to a different project'); | ||
| }, | ||
| }); | ||
| const client = await connectClient(backend); | ||
| const res = await client.callTool({ name: 'search', arguments: { projectId: 'other', query: 'x' } }); | ||
| expect(res.isError).toBe(true); | ||
| expect(textOf(res)).toContain('different project'); | ||
| await client.close(); | ||
| }); | ||
|
|
||
| it('an unknown tool is a tool error', async () => { | ||
| const client = await connectClient(makeBackend().backend); | ||
| const res = await client.callTool({ name: 'nope', arguments: {} }); | ||
| expect(res.isError).toBe(true); | ||
| await client.close(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
app.allexposes the MCP endpoint to all HTTP methodsThe MCP streamable-HTTP protocol only uses POST (JSON-RPC) and GET (SSE). Using
app.alladditionally routes DELETE, PUT, PATCH, HEAD, and OPTIONS through the full auth middleware before the transport can reject them. CORS preflight OPTIONS requests will fail with 401 because browsers omit theAuthorizationheader on preflights — silently breaking any browser-based MCP client. Restricting to the two methods the protocol actually uses is safer.