Friction log — Unsay

Every entry written the day it was hit, not assembled before submission. Format per entry: task attempted · steps taken · expected vs actual · severity · workaround · actionable suggestion.

Tools covered so far: @modelcontextprotocol/sdk (TypeScript), MCP spec 2025-11-25, Amazon Developer account onboarding.


F-001 · MCP TypeScript SDK · client method name does not match the protocol method

Date: 2026-09-02 · Severity: Low (30 min) · Tool: @modelcontextprotocol/sdk@1.30.0

Task attempted. Subscribe a client to a resource so it receives notifications/resources/updated.

Steps taken. Read the 2025-11-25 spec, which names the method resources/subscribe. Wrote await client.subscribe({ uri }) by analogy with the spec name.

Expected vs actual. Expected a subscribe() method mirroring the protocol method name. Got TypeError: client.subscribe is not a function. The actual method is subscribeResource() (and unsubscribeResource()).

Why it's confusing rather than wrong. The SDK is internally consistent — readResource → resources/read, listResources → resources/list, so subscribeResource → resources/subscribe follows its own verb-noun pattern. But the spec is what a developer reads first, and the spec says subscribe. The two vocabularies diverge at exactly the surface that is hardest to discover, because subscription is the one method with no return value to inspect and no example in the README.

Workaround. Grep the .d.ts: grep -oE '^\s{4}[a-zA-Z]+\(' node_modules/@modelcontextprotocol/sdk/dist/esm/client/index.d.ts

Actionable suggestion. Add a one-line subscription example to the TypeScript SDK README — there is currently none — or export a subscribe alias. A single snippet showing client.subscribeResource() + client.setNotificationHandler(ResourceUpdatedNotificationSchema, …) would have saved the lookup entirely.


F-002 · MCP spec 2025-11-25 · annotations.audience is advisory, with no enforcement story

Date: 2026-09-02 · Severity: High (architectural) · Tool: MCP spec 2025-11-25

Task attempted. Use annotations.audience: ["assistant"] to hold clinical context that shapes the model's answer but must never be spoken to the patient.

Expected vs actual. Expected the spec to state what a client MUST do with audience. It defines the field and its two legal values, and places no obligation on the client to honour it. A compliant client may read an audience: ["assistant"] resource and speak it verbatim.

Why this matters here. In a healthcare context the difference between "reasoning context" and "speakable" is a safety boundary, not a presentation hint. "Fall risk high; family disputes the discharge plan" must change the advice and must never reach the patient's ears. A safety-relevant annotation a client may ignore is a documentation comment, not a control.

