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 annotations — readOnlyHint / 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)
remember— append a memory (type, scope, project). Never merges. Returns the created memory (and anyfactswritten 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 throughreview_proposals. Optionally carriesfacts: the measurable claims the memory states, as subject/predicate/value triples written in the same transaction and read back byget_factswithout re-parsing the prose. Fact values are text, so the unit belongs in the predicate and each fact holds one measure — subjectlift.back_squat, predicatetop_set.weight_kg, value98, not a predicatetop_setwith value98kg x 3. That convention is what keeps a later range read comparable across entries.search— unified retrieval: semantic + FTS + recency fusion; filters (type/scope/project/status);as_offor 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, unrankedrecorded_at-descending listing narrowed by the same filters — no embedding call, andquerybecomes 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 withtruncated: trueis read in full viaget_memories. The web UI exposes a user-selectable result limit (5 / 10 / 25, defaulting to 5); the hard ceiling isMAX_SEARCH_LIMIT = 25(packages/schemamcp.ts).revise— the correction surface: create asupersedesorupdatesedge with new content. Never edits in place.extendsis an advisory consolidation edge, not areviseintent. 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 siblingresolve.resolve/ unresolve (flag) — commitment/blocker FSM transitions.briefing— structured: commitments, blockers, overdue, stale, recent decisions, preferences. Requires explicit selector (scope/project/all) — carry forward the no-firehose rule.briefmode default.handoff— export structured context for another provider/agent.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.rangeand the point-in-timeas_ofcoordinate are mutually exclusive. No dedicatedget_series-style tool: the range axis onget_factscovers that job without adding a description that would have competed withget_facts’s own.configure_scope— scope CRUD + aliases + mappings.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 soget_factscan 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 afactProposalskey when there are some, and accept/reject answer with their ownapplied_fact/rejected_factvariants, so a client written against the edge-only surface keeps seeing exactly what it saw.describe_environment— capabilities, config, scopes, stats.get_memories— batched full-content read by id: up toMAX_GET_MEMORIES_IDS = 20ids, per-item content bounded atmaxContentChars(default 10,000, ceilingMAX_GET_CONTENT_CHARS = 65,536— import rows reach 262,144 chars and never ride back verbatim). Unknown and cross-tenant ids land innotFound(data, never an error — no existence leak). The follow-up read for atruncated: truesearch/handoff line. The unbounded body is served by RESTGET /api/v1/memories/:idand, since resources landed, bythreengram://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 and2026-07-28clients. 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 presentOriginmust be allowlisted (WEB_APP_URL∪MCP_ALLOWED_ORIGINS) or the request is refused403ahead of authentication and rate limiting. An absentOriginis allowed — the spec’s requirement is conditional on the header being present, and no non-browser client sends one.Hostis deliberately not validated: behind a proxy it is not a reliable signal, and/mcpcarries no cookie or ambient credential for a rebinding attacker to spend. -
Every tool: Zod input schema +
outputSchema(structured output) frompackages/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,
briefmodes, 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
structuredContentwith a JSON text mirror incontent, becausestructuredContentonly 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 readcontentalone, and the SDK does not fill the gap: a contentless result is normalized tocontent: [], 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 atextfield, so the envelope’s own serialization escapes its quotes and newlines a second time.get_memoriesis the only tool whose budget makes this material — at itsMAX_GET_TOTAL_CHARSaggregate 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/coreservice 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 samelistMemoryFacetsthe REST facets route uses, and carries the same guards — tenant from verifiedauthInfonever from the request,memory:readenforced 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 noref/tool— sosearch’s scope/project filters cannot be completed however useful that would be. Today the one completable argument isdebrief.scope;briefingtakes a selector kind, not a scope name. Givingbriefingscope/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 enumerationresources/listalready refuses. -
GET /api/v1/memories/facets: returns{ scopes: string[], projects: string[] }— DISTINCT live-corpus values for the browse filter UI. Registered beforeGET /api/v1/memories/:idto prevent Express matching “facets” as an:idparam. Also extendsGET /api/v1/memoriesto accept repeated?project=params for multi-project IN filtering.
Protocol compatibility
- The v2 handler creates a fresh
McpServerfor every request. It serves legacy clients through the stateless compatibility path and2026-07-28clients throughserver/discover; protocol-version contract tests drive both paths with the official client SDK. server/discover,tools/list, andprompts/listare deterministic, tenant-independent payloads. Modern responses use the protocol’s cache hints to advertise a one-hourttlMswithcacheScope: 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 ontools/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 (-32601method-not-found,-32602invalid-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/-32602the client reads as staleness. That is not hypothetical — a session holding the v1.3.0 catalog calledget_factsafter prod moved to v1.4.1 and died ondata/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/schemaoutput-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 droppedscopefilter 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-Methodand, 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/discoveradvertises protocol capabilities. It does not replacedescribe_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-shapedclient_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
Thesearch 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 verifiedAuthInfo 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 toreview_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.