feat(server): remote authenticated MCP recall endpoint (POST /v1/mcp)#3070
feat(server): remote authenticated MCP recall endpoint (POST /v1/mcp)#3070ChenglinWei97 wants to merge 3 commits into
Conversation
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>
Greptile SummaryThis PR adds a remote authenticated MCP recall endpoint for Server Beta. The main changes are:
Confidence Score: 4/5The endpoint is read-only and reuses existing authentication and storage paths, with no outstanding code issues identified. Coverage includes unit tests for MCP tool behavior and route-level auth checks, while a live transport smoke test against the deployed environment remains the main remaining integration risk.
What T-Rex did
Reviews (2): Last reviewed commit: "docs(api): document the /v1/mcp remote M..." | Re-trigger Greptile |
| 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); | ||
| })); |
There was a problem hiding this comment.
Audit logging is absent from the MCP read path
/v1/search and /v1/context both call this.auditRead(...) immediately after a successful Postgres query, recording the actor, project, query, and returned observation IDs. The /v1/mcp handler skips this entirely. Because the backend closures (search / recent) are called deep inside the MCP SDK dispatch loop — not from this handler — there is no natural place to attach a post-query hook without threading audit context down into RecallBackend. As a result every observation read through the MCP endpoint leaves no audit trail, even though the PR description explicitly states it "reads identical data through identical guards."
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!
| // 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) => { |
There was a problem hiding this comment.
app.all exposes the MCP endpoint to all HTTP methods
The MCP streamable-HTTP protocol only uses POST (JSON-RPC) and GET (SSE). Using app.all additionally 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 the Authorization header on preflights — silently breaking any browser-based MCP client. Restricting to the two methods the protocol actually uses is safer.
| app.all('/v1/mcp', readAuth, this.asyncHandler(async (req, res) => { | |
| app.route('/v1/mcp') | |
| .get(readAuth, this.asyncHandler(async (req, res) => { await handleMcp(req, res); })) | |
| .post(readAuth, this.asyncHandler(async (req, res) => { await handleMcp(req, res); })); |
…sport) Covers the gap the unit test can't: the real streamable-HTTP wiring through Express 5 + readAuth + Postgres. Boots the actual server, seeds observations, and drives /v1/mcp with a genuine MCP HTTP client carrying a Bearer cm_ key — asserts tool listing, team-scoped recent/search, and a 401 for an unauthenticated connection. Postgres-gated (CLAUDE_MEM_TEST_POSTGRES_URL), mirroring server-mcp-routes.test.ts. Verified live: 4/4 pass against a real Postgres 17 + the MCP streamable-HTTP client. tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Update: the "one live check" caveat is done. Added Verified live: 4/4 pass against real Postgres 17 + the HTTP transport — tool listing, team-scoped (CI's |
Adds the connect command (claude mcp add --transport http ... /v1/mcp), the read-only tool list (search/context/recent), auth (memories:read, team-scoped), and the stateless-transport note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What
The secure authenticated MCP link a user pastes into Claude Code (or Cursor / Codex / any MCP client) to recall their cloud memory from Server Beta:
Closes the one gap: the MCP server today is stdio-only (local worker). Server Beta already has the cloud recall data + API-key auth + REST recall — it just had no remote MCP transport. This adds it.
Design — reuse, not rebuild
readAuth(requirePostgresServerAuth, scopememories:read). No new auth — thecm_key's team (and project scope) bound every read, same as/v1/search.PostgresObservationRepository.search/.listByProject— the same code path as/v1/searchand/v1/context. Identical data, identical guards.src/server/mcp/recall-mcp-server.tsis a pure factory (storage injected as aRecallBackend) exposing read-only toolssearch/context/recent. The route inServerV1PostgresRoutessupplies a team-scoped backend. Mutating tools are intentionally omitted — a pasted recall link is read-only.Tests
tests/server/mcp/recall-mcp-server.test.tsdrives a real MCP client over an in-memory transport (no Postgres): tool listing, arg forwarding + limit clamping, context packing, and backend failures surfacing as tool errors. 7/7 pass; fulltsc --noEmitclean.Before merge — one live check
Per the "verify against the live server" lesson: worth one integration pass hitting
/v1/mcpon a real Server Beta with acm_key from an MCP client, to confirm the streamable-HTTP + Express 5 wiring end-to-end (the unit test covers the tool logic, not the HTTP transport handshake).Open question
Where is Server Beta deployed (the host the link points at), and is
POST /v1/keysthe intended way for a user to mint acm_key? Happy to add a key-issuance route + a Connect snippet in a follow-up.🤖 Generated with Claude Code