Skip to main content
The MCP surface is designed from jobs to be done and is deliberately small — 11 tools today — because tool sprawl across tools, prompts, and resources is a leading complexity source. Adding a tool requires: (a) a JTBD no existing tool covers, (b) evidence an agent actually needs it (transcript/eval), and (c) that it survive the tool-selection eval — a regenerated surface snapshot, the 5 agent-utterance scenarios every registered tool carries, and a run that holds the recorded floors on selection accuracy and margin plus the ceiling on description overlap. There is no numeric cap: 3ngram kept one (MAX_TOOLS = 12) from launch until the property it proxied for could be measured, and the specification never set one — it defines no maximum tool count and paginates tools/list. A twelfth tool is not blocked; a twelfth tool that reads like an existing one is. See MCP surface budget for what this is really protecting and how the surface should grow.

Jobs to be done

This table is compressed into SERVER_INSTRUCTIONS (apps/server/src/mcp/server.ts), which the server advertises as DiscoverResult.instructions — the spec’s slot for telling the model how to use the server, as opposed to the per-tool descriptions that only explain one tool at a time. Change the two together: the constant is a policy statement derived from this table, and it is prepended to model context on every client that surfaces it, so it competes with the tool descriptions for attention. Every tool also declares annotationsreadOnlyHint / destructiveHint / idempotentHint / openWorldHint — so a client can auto-approve a read like search without giving it the same friction as a write. They are not derived from requiredScope: the per-action tools (configure_scope, review_proposals) span read and write, so the mapping is not total and an explicit table is easier to review than an inference. destructiveHint is false on every memory write — append-and-supersede never destroys memory data (hard rule 1), and that is a claim worth making visible. configure_scope is the one true, because delete removes a scope registry entry.

