Skip to content

mcp-server: batched cleanup for /v1 drift, redaction, key gate, and scale #326

Description

@hyochan

packages/mcp-server was audited end to end while comparing our MCP architecture
against a host-side reference design. Everything below is out of scope for #321,
which took only the one finding that belonged to its contract-guard theme
(iapkit_set_client_payload no longer pins format to a hardcoded enum).

Filing these together so they can be fixed in one pass. They are independent of
each other; the ordering at the bottom is a suggestion, not a dependency chain.


1. Response shapes in kit-client.ts have drifted from kit's handlers

Every path, method, and request parameter still resolves correctly. The drift is
entirely on the response side.

1a. Product catalogs above 25 items are unreachable through MCP.
listProducts (packages/mcp-server/src/kit-client.ts:162-169) declares
{ products }, but kit returns { products, hasMore, nextCursor? }. The client
never sends limit/cursor and iapkit_list_products exposes neither, so
anything past kit's 25-item default page cannot be listed. This is the only
finding here with direct user-visible impact.

1b. metrics is missing three fields. It declares 7
(kit-client.ts:128-137); kit returns 9. Missing: reportingCurrency (required
server-side), mrrByCurrency, excludedMrrByCurrency.

1c. health declares { ok: boolean } against a 7-field payload
(kit-client.ts:275).

1d. Optionality skew. listSubscriptions.total and syncProducts.deduped
are required server-side, optional client-side.

Nothing would catch any of this. scripts/audit-kit-spec-contract.mjs reads
three files and contains no occurrence of mcp. test/kit-client.test.ts stubs
globalThis.fetch with hand-written Response objects and asserts only the URL
the client builds. packages/mcp-server has no dependency on packages/kit, so
no test can import a real handler.

2. The redaction regex corrupts error messages

redactSecretString (packages/mcp-server/src/mcp.ts:181-198) still encodes the
old key-in-path contract. Under the current Bearer scheme the segment after
/v1/products/ is a route, not a key, so the regex at line 191 redacts it:

actual:            kit /v1/products/sync/ios?... returned 403
what the model sees: kit /v1/products/<IAPKIT_SECRET_KEY>/ios?... returned 403

Reproduced by running the regex against the real KitHttpError message text.
Agents receive misleading diagnostics for every Bearer-route failure.

Note that iapkit_simulate_webhook legitimately puts a publishable key in the
path (mcp.ts:578-585) and builds its own placeholder message, so the fix needs
to keep that case working while dropping the stale route matches.

3. The publishable-key gate is bypassable through the tool argument

isPublishableApiKey is checked only on the transport's Authorization header
(web.ts:69, http.ts:137). withClient (mcp.ts:107-125) validates blank /
whitespace / length but never re-applies the prefix check, so a
openiap-kit_pk_… key passed as a tool's own apiKey argument reaches kit's
/v1 unchecked.

Severity is low, deliberately stated: each call still authenticates to kit
with that publishable key, and kit rejects publishable keys on admin routes. The
holder gains access only to routes a publishable key can already reach directly
(GET /v1/products, /v1/subscriptions/status, /v1/subscriptions/entitlements).
So this is a consistency / defense-in-depth gap, not privilege escalation. It
should still be closed so the "MCP is secret-key-only" invariant is true in one
place rather than two.

4. Deployment and scale

4a. The MCP handler calls kit through the public internet. IAPKIT_BASE_URL
is not set in the Fly deploy, so kitClient falls back to
https://kit.openiap.dev (kit-client.ts:11). The handler runs inside the kit
process and re-enters through the Fly proxy. Two consequences:

  • Each tool call occupies two slots against soft_limit = 80 / hard_limit = 120
    (packages/kit/fly.toml), and the inbound one blocks waiting on the outbound
    one. At high concurrency this can starve itself.
  • getRequestIp reads fly-client-ip
    (packages/kit/server/api/v1/rate-limit.ts:488), which for a self-call is the
    machine's own egress IP. All MCP-originated /v1 traffic therefore shares one
    per-IP bucket (capacity 600, refill 5/s).

Setting IAPKIT_BASE_URL=http://127.0.0.1:3000 in fly.toml [env] removes all
of this without a code change. The starvation mechanism is derived from the
config, not observed in production — worth confirming under load before and
after.

4b. The session map has no TTL and no cap. transports
(packages/mcp-server/src/web.ts:49-52) is only pruned on an explicit DELETE
or transport.onclose. A client that disappears without either leaks its entry,
and each session holds a full McpServer with 16 zod-schema'd tools on a 512 MB
machine. Compare rate-limit.ts:186, which caps its own map at 10k with a 15-minute
TTL and LRU eviction precisely to avoid an OOM; the session map has no equivalent.

4c. /mcp bypasses rate limiting. It is mounted at app level
(packages/kit/server/server.ts:31) while multiAxisRateLimitMiddleware lives
inside apiRoutes. Today 4a masks this, because the inner /v1 call is limited.
Fixing 4a without also rate-limiting /mcp would leave an unmetered surface, so
these two should land together.

5. Structural: the /v1 contract is hand-maintained in three places

  • packages/kit/server/api/v1/route-*-schemas.ts (valibot)
  • packages/gql/src/kit-api.ts (432 lines; its header states it "Mirrors the
    shape of packages/mcp-server/src/kit-client.ts"), manifest-synced into
    react-native-iap and expo-iap
  • packages/mcp-server/src/kit-client.ts

kit does generate an OpenAPI 3.1 document at GET /v1/openapi, but it covers 2
paths (POST /purchase/verify and its alias) with 0 component schemas. The
/products, /subscriptions, and /webhooks sub-routers use neither
describeRoute nor resolver, and hono-openapi skips undecorated routes
silently. The documented surface and the surface MCP actually calls are disjoint,
so the 16 tools cannot be generated from anything today.

Adding describeRoute to the products and subscriptions routes would make the
document complete enough to generate from, which would collapse items 1a-1d into
a category that cannot recur. That is the largest piece of work here and the only
one that needs a design discussion first.


Suggested order

  1. §2 (redaction) and §1a (pagination) — smallest fixes, clearest impact
  2. §3 (key gate) — a few lines
  3. §4a + §4c together, then §4b
  4. §5 — design discussion, then §1b-1d fall out of it

How these were found

Static reading of packages/mcp-server and packages/kit at
chore/kit-native-contract-guards, cross-checked against kit's live handlers.
The redaction bug and the OpenAPI path count were reproduced by execution; the
concurrency starvation in §4a is a mechanism derived from config and has not been
load-tested.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions