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
- §2 (redaction) and §1a (pagination) — smallest fixes, clearest impact
- §3 (key gate) — a few lines
- §4a + §4c together, then §4b
- §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.
packages/mcp-serverwas audited end to end while comparing our MCP architectureagainst 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_payloadno longer pinsformatto 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.tshave drifted from kit's handlersEvery 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 clientnever sends
limit/cursorandiapkit_list_productsexposes neither, soanything past kit's 25-item default page cannot be listed. This is the only
finding here with direct user-visible impact.
1b.
metricsis missing three fields. It declares 7(
kit-client.ts:128-137); kit returns 9. Missing:reportingCurrency(requiredserver-side),
mrrByCurrency,excludedMrrByCurrency.1c.
healthdeclares{ ok: boolean }against a 7-field payload(
kit-client.ts:275).1d. Optionality skew.
listSubscriptions.totalandsyncProducts.dedupedare required server-side, optional client-side.
Nothing would catch any of this.
scripts/audit-kit-spec-contract.mjsreadsthree files and contains no occurrence of
mcp.test/kit-client.test.tsstubsglobalThis.fetchwith hand-writtenResponseobjects and asserts only the URLthe client builds.
packages/mcp-serverhas no dependency onpackages/kit, sono test can import a real handler.
2. The redaction regex corrupts error messages
redactSecretString(packages/mcp-server/src/mcp.ts:181-198) still encodes theold 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:Reproduced by running the regex against the real
KitHttpErrormessage text.Agents receive misleading diagnostics for every Bearer-route failure.
Note that
iapkit_simulate_webhooklegitimately puts a publishable key in thepath (
mcp.ts:578-585) and builds its own placeholder message, so the fix needsto keep that case working while dropping the stale route matches.
3. The publishable-key gate is bypassable through the tool argument
isPublishableApiKeyis checked only on the transport'sAuthorizationheader(
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 ownapiKeyargument reaches kit's/v1unchecked.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_URLis not set in the Fly deploy, so
kitClientfalls back tohttps://kit.openiap.dev(kit-client.ts:11). The handler runs inside the kitprocess and re-enters through the Fly proxy. Two consequences:
soft_limit = 80/hard_limit = 120(
packages/kit/fly.toml), and the inbound one blocks waiting on the outboundone. At high concurrency this can starve itself.
getRequestIpreadsfly-client-ip(
packages/kit/server/api/v1/rate-limit.ts:488), which for a self-call is themachine's own egress IP. All MCP-originated
/v1traffic therefore shares oneper-IP bucket (capacity 600, refill 5/s).
Setting
IAPKIT_BASE_URL=http://127.0.0.1:3000infly.toml [env]removes allof 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 explicitDELETEor
transport.onclose. A client that disappears without either leaks its entry,and each session holds a full
McpServerwith 16 zod-schema'd tools on a 512 MBmachine. Compare
rate-limit.ts:186, which caps its own map at 10k with a 15-minuteTTL and LRU eviction precisely to avoid an OOM; the session map has no equivalent.
4c.
/mcpbypasses rate limiting. It is mounted at app level(
packages/kit/server/server.ts:31) whilemultiAxisRateLimitMiddlewarelivesinside
apiRoutes. Today 4a masks this, because the inner/v1call is limited.Fixing 4a without also rate-limiting
/mcpwould leave an unmetered surface, sothese two should land together.
5. Structural: the
/v1contract is hand-maintained in three placespackages/kit/server/api/v1/route-*-schemas.ts(valibot)packages/gql/src/kit-api.ts(432 lines; its header states it "Mirrors theshape of
packages/mcp-server/src/kit-client.ts"), manifest-synced intoreact-native-iap and expo-iap
packages/mcp-server/src/kit-client.tskit does generate an OpenAPI 3.1 document at
GET /v1/openapi, but it covers 2paths (
POST /purchase/verifyand its alias) with 0 component schemas. The/products,/subscriptions, and/webhookssub-routers use neitherdescribeRoutenorresolver, andhono-openapiskips undecorated routessilently. The documented surface and the surface MCP actually calls are disjoint,
so the 16 tools cannot be generated from anything today.
Adding
describeRouteto the products and subscriptions routes would make thedocument 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
How these were found
Static reading of
packages/mcp-serverandpackages/kitatchore/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.