Workaround. Do not rely on the annotation. Unsay splits the graph into two URI schemes (care:// and care-internal://) behind two OAuth scopes, enforced at the resource server. A client holding only care.read.user receives -32002 on every internal URI — it is never sent the content, so it cannot leak it. The annotation is retained as a correct-by-convention hint for compliant hosts, but the enforcement is server-side.

Actionable suggestion. Either (a) add normative language — "clients MUST NOT surface content annotated audience: ["assistant"] to end users" — or (b) state explicitly in the spec that audience is a rendering hint with no security properties, so implementers don't mistake it for one. The current silence invites the second reading of a field that looks like the first. Option (a) would make an entire class of safety-partitioned MCP servers possible without bespoke authorization.


F-003 · MCP spec 2025-11-25 · no defined client behaviour on notifications/resources/updated

Date: 2026-09-02 · Severity: Medium · Tool: MCP spec 2025-11-25

Task attempted. Guarantee that a correction written by a clinician reaches the assistant's speech while it is mid-answer.

Expected vs actual. Expected the spec to say what a client does on receiving updated — re-read, invalidate, or nothing. It specifies the notification's delivery, not its consequence. A conforming host may receive the notification and never re-read, in which case the user hears the stale answer to completion and the correction is invisible.

Severity reasoning. This cannot be fixed from the server side. It is the one residual risk in Unsay's design that no amount of server engineering removes.

Workaround. Dual path from commit 1 — native subscription for compliant hosts, plus a whats_changed tool with an outputSchema, nudged every turn by the server's instructions field. Both are built and both are exercised in CI, because an unexercised fallback is indistinguishable from a missing one.

Actionable suggestion. A SHOULD-level line — "clients SHOULD re-read a subscribed resource on notifications/resources/updated before using its content in a response" — would be enough. Without it, "live resources" is a delivery guarantee with no freshness guarantee, and every server author has to invent the same fallback.


F-004 · Amazon Developer account · payment-verification hold blocks all device tracks

Date: 2026-09-01 · Severity: High (blocking, multi-day) · Tool: Amazon account onboarding

Task attempted. Register for the Ring Developer Portal to build on the Ring track.

Steps taken. Attempted sign-in with an existing Amazon account. Account was under a payment-verification hold requiring documentary proof of ownership of a card used on a past order.

Expected vs actual. Expected developer-console access to be gated on developer identity verification (which the Ring docs describe: government ID, name matching the Company Profile). Instead it was gated on retail payment verification for an expired debit card, with no path visible from the developer console itself.

Why the friction is sharper than it looks. The accepted document types assume a card statement showing the card number. For an Indonesian debit card the bank statement shows the account number, never the card number — so the "preferred" evidence type is structurally impossible to produce, and the correct route (card photo + alternative statement) is the third option down a list most users will not read that far into.

Impact on this hackathon. Blocks the Ring and Fire TV tracks entirely. Alexa+ is unaffected because a self-hosted MCP server needs no Amazon credential — which is the only reason this project was buildable on day 1.

Actionable suggestion. When a hold blocks the developer console specifically, surface the reason and the remedy inside the developer console rather than only in retail account settings. And in the document-type list, mark which options work for debit cards — the preferred option silently does not.


F-005 · MCP spec 2025-11-25 + TS SDK · annotations are dropped by resources/read

Date: 2026-09-04 · Severity: High (design-forcing, ~1 h) · Tool: MCP spec 2025-11-25, @modelcontextprotocol/sdk@1.30.0

Task attempted. Deliver a fact's audience, priority and lastModified alongside its content, so a host reading care://ray/anticoagulant receives the instruction and the knowledge that it expired four days ago in the same response.

Steps taken. Attached annotations to the object in ReadResourceResult.contents[], exactly as we already do on resources/list entries. Ran a real client against the real server over Streamable HTTP and printed the keys of what arrived.

Expected vs actual.

LIST  entry keys   : [ 'name', 'uri', 'description', 'mimeType', 'annotations' ]
LIST  annotations  : {"audience":["user"],"priority":0.9,"lastModified":"2026-10-06T09:14:00.000Z"}
READ  content keys : [ 'uri', 'mimeType', 'text' ]
READ  annotations  : undefined

Expected the annotation to arrive on both. It arrives on list and is silently discarded on read. The cause is in the shipped typings — TextResourceContentsSchema and BlobResourceContentsSchema carry uri, mimeType, _meta and the payload, and nothing else:

node_modules/@modelcontextprotocol/sdk/dist/esm/types.d.ts
  ResourceSchema        : { uri, name, description, mimeType, size, annotations?, _meta? }
  TextResourceContents  : { uri, mimeType?, _meta?, text }     ← no annotations
  BlobResourceContents  : { uri, mimeType?, _meta?, blob }     ← no annotations

Both are z.core.$strip, so the client's schema parse deletes the field without an error, a warning, or a hint that the server sent something. Annotations are defined on Resource, PromptMessage and ContentBlock, and are simply not part of ResourceContents.

Why it matters more than a missing field usually would. annotations.audience is the field an implementer reaches for when content must not be spoken (F-002). Discovering that it is advisory is bad. Discovering that on the one method that actually carries content it does not arrive at all is worse, because the two facts point in opposite directions: resources/list teaches you the field works, and resources/read quietly proves it does not. A server author who tests against list will ship believing the annotation is delivered.

Workaround. Three, all in this repo. Authorization was already server-side (F-002), so nothing leaked. But lastModified was going to carry the staleness signal, and it cannot — so (a) the age is written into the text body, as a [STALE — last changed 9 days ago by Dr Mensah, GP; say this age aloud] header prepended to the value in the resources/read handler (src/server.ts); (b) the machine-readable copy moved to _meta, which ResourceContents does define — unsay/version, unsay/versionHash, unsay/prevHash, unsay/audience, unsay/lastModified, unsay/stale; (c) the annotation is still emitted on both surfaces, because a non-SDK host that passes the JSON through will see it, and on resources/list it survives even the SDK. Writing the safety-relevant field into free text is ugly, and it is the only channel that provably reaches the model.

Actionable suggestion. Pick one and say it out loud in the spec:

  1. Add annotations to ResourceContents — it is where the content is, and where a per-version lastModified naturally belongs, since Resource describes a URI while ResourceContents describes what that URI holds right now; or
  2. state in the resources/read section that annotations are carried on the Resource, not on its contents, and that a server must repeat anything content-specific through _meta.

Either is fine. The current state — a field that exists on the listing, is absent from the read, and is stripped silently by the reference implementation — is the one outcome that costs every server author the same hour.


F-006 · MCP TypeScript SDK · the OAuth resource-server helpers are express-shaped and route-scoped

Date: 2026-09-04 · Severity: High (architectural, ~50 min) · Tool: @modelcontextprotocol/sdk@1.30.0

Task attempted. Serve /.well-known/oauth-protected-resource, verify bearer tokens, and enforce per-resource scopes: care://… requires care.read.user, care-internal://… requires care.read.assistant.

Steps taken. Read every file under dist/esm/server/auth/ looking for the resource-server half of the story, since the MCP server here is a resource server and not an authorization server.

Expected vs actual — two separate problems.

(a) Everything in the authorization surface is express. mcpAuthMetadataRouter() returns an express.Router. requireBearerAuth() returns an express RequestHandler. metadataHandler() returns an express RequestHandler. bearerAuth.d.ts goes further and does declare module 'express-serve-static-core' to add Request.auth, so merely importing it edits express's types. Meanwhile StreamableHTTPServerTransport.handleRequest() takes a plain node:http request/response and needs no framework at all — this repo's transport plumbing is fourteen lines of node:http in scripts/e2e.ts. Adopting the SDK's auth helpers means adopting a web framework for the sole purpose of getting a .well-known route and a header check, in a project whose dependency budget is one package.

(b) requiredScopes cannot express what MCP servers actually need. The signature is requireBearerAuth({ verifier, requiredScopes, resourceMetadataUrl }) — a flat list checked once, per route. But MCP multiplexes every resource behind a single POST /mcp. There is no route to attach a per-resource scope to. resources/read on a speakable fact and resources/read on a never-speakable one are the same HTTP request to the same path, differing only in a JSON-RPC param. Route-level scope checking can therefore only express "this token may talk to this server at all", which for a server whose entire point is a safety partition is the wrong granularity by one whole level.

Workaround. Do the authorization inside the protocol handler instead of in middleware: the scope required is derived from the URI's scheme, and both the scheme-derived and the record-derived scope are checked in LiveResourceStore.read() (src/store.ts) before any content is returned. The SDK does plumb AuthInfo through to handlers as extra.authInfo (shared/protocol.d.ts), which is the piece that makes this possible — it is just not what the auth helpers are built around.

Actionable suggestion. Two concrete asks:

  1. Ship a framework-free resource-server path. A getOAuthProtectedResourceMetadata(options) returning the plain metadata object, and a verifyBearer(headers, options) returning AuthInfo | error, would let a node:http (or Workers, or Lambda) server do this in five lines. getOAuthProtectedResourceMetadataUrl() is already framework-free and is exactly the right shape; the rest is not.
  2. Document, in the authorization section, that route-level requiredScopes is a floor and not the authorization model, with the pattern for per-resource checks off extra.authInfo.scopes. Every MCP server with a non-uniform resource graph will hit this, and right now each one has to work out on its own that the middleware cannot do the job.

F-007 · MCP spec 2025-11-25 · pagination cursors have no defined semantics against a changing list

Date: 2026-09-04 · Severity: Medium (~30 min) · Tool: MCP spec 2025-11-25, @modelcontextprotocol/sdk@1.30.0

Task attempted. Decide whether resources/list here should paginate. This server declares resources.listChanged and fires notifications/resources/list_changed whenever a new domain appears — so its resource list changes while a client could be paging through it, which is precisely the case pagination semantics have to pin down.

Steps taken. Read the spec's pagination section and the SDK's typings, then tested what a non-cooperating cursor actually does against a live client.

Expected vs actual. The spec defines cursor as an opaque token and nextCursor as the continuation, and stops. Three questions it does not answer:

  1. Does list_changed invalidate an outstanding cursor? Unspecified. A client mid-page has no way to know whether to restart or continue, and a server has no defined way to tell it.
  2. What must a server return for a cursor it does not recognise or that has expired? Unspecified. No error code is named — not -32602, not anything.
  3. Is a server obliged to honour a cursor at all?

Question 3 is the sharp one, because the answer is observably no, and nothing catches it. Measured against this server on the day it still ignored params.cursor (it paginates now — see the workaround):

BOGUS CURSOR : accepted, returned 6 resources

A garbage cursor produced a complete, successful, schema-valid response. The SDK validates that a cursor is a string (CursorSchema = z.string()) and does nothing else with it, and there is no auto-pagination helper on the client — client.listResources(params) is one round trip, so following nextCursor is the caller's job in every client ever written against this SDK.

Why it matters. A server that silently ignores cursors is indistinguishable, to a conforming client, from one that implements them correctly, until the list outgrows one page. That is a bug that ships.

Workaround. Implement the missing semantics ourselves and write down the choices, since the spec makes none of them for us. resources/list in src/server.ts pages a URI-sorted list at RESOURCE_PAGE_SIZE = 3; the cursor carries the last URI emitted, not an offset, so a concurrent publish between two pages cannot silently drop a resource into the gap the way an offset would; and decodeCursor() answers a cursor it did not issue with -32602 (ErrorCode.InvalidParams) rather than resetting to page one, which would loop a client forever. Three defensible decisions that every other MCP server author will also have to make, differently.

Actionable suggestion. Three lines in the pagination section would close all of it: name the error for an unrecognised cursor (-32602 with a defined message), state whether a cursor survives notifications/*/list_changed (a SHOULD either way is better than silence), and state that a server that receives a cursor it did not issue MUST fail rather than return page one. Optionally, an iterateResources() async-generator on the SDK client would stop every consumer re-implementing the same loop.


F-008 · MCP spec 2025-11-25 · blob contents have no size bound anywhere in the stack

Date: 2026-09-04 · Severity: Medium (~45 min) · Tool: MCP spec 2025-11-25, @modelcontextprotocol/sdk@1.30.0

Task attempted. Serve a physio's demonstration clip as a blob resource, on the same URI scheme as the text so the audience partition covers the audio too — and size it so that reading it cannot break the product's one hard claim, that a correction lands inside a ~3.4 s speech window.

Steps taken. Looked for a size limit, a guidance figure, or a chunking mechanism in the spec, in types.d.ts, and in both Streamable HTTP transports. Then measured, with a real client over a real transport, escalating blob sizes:

  0.06 MiB raw ->   0.08 MiB base64  read      5 ms
  0.50 MiB raw ->   0.67 MiB base64  read      8 ms
  2.00 MiB raw ->   2.67 MiB base64  read     20 ms
  8.00 MiB raw ->  10.67 MiB base64  read     70 ms
 32.00 MiB raw ->  42.67 MiB base64  read    272 ms

Expected vs actual. Expected a documented ceiling, or at least a "keep blobs under N MB, use a URI for anything larger" note. Found none:

A 32 MiB clip was therefore accepted end to end, at every layer, without a warning. On loopback that is 272 ms. Over a typical home connection the same read is 42.67 MiB of base64 — at 5 Mbps, about 68 seconds, twenty times the window this product's central claim depends on. (That figure is arithmetic from the measured payload size, not a measurement over a real link — but the payload size is real, and no layer in the stack objected to it.)

Why it matters. A voice assistant is a latency-bound consumer. blob is the one MCP content type whose size is unbounded by construction, and the same protocol carries the notification that has to arrive mid-sentence. Every implementer has to independently guess a budget the spec could simply state.

Workaround. Bound it ourselves: the clips in src/blobs.ts are 12 kB and 24 kB of WAV, loaded from committed fixtures under fixtures/, cached as base64 on first read, and reported through Resource.size on resources/list so a host can decide before it asks. A budget we enforce is not a budget the protocol enforces.

Actionable suggestion. (a) Define Resource.size as raw decoded bytes in one sentence — the ambiguity is free to remove. (b) Add non-normative guidance to the resources/read section: a recommended maximum inline blob size, and a statement that larger content should be referenced by a fetchable URI rather than inlined. (c) Let a transport advertise a maximum message size at initialize, so a server can choose a representation instead of discovering the limit as a timeout.


F-009 · TS SDK + Node 22 native type-stripping · no-build-step and type-checked are mutually exclusive

Date: 2026-09-04 · Severity: Medium (~25 min) · Tool: Node 22.22, TypeScript 5.7, @modelcontextprotocol/sdk@1.30.0

Task attempted. Run the server with node --experimental-strip-types — no bundler, no build directory, no compile step between the source a judge reads and the process that runs — while still type-checking against the SDK's shipped .d.ts.

Steps taken. Node's type-stripping requires relative imports to carry the real extension, so every import in src/ is ./store.ts, ./types.ts. Then ran tsc --noEmit over the same files.

Expected vs actual.

src/server.ts(23,68): error TS5097: An import path can only end with a '.ts' extension
                      when 'allowImportingTsExtensions' is enabled.
src/server.ts(24,27): error TS5097: ...
src/store.ts(20,8):   error TS5097: ...

The extension Node requires is the extension tsc rejects by default. Getting both needs a tsconfig.json with allowImportingTsExtensions (which itself requires noEmit or emitDeclarationOnly) and, for anything that later does emit, rewriteRelativeImportExtensions. Neither Node's docs nor the SDK's README mentions any of this; the SDK README (172 lines) has no Node-native-TypeScript section at all. Note also that --experimental-strip-types performs zero type checking — it erases annotations and runs. So the default state of a no-build-step project is that the SDK's typings are decoration and every mismatch surfaces at runtime.

A concrete cost, not a hypothetical one. ReadResourceResult.contents is a union of text and blob contents, and the SDK exports no type guard to narrow it — grep 'export declare function is[A-Z]' across the typings returns exactly one unrelated helper, isJsonContentType. So every consumer writes (result.contents[0] as { text: string }).text, which this repo does in scripts/e2e.ts and scripts/probe_subscribe.ts. Under type-stripping that cast is checked by nobody at all: a server that started returning a blob there would produce undefined, not an error.

Workaround. Keep .ts extensions (Node is the runtime and wins), and treat tsc as an opt-in lint that needs its own config rather than as a gate. The real safety net here is scripts/verify.ts and scripts/fresh_clone_check.sh, which assert behaviour rather than types.

Actionable suggestion. (a) Export isTextResourceContents() / isBlobResourceContents() type guards from the SDK — three lines, and they remove an unchecked cast from every client ever written. (b) Add a short "Running with Node's native TypeScript support" section to the SDK README with the exact tsconfig.json (allowImportingTsExtensions, noEmit, erasableSyntaxOnly) that makes .ts-extension imports type-check. Node 22 shipping type-stripping by default makes this the fastest path from npm install to a running MCP server, and it is currently undocumented in the one place a new server author is already reading.


F-010 · MCP TypeScript SDK · the standalone SSE stream sends no priming event, so a fresh stream cannot resume

Date: 2026-09-04 · Severity: High (3 h) · Tool: @modelcontextprotocol/sdk@1.30.0

Task attempted. Prove that a correction survives the network dropping under an Echo Show: subscribe, drop the SSE stream as a proxy timeout would, write a revision into the dark, reconnect with Last-Event-ID, and assert the missed notifications/resources/updated is replayed.

Steps taken. Configured StreamableHTTPServerTransport with an EventStore, opened the standalone GET /mcp stream from a real client, dropped it server-side, wrote, and waited for the reconnect.

Expected vs actual. Expected the reconnect to carry Last-Event-ID and the missed notification to be replayed. Got a reconnect with no Last-Event-ID header and the notification silently lost. The cause is in the SDK: writePrimingEvent is called only on the POST response stream (dist/esm/server/webStandardStreamableHttp.js:652), never on the standalone GET stream. A client that has received nothing on that stream therefore holds no event id, and the transport has nothing to resume from — so resumability protects only a stream that has already delivered at least one event, which is precisely not the case when a correction is the first thing to happen.

Why this matters more than it looks. The failure is silent and it inverts the guarantee. You configure an event store, you see it fill, you assume corrections are durable — and the one window where a household wifi blip actually loses a message is the window before the first event, which is most of the time on a freshly opened screen.

Workaround. scripts/probe_resume.ts writes a live revision FIRST, so the client is holding an event id before the stream is dropped. The probe says so in a comment, because the workaround is also the disclosure: a correction published before the very first event on a fresh stream is stored and not replayable.

Actionable suggestion. Call writePrimingEvent when the standalone GET stream opens, exactly as the POST stream does — it is a one-line change and it makes Last-Event-ID mean what its documentation implies. Failing that, say in the Streamable HTTP section of the README that resumability begins at the first delivered event, so an implementer knows the gap exists.


F-011 · MCP TypeScript SDK · Streamable HTTP emits an empty data: frame that no document mentions

Date: 2026-09-04 · Severity: Medium (1 h) · Tool: @modelcontextprotocol/sdk@1.30.0

Task attempted. Write a browser MCP host by hand — fetch plus an SSE reader — because the three pages in web/ load no script from anywhere and cannot import the SDK.

Steps taken. Implemented the obvious SSE reader: split on a blank line, take the data: line of each block, JSON.parse it.

Expected vs actual. Expected every data: line to carry a JSON-RPC message. Got SyntaxError: Unexpected end of JSON input on the very first frame the stream ever delivered: the transport writes an empty data: keep-alive/ack frame before each message. Neither the 2025-11-25 spec's Streamable HTTP section nor the SDK README mentions it, and the failure lands on frame one, so it reads as "my reader is broken" rather than "there is a frame here I was not told about".

Workaround. A proper SSE framer in both pages: normalise CRLF, concatenate multi-line data: fields, skip empty payloads, capture id: for resumption. It is thirty lines and every hand-written client will need the same thirty lines.

Actionable suggestion. Either omit the data: line when there is no payload — a bare : comment line is the SSE-native keep-alive and every reader already ignores it — or document the ack frame in the transport section. One sentence prevents a first-frame crash in every non-SDK client.


F-012 · MCP spec 2025-11-25 · list_changed has no defined semantics for a server's initial state

Date: 2026-09-04 · Severity: Medium (2 h) · Tool: MCP specification 2025-11-25

Task attempted. Declare resources.listChanged and send notifications/resources/list_changed when the GP adds a new care domain — without telling a host that the care plan changed merely because the server finished starting up.

Steps taken. Read the specification's list_changed section for guidance on a server whose resource list is populated at boot. There is none: the notification is defined, its initial-state semantics are not.

Expected vs actual. Expected a rule such as "do not send list_changed for resources present before the client's first resources/list". Found nothing, so every implementation invents its own suppression window. Ours was a queueMicrotask flag — and it turned out to be untestable: a notification sent before any transport is attached fails silently, so a correctly suppressed notification and a wrongly sent one produce identical observable behaviour. The test we wrote to prove the guard worked would have passed with the guard deleted.

Workaround. Arm on the first resources/list actually served over the connection. The rule is the notification's own meaning — list_changed invalidates a list, and a client that has never listed holds none — and unlike the microtask flag it is testable: test/server.test.ts now seeds the store with the host already connected and asserts zero notifications for eight seeded domains, then exactly one when a new domain appears.

Actionable suggestion. Add one line to the spec: "Servers SHOULD NOT send notifications/resources/list_changed to a client that has not yet called resources/list." It makes the suppression window uniform across implementations and, more importantly, makes it observable — which is the difference between a guard and a comment.

F-013 · MCP Apps extension · the tool→template binding key is vendor-namespaced, with no registry

Date: 2026-09-04 · Severity: Medium (3 h) · Tool: MCP Apps extension (apps.extensions.modelcontextprotocol.io)

Task attempted. Publish Ray's Echo Show card as an MCP resource rather than as an HTTP static route, so a host renders the card through the protocol — the "media support (cards, carousels), MCP Apps" bar the Alexa+ track names — and bind it to the whats_changed tool result.

Steps taken. Served the card at ui://unsay/echo with mime type text/html+skybridge, put the preferred frame size in _meta under the extension's mcpui.dev/ namespace, and named the template on the tool descriptor's _meta.

Expected vs actual. Expected one normative key for "render this result into that template", in the mcp/ namespace the rest of the protocol uses. Found openai/outputTemplate — a key carrying one vendor's prefix, inherited from the Apps SDK the extension grew out of, sitting next to a frame hint under a second, different third-party namespace (mcpui.dev/). Two vendor namespaces and no registry means a server cannot tell, by reading, whether a host will look for its template at all. There is also no capability a host declares for this, so there is nothing to negotiate against and nothing to probe: the binding either works in a given host or is silently ignored.

Workaround. Ship both doors. The card is an MCP resource and an HTTP route (/echo.html), read from one file so the two cannot drift, and the extension _meta keys are named once in src/ui_resource.ts so the day they are settled is a one-line change. npm run e2e asserts the resource itself is served and readable over Streamable HTTP; it cannot assert that a host honours the binding, because we have no host that implements the extension. Read the binding as shaped, not exercised — the same posture as the KMS provider under F-004, and disclosed in the same places.

Actionable suggestion. Register the binding key under mcp/ — _meta["mcp/outputTemplate"] — keep the vendor key as an alias for one revision, and add a capabilities.experimental.apps (or equivalent) a host declares, so a server can negotiate instead of guessing. A rendering contract that cannot be probed is a rendering contract a server ships blind.


F-014 · MCP TypeScript SDK · EventStore replay has no authorization seam, so resumability can walk around a send-time guard

Date: 2026-09-04 · Severity: High (4 h, and it was a live leak) · Tool: @modelcontextprotocol/sdk (TypeScript) — EventStore, Streamable HTTP resumability

Task attempted. Keep Unsay's audience partition intact across a dropped SSE stream. A care-internal:// URI must never be named to a principal that cannot read it — not by notifications/resources/updated, not by the revision log, and not by a resume.

Steps taken. Guarded both live channels at send time: canNotify in the notifier and logRevision() in src/server.ts both re-read the session's current principal, because a session's scopes can narrow within its lifetime (the server re-reads the bearer token on every request). Then dropped the stream and reconnected with Last-Event-ID under a narrowed token of the same subject.

Expected vs actual. Expected the resume to be subject to the same authorization as the send. Actual: the resumed stream returned the internal URI, its version, its author, its timestamp and both hash-chain links, verbatim. The reason is in the interface, not in our wiring. EventStore is

storeEvent(streamId, message): Promise<EventId>
replayEventsAfter(lastEventId, { send }): Promise<StreamId>

replayEventsAfter is handed an event id and a sink. It is given no principal, no AuthInfo, no request — nothing about who is asking now. The transport calls it before the response is constructed and hands whatever comes back straight to the client. So a server that has correctly re-authorized every send has still not re-authorized anything a client can ask for again, and the one seam where that could be fixed does not receive the identity it would need. The asymmetry is easy to miss precisely because the send path looks guarded.

Workaround. Read the principal out of the same AsyncLocalStorage the GET handler already populates, and filter inside our own store implementation: MemoryEventStore takes a canReplay predicate and replayAllowed() drops any stored frame naming a URI the resuming principal lacks the scope for, failing closed on a URI the partition did not issue. It works, but only because Unsay owns both the HTTP handler and the event store; a server using a third-party EventStore has no seam at all. Asserted end to end by test/http.test.ts "withholds an internal URI from a resume whose principal has narrowed", which also asserts the wide resume still replays.

Actionable suggestion. Pass the resuming request's AuthInfo (or the whole RequestInfo the transport already has) into replayEventsAfter as a third field alongside send, and say in the spec's resumability section that replay is a delivery and carries the same authorization obligations as the original send. Without it, "the server MUST NOT send a message to an unauthorized client" is a rule the transport's own resume path makes impossible to keep.


Filed to the submission's product-feedback field, and F-002/F-003 additionally to the MCP specification repository. Send-by date for upstream filing: 2026-09-20 — a draft with no send date is a loss in progress.

Fourteen entries. Seven ask for a change to the MCP specification or one of its extensions — F-002, F-003, F-005, F-007, F-008, F-012, F-013. Six ask for a change to the reference TypeScript SDK — F-001, F-006, F-009, F-010, F-011, F-014. F-005 asks both, and is counted in the seven. One, F-004, is Amazon account onboarding. 7 + 6 + 1 = 14. F-005 through F-014 were found by building against the spec and the reference SDK, not by reading about them; each names the file or the measurement it came from, and all carry the same send-by date.


Decisions recorded here rather than in a commit message

Not friction — the tools did not cost us these. They are trades this build made on purpose, where the road not taken is still visible in the repository and a reader would otherwise have to guess why. Numbered apart from the F- entries so the count above stays the count.

D-001 · the change bus is in-process, and the AWS path was never built

Date: 2026-09-04 · Severity: n/a — a trade, not a defect · Touches: packages/live-resources/src/notifier.ts, scripts/bench.ts, web/index.html

The gate, written before any code. The build plan's week 1 put a DynamoDB table, a stream and a notifyHandler Lambda on days 3–4, and then set a gate against them: if write → client has new value exceeds 500 ms at p95, the "mid-sentence" claim is false — move the change bus in-process and keep the Stream for durability. Decide by Sep 8, not week 5.

What was measured. npm run bench -- --n 200 puts the whole claimed path inside one number: the clinician's HMAC-signed write, the verification, the store, the notification, and an authorized re-read by a subscribed client over Streamable HTTP. The p95 it prints is in docs/proof/bench.txt, more than an order of magnitude inside the 500 ms the gate allowed, and all 200 runs landed inside the 3,400 ms speech window.

The decision. The change bus stays in-process — the notifier in packages/live-resources, not a queue and not a stream — and no API Gateway, DynamoDB Stream or Lambda was built. The other half of the gate's sentence, keep the Stream for durability, was not taken either: there is no stream to keep. So the trade is not "we chose the fast path and kept the durable one". It is: latency bought outright, durability deferred, and nothing about durability claimed. The store, the MemoryEventStore behind resumability and the audit log are all in memory, which is already written down under Durability below and is the price of this decision.

What is therefore not true. One process. No horizontal scale — a second instance would hold its own subscriptions and its own chain. Subscriptions and replay history die with the process. And no network hop between the clinician's browser and the host is inside the measured number, because there is nowhere for one to be: nothing is deployed. (Update 2026-09-28: it is now deployed, and npm run bench -- --url measures the same loop over the public internet — p95 668 ms, docs/proof/bench.remote.txt. The loopback figure stays as the floor.)

Why it is written down here rather than in a commit message. Because two files went on describing the road not taken as though it were merely work that had not happened yet. scripts/bench.ts printed, into a committed receipt, "the deployed path adds API Gateway → DynamoDB Streams → Lambda; those segments are measured separately once deployed and this file is regenerated", and the landing page's own caveat said the same thing in the same future tense. None of it exists and none of it is planned before 2026-10-23. A future tense is the easiest place in a repository to keep a claim alive after the decision that killed it, and it survives review because it never quite asserts anything. Both files now describe the in-process bus and the p95 that bought it, and both keep the caveat that these are loopback figures.


What this build does NOT do

Friction above is what the tools cost us. This is what we did not finish, or finished in a narrower form than the words might suggest. It is here rather than in a footnote because a judge who finds one of these on their own has stopped believing the rest.

Each item names where to look, so none of it has to be taken on trust.

Deployment and AWS

OAuth

The write path

Durability

Resumability

The audience partition

CORS and transport

The demo surfaces

The number

The Alexa+ surfaces

Not started

No demo video. No screenshots of the running screens. No published npm package. No external users. No sourced epidemiology behind the impact case — the README says so in the section that would otherwise carry it, because a market-size figure this repo did not measure is exactly the kind of number it refuses to print. None of these is claimed anywhere else in this repository, and this line exists so that absence is explicit rather than merely unmentioned.

Rendered from FRICTION.md in the repository. Source as text: /FRICTION.md.