Tools (11)

  1. remember — append a memory (type, scope, project). Never merges. Returns the created memory (and any facts written in the same transaction). Content is capped at 2000 characters — one typed atom per call; a longer debrief splits across several remembers (issue #166). Consolidation proposals are not returned here: the hourly worker inserts them independently, and they surface through review_proposals. Optionally carries facts: the measurable claims the memory states, as subject/predicate/value triples written in the same transaction and read back by get_facts without re-parsing the prose. Fact values are text, so the unit belongs in the predicate and each fact holds one measure — subject lift.back_squat, predicate top_set.weight_kg, value 98, not a predicate top_set with value 98kg x 3. That convention is what keeps a later range read comparable across entries.
  2. search — unified retrieval: semantic + FTS + recency fusion; filters (type/scope/project/status); as_of for bi-temporal time travel; returns coverage envelope. One unified retrieval tool in place of several specialist search tools. order: "chronological" switches from the fused ranking to an exhaustive, unranked recorded_at-descending listing narrowed by the same filters — no embedding call, and query becomes optional as long as at least one filter is present (an unfiltered scan with neither is rejected). Hit content is a bounded excerpt (MAX_EXCERPT_LENGTH); a hit with truncated: true is read in full via get_memories. The web UI exposes a user-selectable result limit (5 / 10 / 25, defaulting to 5); the hard ceiling is MAX_SEARCH_LIMIT = 25 (packages/schema mcp.ts).
  3. revise — the correction surface: create a supersedes or updates edge with new content. Never edits in place. extends is an advisory consolidation edge, not a revise intent. Archiving a memory that is not a blocker is REST-only (POST /api/v1/memories/:id/archive); resolving a blocker archives it via this tool’s sibling resolve.
  4. resolve / unresolve (flag) — commitment/blocker FSM transitions.
  5. briefing — structured: commitments, blockers, overdue, stale, recent decisions, preferences. Requires explicit selector (scope/project/all) — carry forward the no-firehose rule. brief mode default.
  6. handoff — export structured context for another provider/agent.
  7. get_facts — currently-valid facts for a subject (bi-temporal aware), or a chronological time-series read across a valid-time window (range: {from?, to?}) — surfaces superseded generations inside the window, ordered oldest-first. range and the point-in-time as_of coordinate are mutually exclusive. No dedicated get_series-style tool: the range axis on get_facts covers that job without adding a description that would have competed with get_facts’s own.
  8. configure_scope — scope CRUD + aliases + mappings.
  9. review_proposals — list/accept/reject proposals (the human-in-the-loop side of background work). Two kinds share the flow: consolidation proposals, where accepting materializes the proposed edge, and extracted-fact proposals, where accepting writes the structured fact so get_facts can read it. Nothing extracted becomes queryable truth without review. The input is unchanged — accept/reject take the proposal id, and ids are disjoint across the two tables, so the id alone identifies the kind. The list response only grows a factProposals key when there are some, and accept/reject answer with their own applied_fact/rejected_fact variants, so a client written against the edge-only surface keeps seeing exactly what it saw.
  10. describe_environment — capabilities, config, scopes, stats.
  11. get_memories — batched full-content read by id: up to MAX_GET_MEMORIES_IDS = 20 ids, per-item content bounded at maxContentChars (default 10,000, ceiling MAX_GET_CONTENT_CHARS = 65,536 — import rows reach 262,144 chars and never ride back verbatim). Unknown and cross-tenant ids land in notFound (data, never an error — no existence leak). The follow-up read for a truncated: true search/handoff line. The unbounded body is served by REST GET /api/v1/memories/:id and, since resources landed, by threengram://memory/{id} — which returns the full stored content and is cacheable, so a client that reads the same memory twice pays for it once.

Server architecture

  • Official TS SDK v2 packages pinned 2.0.0 + Express, following the dual-era migration path. One stateless handler serves legacy and 2026-07-28 clients. Keep Express: changing the protocol generation and HTTP framework together would add migration risk without a product benefit.
  • Strict OAuth resource server: validate JWT aud + RFC 8707 resource indicators. No token proxying (the FastMCP CVE class).
  • Origin validation on /mcp: a present Origin must be allowlisted (WEB_APP_URLMCP_ALLOWED_ORIGINS) or the request is refused 403 ahead of authentication and rate limiting. An absent Origin is allowed — the spec’s requirement is conditional on the header being present, and no non-browser client sends one. Host is deliberately not validated: behind a proxy it is not a reliable signal, and /mcp carries no cookie or ambient credential for a rebinding attacker to spend.
  • Every tool: Zod input schema + outputSchema (structured output) from packages/schema — same types the REST API and SDK use. The advertised JSON Schema is not a straight mirror of the Zod object in one place: output schemas advertise open (see the compatibility decision below), inputs advertise closed.
  • Output size discipline: structured, brief modes, pagination — oversized responses are an easy trap, so design the size discipline in from the start.
  • Every tool result carries its payload twice, and that is deliberate. Each result pairs structuredContent with a JSON text mirror in content, because structuredContent only arrived in protocol revision 2025-06-18 while the server still serves 2025-03-26, 2024-11-05, and 2024-10-07 — and 2025-03-26 is what a client that sends no version negotiates by default. Those clients read content alone, and the SDK does not fill the gap: a contentless result is normalized to content: [], so dropping the mirror would return them an empty success. The duplication costs slightly more than 2×, not exactly 2×: the mirror is serialized and then embedded in a text field, so the envelope’s own serialization escapes its quotes and newlines a second time. get_memories is the only tool whose budget makes this material — at its MAX_GET_TOTAL_CHARS aggregate bound the worst case measures 284 KB structured + 315 KB mirror = 599 KB on the wire (2.11×), of which re-escaping is +10.7%. The cost is bounded and caller-requested, so the budgets stay as they are; a test pins that worst case under a named ceiling so raising a budget cannot quietly double the response with it.
  • MCP and REST are thin adapters over the same packages/core service layer — no logic in the transport layer, no MCP↔API drift.
  • Prompts: 2 (briefing, debrief) as code-defined templates. Resources: threengram://memory/{id} is served — see MCP resources.
  • Argument completion (completion/complete): a client can offer the tenant’s real scope names instead of making the user recall them. It is an adapter over the same listMemoryFacets the REST facets route uses, and carries the same guards — tenant from verified authInfo never from the request, memory:read enforced fail-closed, and the access gate ahead of the read, because facet labels are themselves tenant data. A denied read completes to an empty list rather than an error: completion is a UI affordance, and failing quiet is the right shape mid-keystroke. Results are not cacheable in this revision and sit behind the per-user rate limiter, which the spec asks for by name. The protocol only completes prompt and resource arguments — there is no ref/tool — so search’s scope/project filters cannot be completed however useful that would be. Today the one completable argument is debrief.scope; briefing takes a selector kind, not a scope name. Giving briefing scope/project arguments is the change that would make this pay off broadly. The memory resource’s {id} is deliberately not completable: suggesting memory ids is the corpus enumeration resources/list already refuses.
  • GET /api/v1/memories/facets: returns { scopes: string[], projects: string[] } — DISTINCT live-corpus values for the browse filter UI. Registered before GET /api/v1/memories/:id to prevent Express matching “facets” as an :id param. Also extends GET /api/v1/memories to accept repeated ?project= params for multi-project IN filtering.

Protocol compatibility

  • The v2 handler creates a fresh McpServer for every request. It serves legacy clients through the stateless compatibility path and 2026-07-28 clients through server/discover; protocol-version contract tests drive both paths with the official client SDK.
  • server/discover, tools/list, and prompts/list are deterministic, tenant-independent payloads. Modern responses use the protocol’s cache hints to advertise a one-hour ttlMs with cacheScope: public; the SDK omits those fields from legacy responses. All three share one TTL because they go stale on the same trigger — a deployment. A cacheable method left unconfigured does not ship without hints: the SDK fills its own defaults (ttlMs: 0, cacheScope: private), which reads to clients as “always stale”, so every cacheable result the server serves must be listed explicitly. Cached definitions never bypass bearer authentication or the scope gate on tools/call.
  • Catalog staleness has no push invalidation, and that is a decision. With a one-hour TTL and no subscriptions/listen, a rolling deploy that changes a tool schema can leave a client on a stale catalog for up to an hour. Two things bound it: the spec permits a client to re-fetch early when a call fails in a way that suggests staleness (-32601 method-not-found, -32602 invalid-params), which is exactly what a stale schema produces; and long-lived listen streams fit poorly with the stateless deploy model that the rest of this design is built around — a held-open stream is the one thing that would make a redeploy an event. Subscriptions are therefore not implemented, and the staleness window is accepted rather than overlooked.
  • Output schemas advertise open (additionalProperties: true) while the server still parses them strict. The staleness window above is only survivable if a stale catalog stays usable, and a strict advertised output schema breaks exactly that: adding one response field makes every validating client on the cached catalog hard-fail for the whole hour, and nothing prompts an early re-fetch, because the failure is client-side output validation rather than a -32601/-32602 the client reads as staleness. That is not hypothetical — a session holding the v1.3.0 catalog called get_facts after prod moved to v1.4.1 and died on data/facts/0 must NOT have additional properties, on a nested item object, from a purely additive field. So every object node in every tool output tree carries .meta({ additionalProperties: true }) (packages/schema output-openness.ts): the advertised contract tolerates a field the client was not compiled against, while the Zod object stays .strict() and the server still rejects an unknown key it produced itself. Inputs are the opposite by design — an unknown argument key stays a loud rejection, because a silently dropped scope filter reads as a scope leak. A registry invariant test asserts both halves at every depth, and the REST response schemas open up with them, since they reuse the same output shapes and a REST reader can be just as far behind.
  • Modern Streamable HTTP clients mirror the request body into Mcp-Method and, for named primitives, Mcp-Name. A pre-parser middleware reduces those headers to closed allowlists for routing telemetry. The headers are untrusted: the SDK cross-checks them against the body, and neither authorization nor completed-call accounting reads them.
  • server/discover advertises protocol capabilities. It does not replace describe_environment, which reports tenant scopes, configured capabilities, and memory statistics.

OAuth client registration

Client ID Metadata Documents (CIMD) are supported alongside RFC 7591 Dynamic Client Registration (DCR). A persisted DCR client wins; an HTTPS URL-shaped client_id otherwise resolves as CIMD. DCR remains advertised as a compatibility fallback. CIMD fetching is a security boundary: validate the URL and document at the schema boundary, resolve every hop, reject any non-public DNS answer, connect to the validated IP while verifying TLS for the original hostname, and cap redirects, bytes, time, concurrency, and cache size. Cache only valid documents and honor shared-cache directives. The materialized oauth_clients row exists for foreign keys and connected-app display; the fetched document remains the authorization source.

Search — fusion detail

The search tool runs a weighted-sum fusion of three legs (vector, FTS, recency). Weights are policy-owned by packages/core; the SQL is owned by packages/db. Short queries (≤2 whitespace tokens) activate a topic entity-match bonus (topicMatch weight 0.5): memories whose topic contains the query string score +0.5. This surfaces person-identity facts for first-name searches without affecting long-query MRR (all golden-set queries are 6+ tokens).

Rate limiting & sessions

Redis-backed, per-user + per-key. The Node adapter receives only the verified AuthInfo produced by bearer middleware; tenant and tool authorization stay inside the per-request handler. Sessions are stateless by design: all MCP session state lives in Postgres/Redis, never in-process — any server instance serves any request. This makes redeploys non-events. .well-known/mcp server metadata (SEP-1649) is tracked but deferred until the SEP stabilizes.

Deferred protocol features

Multi-round-trip responses could add explicit confirmation to review_proposals or future scope changes. Standard Tasks could represent long-running imports, consolidation, and repair jobs backed by BullMQ. Both require separate product and persistence designs and do not gate protocol compatibility. The Roots, Sampling, and Logging deprecations have no migration cost because 3ngram does not depend on those client features.