From b6e2d5cc6485b015aeaa58dbe71e2f93ea6d2c34 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 13 Aug 2026 14:58:18 +0900 Subject: [PATCH 1/3] fix(mcp): harden contracts and capacity Expose product pagination and align MCP response shapes with Kit handlers. Reject publishable keys on every tool credential path while preserving Bearer route diagnostics. Bound MCP sessions, rate limit the hosted transport, and use loopback for in-process Kit calls. Add runtime and compile-time contract guards.\n\nCloses #326 --- packages/kit/fly.toml | 3 + .../kit/server/api/v1/mcp-contract.test.ts | 143 ++++++++++++++++++ packages/kit/server/api/v1/rate-limit.test.ts | 84 ++++++++++ packages/kit/server/api/v1/rate-limit.ts | 135 ++++++++++++----- packages/kit/server/server.ts | 30 +++- packages/mcp-server/src/http.ts | 16 +- packages/mcp-server/src/kit-client.ts | 63 ++++++-- packages/mcp-server/src/mcp.ts | 32 ++-- packages/mcp-server/src/session-store.ts | 115 ++++++++++++++ packages/mcp-server/src/web.ts | 14 +- packages/mcp-server/test/http.test.ts | 113 ++++++++++++++ packages/mcp-server/test/kit-client.test.ts | 31 +++- .../mcp-server/test/session-store.test.ts | 73 +++++++++ 13 files changed, 770 insertions(+), 82 deletions(-) create mode 100644 packages/kit/server/api/v1/mcp-contract.test.ts create mode 100644 packages/mcp-server/src/session-store.ts create mode 100644 packages/mcp-server/test/session-store.test.ts diff --git a/packages/kit/fly.toml b/packages/kit/fly.toml index 5e3b578b4..d9164e3db 100644 --- a/packages/kit/fly.toml +++ b/packages/kit/fly.toml @@ -4,6 +4,9 @@ primary_region = 'iad' [build] dockerfile = 'Dockerfile' +[env] + IAPKIT_BASE_URL = 'http://127.0.0.1:3000' + [http_service] internal_port = 3000 force_https = true diff --git a/packages/kit/server/api/v1/mcp-contract.test.ts b/packages/kit/server/api/v1/mcp-contract.test.ts new file mode 100644 index 000000000..1c9f769e8 --- /dev/null +++ b/packages/kit/server/api/v1/mcp-contract.test.ts @@ -0,0 +1,143 @@ +import type { FunctionReturnType } from "convex/server"; +import { + afterEach, + beforeEach, + describe, + expect, + expectTypeOf, + it, + vi, +} from "vitest"; + +import type { KitProductsResponse as SdkProductsResponse } from "../../../../gql/src/kit-api"; +import { kitClient } from "../../../../mcp-server/src/kit-client"; +import type { api } from "../../../convex/_generated/api"; +import type { HealthPayload } from "../../health"; + +const mocks = vi.hoisted(() => ({ + action: vi.fn(), + mutation: vi.fn(), + query: vi.fn(), +})); + +vi.mock("hono/bun", () => ({ + getConnInfo: () => ({ remote: { address: "127.0.0.1" } }), +})); + +vi.mock("../../convex", () => ({ + client: mocks, + handleConvexError: () => null, +})); + +const { apiRoutes } = await import("./routes"); + +type McpClient = ReturnType; +type McpProductsResponse = Awaited>; +type McpSubscriptionsResponse = Awaited< + ReturnType +>; +type McpMetricsResponse = Awaited>; +type McpSyncResponse = Awaited>; +type McpHealthResponse = Awaited>; + +type ServerProductsResponse = FunctionReturnType< + typeof api.products.query.listProductsPage +>; +type ServerSubscriptionsResponse = FunctionReturnType< + typeof api.subscriptions.query.listSubscriptions +>; +type ServerMetricsResponse = FunctionReturnType< + typeof api.subscriptions.query.metricsSummary +>; +type ServerSyncResponse = FunctionReturnType< + typeof api.products.jobs.enqueueProductSync +>; + +type OptionalKeys = { + [Key in keyof T]-?: object extends Pick ? Key : never; +}[keyof T]; + +describe("MCP Kit response contracts", () => { + beforeEach(() => { + mocks.action.mockReset(); + mocks.mutation.mockReset(); + mocks.query.mockReset(); + vi.stubGlobal( + "fetch", + (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + const url = new URL(request.url); + return apiRoutes.request( + `${url.pathname.replace(/^\/v1/, "")}${url.search}`, + { method: request.method, headers: request.headers }, + ); + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps product pagination envelopes aligned", () => { + expectTypeOf().toEqualTypeOf< + keyof ServerProductsResponse + >(); + expectTypeOf().toEqualTypeOf< + keyof SdkProductsResponse + >(); + expectTypeOf>().toEqualTypeOf< + OptionalKeys + >(); + expectTypeOf>().toEqualTypeOf< + OptionalKeys + >(); + }); + + it("keeps administrative response fields and optionality aligned", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + keyof ServerSubscriptionsResponse + >(); + expectTypeOf>().toEqualTypeOf< + OptionalKeys + >(); + expectTypeOf().toEqualTypeOf< + keyof ServerSyncResponse + >(); + expectTypeOf>().toEqualTypeOf< + OptionalKeys + >(); + }); + + it("keeps the health response aligned", () => { + expectTypeOf().toEqualTypeOf(); + }); + + it("calls the real catalog handler with pagination", async () => { + const responsePayload = { + products: [{ productId: "premium", platform: "IOS" }], + hasMore: true, + nextCursor: "opaque/next=2", + }; + mocks.query.mockResolvedValueOnce(responsePayload); + const client = kitClient({ + apiKey: "openiap-kit_sk_contract", + baseUrl: "http://kit.test", + }); + + await expect( + client.listProducts({ + platform: "IOS", + limit: 50, + cursor: "opaque/start=1", + }), + ).resolves.toEqual(responsePayload); + expect(mocks.query).toHaveBeenCalledWith(expect.anything(), { + apiKey: "openiap-kit_sk_contract", + platform: "IOS", + limit: 50, + cursor: "opaque/start=1", + }); + }); +}); diff --git a/packages/kit/server/api/v1/rate-limit.test.ts b/packages/kit/server/api/v1/rate-limit.test.ts index f589d3e4d..9c4ebb34d 100644 --- a/packages/kit/server/api/v1/rate-limit.test.ts +++ b/packages/kit/server/api/v1/rate-limit.test.ts @@ -8,6 +8,7 @@ import { multiAxisRateLimitMiddleware, parsePositiveNumber, rateLimitMiddleware, + sourceRateLimitMiddleware, tryConsume, type Bucket, } from "./rate-limit"; @@ -435,3 +436,86 @@ describe("multiAxisRateLimitMiddleware", () => { expect(keyStore.size).toBe(3); }); }); + +describe("sourceRateLimitMiddleware", () => { + test("limits unauthenticated requests by source IP", async () => { + const ipStore = new Map(); + const globalStore = new Map(); + const app = new Hono(); + app.post( + "/mcp", + sourceRateLimitMiddleware({ + now: () => 1_000, + getIp: (c) => c.req.header("x-test-ip"), + ip: { + capacity: 1, + refillPerSecond: 1, + maxStoreSize: 10, + store: ipStore, + }, + global: { + capacity: 10, + refillPerSecond: 1, + maxStoreSize: 1, + store: globalStore, + }, + }), + (c) => c.json({ ok: true }), + ); + + const first = await app.request("/mcp", { + method: "POST", + headers: { "x-test-ip": "203.0.113.1" }, + }); + const limited = await app.request("/mcp", { + method: "POST", + headers: { "x-test-ip": "203.0.113.1" }, + }); + const otherIp = await app.request("/mcp", { + method: "POST", + headers: { "x-test-ip": "203.0.113.2" }, + }); + + expect(first.status).toBe(200); + expect(limited.status).toBe(429); + expect(limited.headers.get("X-RateLimit-Scope")).toBe("ip"); + expect(otherIp.status).toBe(200); + }); + + test("shares a bounded process bucket across source IPs", async () => { + const app = new Hono(); + app.post( + "/mcp", + sourceRateLimitMiddleware({ + now: () => 1_000, + getIp: (c) => c.req.header("x-test-ip"), + ip: { + capacity: 10, + refillPerSecond: 1, + maxStoreSize: 10, + store: new Map(), + }, + global: { + capacity: 1, + refillPerSecond: 1, + maxStoreSize: 1, + store: new Map(), + }, + }), + (c) => c.json({ ok: true }), + ); + + const first = await app.request("/mcp", { + method: "POST", + headers: { "x-test-ip": "203.0.113.1" }, + }); + const limited = await app.request("/mcp", { + method: "POST", + headers: { "x-test-ip": "203.0.113.2" }, + }); + + expect(first.status).toBe(200); + expect(limited.status).toBe(429); + expect(limited.headers.get("X-RateLimit-Scope")).toBe("global"); + }); +}); diff --git a/packages/kit/server/api/v1/rate-limit.ts b/packages/kit/server/api/v1/rate-limit.ts index 8ab7abab4..03972b058 100644 --- a/packages/kit/server/api/v1/rate-limit.ts +++ b/packages/kit/server/api/v1/rate-limit.ts @@ -55,6 +55,16 @@ export interface MultiAxisRateLimitConfig { getIp?: (c: Context) => string | undefined; } +type RateLimitResponder = (c: Context, result: ConsumeResult) => Response; + +export interface SourceRateLimitConfig { + ip?: Partial; + global?: Partial; + now?: () => number; + getIp?: (c: Context) => string | undefined; + respond?: RateLimitResponder; +} + export interface ConsumeResult { allowed: boolean; remaining: number; @@ -202,6 +212,8 @@ const DEFAULT_STORE_TTL_MS = const sharedStore = new Map(); const sharedIpStore = new Map(); const sharedGlobalStore = new Map(); +const sharedSourceIpStore = new Map(); +const sharedSourceGlobalStore = new Map(); const DEFAULT_IP_CAPACITY = parsePositiveNumber( process.env.RATE_LIMIT_IP_CAPACITY, @@ -349,13 +361,7 @@ function resolveAxis( }; } -function rateLimitResponse( - c: Context, - scope: AxisRuntime["scope"], - result: ConsumeResult, -) { - c.header("X-RateLimit-Scope", scope); - c.header("Retry-After", String(result.retryAfterSec)); +function rateLimitResponse(c: Context, result: ConsumeResult) { return c.json( { errors: [ @@ -369,6 +375,47 @@ function rateLimitResponse( ); } +async function applyRateLimitAxes( + c: Context, + next: () => Promise, + axes: AxisRuntime[], + nowMs: number, + primaryScope: AxisRuntime["scope"], + respond: RateLimitResponder = rateLimitResponse, +): Promise { + if (axes.every((axis) => axis.cost <= 0)) { + await next(); + return; + } + + let primaryResult: ConsumeResult | null = null; + for (const axis of axes) { + const result = tryConsume( + axis.store, + axis.identifier, + axis.capacity, + axis.refillPerSecond, + nowMs, + axis.maxStoreSize, + axis.ttlMs, + axis.cost, + ); + if (axis.scope === primaryScope) primaryResult = result; + if (!result.allowed) { + c.header("X-RateLimit-Limit", String(axis.capacity)); + c.header("X-RateLimit-Remaining", String(result.remaining)); + c.header("X-RateLimit-Scope", axis.scope); + c.header("Retry-After", String(result.retryAfterSec)); + return respond(c, result); + } + } + + const primaryAxis = axes.find((axis) => axis.scope === primaryScope); + c.header("X-RateLimit-Limit", String(primaryAxis?.capacity ?? 0)); + c.header("X-RateLimit-Remaining", String(primaryResult?.remaining ?? 0)); + await next(); +} + /** * Cost guard shared by receipt verification and every publishable-key API. * @@ -445,37 +492,55 @@ export function multiAxisRateLimitMiddleware( ), ] satisfies AxisRuntime[]; - if (axes.every((axis) => axis.cost <= 0)) { - await next(); - return; - } + return applyRateLimitAxes(c, next, axes, nowMs, "key"); + }); +} - let keyResult: ConsumeResult | null = null; - for (const axis of axes) { - const result = tryConsume( - axis.store, - axis.identifier, - axis.capacity, - axis.refillPerSecond, - nowMs, - axis.maxStoreSize, - axis.ttlMs, - axis.cost, - ); - if (axis.scope === "key") keyResult = result; - if (!result.allowed) { - c.header("X-RateLimit-Limit", String(axis.capacity)); - c.header("X-RateLimit-Remaining", String(result.remaining)); - return rateLimitResponse(c, axis.scope, result); - } - } +/** Protects unauthenticated transport surfaces by source IP and process. */ +export function sourceRateLimitMiddleware( + config: SourceRateLimitConfig = {}, +): ReturnType { + const clock = config.now ?? (() => Date.now()); + const getIp = config.getIp ?? getRequestIp; + + return createMiddleware(async (c, next) => { + const ip = getIp(c) ?? "unknown"; + const nowMs = clock(); + const axes = [ + resolveAxis( + "ip", + hashApiKey(`source:${ip}`), + config.ip, + { + capacity: DEFAULT_IP_CAPACITY, + refillPerSecond: DEFAULT_IP_REFILL_PER_SEC, + maxStoreSize: DEFAULT_IP_MAX_STORE_SIZE, + store: sharedSourceIpStore, + }, + c, + ), + resolveAxis( + "global", + "source-process", + config.global, + { + capacity: DEFAULT_GLOBAL_CAPACITY, + refillPerSecond: DEFAULT_GLOBAL_REFILL_PER_SEC, + maxStoreSize: 1, + store: sharedSourceGlobalStore, + }, + c, + ), + ] satisfies AxisRuntime[]; - c.header( - "X-RateLimit-Limit", - String(config.key?.capacity ?? DEFAULT_CAPACITY), + return applyRateLimitAxes( + c, + next, + axes, + nowMs, + "ip", + config.respond ?? rateLimitResponse, ); - c.header("X-RateLimit-Remaining", String(keyResult?.remaining ?? 0)); - await next(); }); } diff --git a/packages/kit/server/server.ts b/packages/kit/server/server.ts index e2382f879..8a191447d 100644 --- a/packages/kit/server/server.ts +++ b/packages/kit/server/server.ts @@ -1,17 +1,44 @@ import "./sentry"; -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; import { serveStatic } from "hono/bun"; import { promises as fs } from "node:fs"; import path from "node:path"; import { apiRoutes } from "./api/v1/routes"; +import { + sourceRateLimitMiddleware, + type ConsumeResult, +} from "./api/v1/rate-limit"; import { handleHealthRequest } from "./health"; import { handleIapKitMcpRequest } from "./mcp"; import { shouldReturnNotFoundForMissingStaticPath } from "./staticPaths"; import { parsePort } from "./utils/env"; const app = new Hono(); +const mcpRateLimit = sourceRateLimitMiddleware({ + ip: { cost: mcpRequestCost }, + global: { cost: mcpRequestCost }, + respond: mcpRateLimitResponse, +}); + +function mcpRequestCost(c: Context): number { + return c.req.method === "OPTIONS" ? 0 : 1; +} + +function mcpRateLimitResponse(c: Context, result: ConsumeResult): Response { + return c.json( + { + jsonrpc: "2.0", + error: { + code: -32000, + message: `Too many requests. Retry after ${result.retryAfterSec}s.`, + }, + id: null, + }, + 429, + ); +} // Liveness/readiness probe. No DB hit — Fly.io health checks fire // frequently and hitting Convex from here would both mask real @@ -28,6 +55,7 @@ app.route("/v1", apiRoutes); // Codex / MCP plugin endpoint for IAPKit. This must sit before // static serving so `/mcp` never falls through to the React Router SPA. +app.use("/mcp", mcpRateLimit); app.all("/mcp", (c) => handleIapKitMcpRequest(c.req.raw)); // Never let an unknown API subroute fall through to the React Router SPA. diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index eefb6e070..a436b1fd1 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -26,6 +26,10 @@ import { currentMachineId, routeUnknownSession, } from "./session-routing.js"; +import { + createMcpSessionStore, + type BoundedSessionStore, +} from "./session-store.js"; const DEFAULT_MCP_PATH = "/mcp"; const DEFAULT_PORT = 3939; @@ -85,7 +89,8 @@ export function createRemoteMcpHttpServer( options.allowedOrigins ?? parseAllowedOrigins(process.env.IAPKIT_MCP_ALLOWED_ORIGINS); const machineId = options.machineId ?? currentMachineId(); - const transports = new Map(); + const transports = + createMcpSessionStore(logger); const server = createServer(async (req, res) => { try { @@ -179,10 +184,7 @@ export function createRemoteMcpHttpServer( }); async function close(): Promise { - await Promise.all( - Array.from(transports.values()).map((transport) => transport.close()), - ); - transports.clear(); + await transports.closeAll(); await new Promise((resolve, reject) => { server.close((error) => { if (error) reject(error); @@ -235,7 +237,7 @@ export async function startRemoteMcpHttpServer( async function handleMcpPost( req: AuthenticatedRequest, res: ServerResponse, - transports: Map, + transports: BoundedSessionStore, logger: Pick, machineId: string | undefined, ): Promise { @@ -288,7 +290,7 @@ async function handleMcpPost( async function handleExistingMcpSession( req: IncomingMessage, res: ServerResponse, - transports: Map, + transports: BoundedSessionStore, machineId: string | undefined, ): Promise { const sessionId = headerString(req.headers["mcp-session-id"]); diff --git a/packages/mcp-server/src/kit-client.ts b/packages/mcp-server/src/kit-client.ts index 4733c0051..17e2f4fba 100644 --- a/packages/mcp-server/src/kit-client.ts +++ b/packages/mcp-server/src/kit-client.ts @@ -8,6 +8,46 @@ export type KitClientOptions = { apiKey: string; }; +export interface KitProductListParams { + platform?: "IOS" | "Android"; + limit?: number; + cursor?: string; +} + +export interface KitProductsResponse { + products: unknown[]; + hasMore: boolean; + nextCursor?: string; +} + +export interface KitMrrCurrencyEntry { + currency: string; + mrrMicros: number; +} + +export interface KitMetricsResponse { + activeSubs: number; + inGracePeriod: number; + inBillingRetry: number; + refunded30d: number; + canceled30d: number; + mrrMicros: number; + currency: string; + reportingCurrency: string; + mrrByCurrency: KitMrrCurrencyEntry[]; + excludedMrrByCurrency: KitMrrCurrencyEntry[]; +} + +export interface KitHealthResponse { + ok: true; + status: "healthy"; + service: "iapkit"; + apiVersion: "v1"; + revision: string | null; + environment: string; + timestamp: string; +} + const DEFAULT_BASE_URL = "https://kit.openiap.dev"; export function normalizeKitBaseUrl(baseUrl?: string): string { @@ -121,20 +161,11 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { if (params.userId) usp.set("userId", params.userId); if (params.limit) usp.set("limit", String(params.limit)); const qs = usp.toString(); - return adminCall<{ items: unknown[]; total?: number }>( + return adminCall<{ items: unknown[]; total: number }>( `/v1/subscriptions/list${qs ? `?${qs}` : ""}`, ); }, - metrics: () => - adminCall<{ - activeSubs: number; - inGracePeriod: number; - inBillingRetry: number; - refunded30d: number; - canceled30d: number; - mrrMicros: number; - currency?: string; - }>("/v1/subscriptions/metrics"), + metrics: () => adminCall("/v1/subscriptions/metrics"), revenueMetrics: (params: { fromDay: string; toDay: string }) => { const usp = new URLSearchParams({ fromDay: params.fromDay, @@ -159,11 +190,13 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { truncated: boolean; }>(`/v1/subscriptions/revenue?${usp.toString()}`); }, - listProducts: (params: { platform?: "IOS" | "Android" } = {}) => { + listProducts: (params: KitProductListParams = {}) => { const usp = new URLSearchParams(); if (params.platform) usp.set("platform", params.platform); + if (params.limit !== undefined) usp.set("limit", String(params.limit)); + if (params.cursor !== undefined) usp.set("cursor", params.cursor); const qs = usp.toString(); - return adminCall<{ products: unknown[] }>( + return adminCall( `/v1/products${qs ? `?${qs}` : ""}`, ); }, @@ -266,13 +299,13 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { direction: params.direction, dryRun: String(params.dryRun), }); - return adminCall<{ jobId: string; deduped?: boolean }>( + return adminCall<{ jobId: string; deduped: boolean }>( `/v1/products/sync/${platformPath}?${usp.toString()}`, { method: "POST" }, ); }, syncJob: (jobId: string) => adminCall(`/v1/products/sync/jobs/${encodeURIComponent(jobId)}`), - health: () => call<{ ok: boolean }>("/health"), + health: () => call("/health"), }; } diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts index bfeda62d3..32ba704ea 100644 --- a/packages/mcp-server/src/mcp.ts +++ b/packages/mcp-server/src/mcp.ts @@ -7,6 +7,10 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +import { + INSUFFICIENT_API_KEY_SCOPE_MESSAGE, + isPublishableApiKey, +} from "./auth.js"; import { kitClient, KitHttpError, normalizeKitBaseUrl } from "./kit-client.js"; // MCP server for IAPKit. Every tool funnels through `withClient` @@ -42,6 +46,8 @@ const PUBLISHABLE_KEY_PLACEHOLDER = ""; const MAX_API_KEY_LENGTH = 128; const MAX_KIT_ID_LENGTH = 256; const MAX_CLIENT_PAYLOAD_BYTES = 16 * 1024; +const MAX_PRODUCT_PAGE_SIZE = 50; +const MAX_PRODUCT_CURSOR_LENGTH = 4096; const MAX_PRICE_AMOUNT_MICROS = Number.MAX_SAFE_INTEGER; const READ_ONLY_TOOL: ToolAnnotations = { readOnlyHint: true, @@ -111,13 +117,16 @@ function withClient( const apiKey = resolveApiKey(opts, extra); if (!apiKey) { throw new Error( - "No IAPKit secret admin key was provided. Set Authorization: Bearer , IAPKIT_API_KEY, or the tool's apiKey argument.", + `No IAPKit secret admin key was provided. Set Authorization: Bearer ${API_KEY_PLACEHOLDER}, IAPKIT_API_KEY, or the tool's apiKey argument.`, ); } const validationError = validateApiKey(apiKey); if (validationError) { throw new Error(validationError); } + if (isPublishableApiKey(apiKey)) { + throw new Error(INSUFFICIENT_API_KEY_SCOPE_MESSAGE); + } return kitClient({ apiKey, baseUrl: opts.baseUrl ?? process.env.IAPKIT_BASE_URL, @@ -186,15 +195,10 @@ function redactSecretString(value: string, apiKey?: string): string { for (const secret of knownSecrets) { redacted = redacted.split(secret).join(API_KEY_PLACEHOLDER); } - return redacted - .replace( - /(\/v1\/(?:subscriptions\/(?:status|entitlements|list|metrics|revenue)|products|webhooks\/(?:apple|google)|webhooks)\/)[^/?\s"]+/g, - `$1${API_KEY_PLACEHOLDER}`, - ) - .replace( - /(Authorization:\s*Bearer\s+)[^\s"]+/gi, - `$1${API_KEY_PLACEHOLDER}`, - ); + return redacted.replace( + /(Authorization:\s*Bearer\s+)[^\s"]+/gi, + `$1${API_KEY_PLACEHOLDER}`, + ); } function registerTool( @@ -433,9 +437,13 @@ function registerIapKitTools(server: McpServer) { registerTool( server, "list_products", - "List the project's product catalog stored in IAPKit.", + "List one page of the project's product catalog stored in IAPKit. Use nextCursor until hasMore is false.", { platform: z.enum(["IOS", "Android"]).optional(), + limit: z.number().int().min(1).max(MAX_PRODUCT_PAGE_SIZE).optional(), + cursor: kitTextParam("cursor", MAX_PRODUCT_CURSOR_LENGTH) + .optional() + .describe("Opaque nextCursor returned by the previous page."), apiKey: OPTIONAL_API_KEY, baseUrl: OPTIONAL_BASE_URL, }, @@ -445,6 +453,8 @@ function registerIapKitTools(server: McpServer) { return ok( await withClient(args, extra).listProducts({ platform: args.platform, + limit: args.limit, + cursor: args.cursor, }), ); } catch (error) { diff --git a/packages/mcp-server/src/session-store.ts b/packages/mcp-server/src/session-store.ts new file mode 100644 index 000000000..01f71676b --- /dev/null +++ b/packages/mcp-server/src/session-store.ts @@ -0,0 +1,115 @@ +interface SessionEntry { + value: T; + lastAccessedAt: number; +} + +const MAX_MCP_SESSIONS = 256; +const MCP_SESSION_IDLE_TTL_MS = 15 * 60 * 1000; + +interface ClosableSession { + close: () => void | Promise; +} + +export interface BoundedSessionStoreOptions { + maxSize: number; + idleTtlMs: number; + dispose: (value: T) => void | Promise; + now?: () => number; + onDisposeError?: (error: unknown) => void; +} + +export class BoundedSessionStore { + private readonly entries = new Map>(); + private readonly now: () => number; + + constructor(private readonly options: BoundedSessionStoreOptions) { + if (!Number.isSafeInteger(options.maxSize) || options.maxSize < 1) { + throw new Error("session maxSize must be a positive integer"); + } + if (!Number.isFinite(options.idleTtlMs) || options.idleTtlMs <= 0) { + throw new Error("session idleTtlMs must be positive"); + } + this.now = options.now ?? (() => Date.now()); + } + + get size(): number { + return this.entries.size; + } + + get(sessionId: string): T | undefined { + const now = this.now(); + this.pruneExpired(now); + const entry = this.entries.get(sessionId); + if (!entry) return undefined; + + this.entries.delete(sessionId); + this.entries.set(sessionId, { value: entry.value, lastAccessedAt: now }); + return entry.value; + } + + set(sessionId: string, value: T): void { + const now = this.now(); + this.pruneExpired(now); + const previous = this.entries.get(sessionId); + this.entries.delete(sessionId); + if (previous && previous.value !== value) { + void this.dispose(previous.value); + } + this.entries.set(sessionId, { value, lastAccessedAt: now }); + + while (this.entries.size > this.options.maxSize) { + const oldestSessionId = this.entries.keys().next().value; + if (oldestSessionId === undefined) break; + this.evict(oldestSessionId); + } + } + + delete(sessionId: string): boolean { + return this.entries.delete(sessionId); + } + + pruneExpired(now: number = this.now()): void { + while (this.entries.size > 0) { + const oldestSessionId = this.entries.keys().next().value; + if (oldestSessionId === undefined) return; + const oldest = this.entries.get(oldestSessionId); + if (oldest && now - oldest.lastAccessedAt < this.options.idleTtlMs) { + return; + } + this.evict(oldestSessionId); + } + } + + async closeAll(): Promise { + const values = Array.from(this.entries.values(), (entry) => entry.value); + this.entries.clear(); + await Promise.all(values.map((value) => this.dispose(value))); + } + + private evict(sessionId: string): void { + const entry = this.entries.get(sessionId); + if (!entry) return; + this.entries.delete(sessionId); + void this.dispose(entry.value); + } + + private async dispose(value: T): Promise { + try { + await this.options.dispose(value); + } catch (error) { + this.options.onDisposeError?.(error); + } + } +} + +export function createMcpSessionStore( + logger: Pick, +): BoundedSessionStore { + return new BoundedSessionStore({ + maxSize: MAX_MCP_SESSIONS, + idleTtlMs: MCP_SESSION_IDLE_TTL_MS, + dispose: (transport) => transport.close(), + onDisposeError: (error) => + logger.error("IAPKit MCP session cleanup failed:", error), + }); +} diff --git a/packages/mcp-server/src/web.ts b/packages/mcp-server/src/web.ts index 9da6442df..36692d11b 100644 --- a/packages/mcp-server/src/web.ts +++ b/packages/mcp-server/src/web.ts @@ -14,6 +14,10 @@ import { currentMachineId, routeUnknownSession, } from "./session-routing.js"; +import { + createMcpSessionStore, + type BoundedSessionStore, +} from "./session-store.js"; const MAX_MCP_BODY_BYTES = 1024 * 1024; const MCP_BODY_TOO_LARGE_ERROR = "MCP request body is too large"; @@ -46,10 +50,8 @@ export function createIapKitWebMcpHandler( options.allowedOrigins ?? parseAllowedOrigins(process.env.IAPKIT_MCP_ALLOWED_ORIGINS); const machineId = options.machineId ?? currentMachineId(); - const transports = new Map< - string, - WebStandardStreamableHTTPServerTransport - >(); + const transports = + createMcpSessionStore(logger); return async function handleIapKitMcpRequest( request: Request, @@ -129,7 +131,7 @@ export function createIapKitWebMcpHandler( async function handlePost( request: Request, - transports: Map, + transports: BoundedSessionStore, logger: Pick, authInfo: AuthInfo | undefined, machineId: string | undefined, @@ -185,7 +187,7 @@ async function handlePost( async function handleExistingSession( request: Request, - transports: Map, + transports: BoundedSessionStore, authInfo: AuthInfo | undefined, machineId: string | undefined, ): Promise { diff --git a/packages/mcp-server/test/http.test.ts b/packages/mcp-server/test/http.test.ts index 5deb39df6..fdfbb55f2 100644 --- a/packages/mcp-server/test/http.test.ts +++ b/packages/mcp-server/test/http.test.ts @@ -169,6 +169,21 @@ describe("remote MCP HTTP server", () => { ]), ); expect(regionSchema?.description).toContain("Send [] to clear"); + + const listProducts = toolsByName.get("iapkit_list_products") as + | { + inputSchema?: { + properties?: { + limit?: { maximum?: number }; + cursor?: { description?: string }; + }; + }; + } + | undefined; + expect(listProducts?.inputSchema?.properties?.limit?.maximum).toBe(50); + expect( + listProducts?.inputSchema?.properties?.cursor?.description, + ).toContain("nextCursor"); }); it("returns 403 before a publishable key can initialize the admin MCP surface", async () => { @@ -200,6 +215,63 @@ describe("remote MCP HTTP server", () => { }); }); + it("rejects publishable keys supplied through tool arguments", async () => { + const { baseUrl, sessionId } = await initializeMcpSession(); + + const payload = await callTool<{ + ok: false; + error: { message: string }; + }>(baseUrl, sessionId, "iapkit_list_products", { + apiKey: "openiap-kit_pk_mobile", + }); + + expect(payload.ok).toBe(false); + expect(payload.error.message).toBe( + "This operation requires a secret admin key. Publishable mobile keys cannot access MCP administrative operations.", + ); + }); + + it("paginates the product catalog through MCP", async () => { + const apiKey = "openiap-kit_sk_catalog"; + const previousBaseUrl = process.env.IAPKIT_BASE_URL; + process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => { + expect(req.url).toBe( + "/v1/products?platform=IOS&limit=50&cursor=opaque%2Fstart%3D1", + ); + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + products: [{ productId: "premium" }], + hasMore: true, + nextCursor: "opaque/next=2", + }), + ); + }); + + try { + const { baseUrl, sessionId } = await initializeMcpSession(apiKey); + const payload = await callTool<{ + products: Array<{ productId: string }>; + hasMore: boolean; + nextCursor?: string; + }>(baseUrl, sessionId, "iapkit_list_products", { + platform: "IOS", + limit: 50, + cursor: "opaque/start=1", + apiKey, + }); + + expect(payload).toEqual({ + products: [{ productId: "premium" }], + hasMore: true, + nextCursor: "opaque/next=2", + }); + } finally { + if (previousBaseUrl === undefined) delete process.env.IAPKIT_BASE_URL; + else process.env.IAPKIT_BASE_URL = previousBaseUrl; + } + }); + it("summarizes revenue analytics through the bearer-authenticated Kit API", async () => { const apiKey = "openiap-kit_secret_revenue"; const previousBaseUrl = process.env.IAPKIT_BASE_URL; @@ -835,6 +907,47 @@ describe("remote MCP HTTP server", () => { } }); + it("keeps Bearer-authenticated route names in redacted errors", async () => { + const apiKey = "openiap-kit_sk_sync_redaction"; + const previousBaseUrl = process.env.IAPKIT_BASE_URL; + process.env.IAPKIT_BASE_URL = await startKitApi((_req, res) => { + res.writeHead(403, { "content-type": "application/json" }); + res.end(JSON.stringify({ errors: [{ code: "FORBIDDEN" }] })); + }); + + try { + const { baseUrl, sessionId } = await initializeMcpSession(apiKey); + const response = await postMcp( + baseUrl, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "iapkit_sync_products", + arguments: { + platform: "IOS", + direction: "push", + dryRun: true, + }, + }, + }, + sessionId, + { authorization: `Bearer ${apiKey}` }, + ); + const callBody = await response.text(); + + expect(callBody).toContain( + "kit /v1/products/sync/ios?direction=push&dryRun=true returned 403", + ); + expect(callBody).not.toContain("products/"); + expect(callBody).not.toContain(apiKey); + } finally { + if (previousBaseUrl === undefined) delete process.env.IAPKIT_BASE_URL; + else process.env.IAPKIT_BASE_URL = previousBaseUrl; + } + }); + it("redacts API keys from partial diagnostic errors", async () => { const apiKey = "openiap-kit_secret_diagnostic"; const previousBaseUrl = process.env.IAPKIT_BASE_URL; diff --git a/packages/mcp-server/test/kit-client.test.ts b/packages/mcp-server/test/kit-client.test.ts index 17864a4a2..335473d91 100644 --- a/packages/mcp-server/test/kit-client.test.ts +++ b/packages/mcp-server/test/kit-client.test.ts @@ -115,12 +115,19 @@ describe("kitClient", () => { it("parses JSON response content types case-insensitively", async () => { const fetchMock = vi.fn(async () => { - return new Response(JSON.stringify({ products: [] }), { - status: 200, - headers: { - "content-type": "Application/VND.OPENIAP+JSON ; Charset=UTF-8", + return new Response( + JSON.stringify({ + products: [{ productId: "premium" }], + hasMore: true, + nextCursor: "opaque/next=2", + }), + { + status: 200, + headers: { + "content-type": "Application/VND.OPENIAP+JSON ; Charset=UTF-8", + }, }, - }); + ); }); vi.stubGlobal("fetch", fetchMock); @@ -129,9 +136,19 @@ describe("kitClient", () => { baseUrl: "https://kit.example", }); - await expect(client.listProducts()).resolves.toEqual({ products: [] }); + await expect( + client.listProducts({ + platform: "IOS", + limit: 50, + cursor: "opaque/start=1", + }), + ).resolves.toEqual({ + products: [{ productId: "premium" }], + hasMore: true, + nextCursor: "opaque/next=2", + }); expect(fetchMock).toHaveBeenCalledWith( - "https://kit.example/v1/products", + "https://kit.example/v1/products?platform=IOS&limit=50&cursor=opaque%2Fstart%3D1", expect.objectContaining({ headers: expect.objectContaining({ authorization: "Bearer custom-secret", diff --git a/packages/mcp-server/test/session-store.test.ts b/packages/mcp-server/test/session-store.test.ts new file mode 100644 index 000000000..ccfbb4cf3 --- /dev/null +++ b/packages/mcp-server/test/session-store.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; + +import { BoundedSessionStore } from "../src/session-store"; + +describe("BoundedSessionStore", () => { + it("expires idle sessions and refreshes active sessions", async () => { + let now = 0; + const dispose = vi.fn(async () => undefined); + const store = new BoundedSessionStore({ + maxSize: 2, + idleTtlMs: 100, + now: () => now, + dispose, + }); + + store.set("active", "active-transport"); + now = 50; + expect(store.get("active")).toBe("active-transport"); + now = 120; + store.set("next", "next-transport"); + expect(store.get("active")).toBe("active-transport"); + + now = 221; + expect(store.get("active")).toBeUndefined(); + await vi.waitFor(() => + expect(dispose).toHaveBeenCalledWith("active-transport"), + ); + }); + + it("evicts the least recently used session at the cap", async () => { + let now = 0; + const dispose = vi.fn(async () => undefined); + const store = new BoundedSessionStore({ + maxSize: 2, + idleTtlMs: 1_000, + now: () => now, + dispose, + }); + + store.set("oldest", "oldest-transport"); + now = 1; + store.set("recent", "recent-transport"); + now = 2; + expect(store.get("oldest")).toBe("oldest-transport"); + now = 3; + store.set("new", "new-transport"); + + expect(store.get("recent")).toBeUndefined(); + expect(store.get("oldest")).toBe("oldest-transport"); + expect(store.get("new")).toBe("new-transport"); + await vi.waitFor(() => + expect(dispose).toHaveBeenCalledWith("recent-transport"), + ); + }); + + it("closes every remaining session during shutdown", async () => { + const dispose = vi.fn(async () => undefined); + const store = new BoundedSessionStore({ + maxSize: 2, + idleTtlMs: 100, + dispose, + }); + store.set("one", "transport-one"); + store.set("two", "transport-two"); + + await store.closeAll(); + + expect(store.size).toBe(0); + expect(dispose).toHaveBeenCalledTimes(2); + expect(dispose).toHaveBeenCalledWith("transport-one"); + expect(dispose).toHaveBeenCalledWith("transport-two"); + }); +}); From 1ce4babb8fa712887cec3ae64c481f3a03fb04b4 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 13 Aug 2026 17:43:38 +0900 Subject: [PATCH 2/3] fix(mcp): close review blockers --- bun.lock | 1 + libraries/expo-iap/src/kit-api.ts | 122 +++++++++++++- libraries/react-native-iap/src/kit-api.ts | 122 +++++++++++++- packages/gql/src/kit-api.ts | 122 +++++++++++++- packages/kit/fly.toml | 1 + .../kit/server/api/v1/mcp-contract.test.ts | 115 +++++++++++--- packages/kit/server/api/v1/rate-limit.test.ts | 149 +++++++++++++++++- packages/kit/server/api/v1/rate-limit.ts | 113 ++++++++----- packages/kit/server/api/v1/subscriptions.ts | 7 + packages/kit/server/mcp.test.ts | 3 +- packages/kit/server/mcp.ts | 29 +++- packages/kit/server/server.ts | 21 +-- packages/mcp-server/README.md | 3 + packages/mcp-server/package.json | 2 + packages/mcp-server/src/auth.ts | 13 +- packages/mcp-server/src/http.ts | 43 +++-- packages/mcp-server/src/kit-client.ts | 115 ++++++-------- packages/mcp-server/src/mcp.ts | 13 +- packages/mcp-server/src/session-store.ts | 50 ++++-- packages/mcp-server/src/web.ts | 71 ++++++--- packages/mcp-server/test/http.test.ts | 149 ++++++++++++------ packages/mcp-server/test/kit-client.test.ts | 33 +++- .../mcp-server/test/session-store.test.ts | 48 ++++-- packages/mcp-server/test/web.test.ts | 30 +++- 24 files changed, 1098 insertions(+), 277 deletions(-) diff --git a/bun.lock b/bun.lock index 7d3349001..67ea2c954 100644 --- a/bun.lock +++ b/bun.lock @@ -167,6 +167,7 @@ "openiap-mcp": "./dist/index.js", }, "dependencies": { + "@hyodotdev/openiap-gql": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^3.23.8", }, diff --git a/libraries/expo-iap/src/kit-api.ts b/libraries/expo-iap/src/kit-api.ts index f5a78d7af..928c71f7d 100644 --- a/libraries/expo-iap/src/kit-api.ts +++ b/libraries/expo-iap/src/kit-api.ts @@ -31,7 +31,15 @@ export type KitSubscription = { id: string; productId: string; platform: "IOS" | "Android"; - state: string; + state: + | "Active" + | "InGracePeriod" + | "InBillingRetry" + | "Expired" + | "Revoked" + | "Refunded" + | "Paused" + | "Unknown"; expiresAt?: number; renewsAt?: number; willRenew?: boolean; @@ -41,6 +49,7 @@ export type KitSubscription = { startedAt: number; updatedAt: number; purchaseToken: string; + originalTransactionId?: string; userId?: string; }; @@ -85,6 +94,13 @@ export type KitProduct = { type: "Subscription" | "NonConsumable" | "Consumable"; title: string; description?: string; + baseLocale?: string; + localizations?: Array<{ + locale: string; + title: string; + description?: string; + }>; + regions?: "all" | string[]; priceAmountMicros?: number; currency?: string; state: "Draft" | "Ready" | "Active" | "Removed"; @@ -114,6 +130,108 @@ export type KitProductsResponse = { nextCursor?: string; }; +export type KitSubscriptionsResponse = { + items: KitSubscription[]; + total: number; +}; + +export type KitMrrCurrencyEntry = { + currency: string; + mrrMicros: number; +}; + +export type KitMetricsResponse = { + activeSubs: number; + inGracePeriod: number; + inBillingRetry: number; + refunded30d: number; + canceled30d: number; + mrrMicros: number; + currency: string; + reportingCurrency: string; + mrrByCurrency: KitMrrCurrencyEntry[]; + excludedMrrByCurrency: KitMrrCurrencyEntry[]; +}; + +export type KitRevenueMetricsResponse = { + days: Array<{ + day: string; + currency: string; + productId: string; + platform: KitProductPlatform; + activeSubs: number; + newSubs: number; + renewals: number; + cancellations: number; + refunds: number; + revenueMicros: number; + }>; + currencies: string[]; + productIds: string[]; + platforms: KitProductPlatform[]; + truncated: boolean; +}; + +export type KitProductUpsertResponse = { id: string; created: boolean }; +export type KitProductStateResponse = { + id: string; + state: KitProduct["state"]; +}; +export type KitClientPayloadStateResponse = { + expectedVersion: number; + clientPayload?: KitProductClientPayload; +}; +export type KitSetClientPayloadResponse = { + id: string; + created: boolean; + changed: boolean; + version: number; + updatedAt: number; +}; +export type KitRemoveClientPayloadResponse = { ok: boolean }; +export type KitProductSyncResponse = { jobId: string; deduped: boolean }; +export type KitProductSyncJobResponse = { + _id: string; + _creationTime: number; + projectId: string; + platform: KitProductPlatform; + direction: "pull" | "push" | "both" | "purge-local"; + dryRun: boolean; + status: "queued" | "running" | "succeeded" | "failed"; + progress: { + phase: string; + current?: number; + total?: number; + failuresCount?: number; + }; + result?: { + pulled: number; + pushed: number; + deleted?: number; + failures: Array<{ productId: string; reason: string }>; + failuresTruncated?: boolean; + plannedWrites?: Array<{ + productId: string; + step: string; + detail?: string; + }>; + plannedWritesTruncated?: boolean; + manualActions?: Array<{ + productId: string; + code: string; + message: string; + }>; + manualActionsTruncated?: boolean; + }; + error?: string; + cancelRequested?: boolean; + expectedDeadline?: number; + createdBy?: string; + startedAt?: number; + completedAt?: number; + createdAt: number; +}; + export type KitClientPayloadResponse = { clientPayload: KitProductClientPayload; }; @@ -136,7 +254,7 @@ const DEFAULT_BASE_URL = "https://kit.openiap.dev"; function mergeHeaders( callerHeaders: Record | undefined, hasBody: boolean, -): HeadersInit { +): RequestInit["headers"] { if (typeof Headers === "function") { const merged = new Headers(callerHeaders); if (!merged.has("accept")) merged.set("accept", "application/json"); diff --git a/libraries/react-native-iap/src/kit-api.ts b/libraries/react-native-iap/src/kit-api.ts index f5a78d7af..928c71f7d 100644 --- a/libraries/react-native-iap/src/kit-api.ts +++ b/libraries/react-native-iap/src/kit-api.ts @@ -31,7 +31,15 @@ export type KitSubscription = { id: string; productId: string; platform: "IOS" | "Android"; - state: string; + state: + | "Active" + | "InGracePeriod" + | "InBillingRetry" + | "Expired" + | "Revoked" + | "Refunded" + | "Paused" + | "Unknown"; expiresAt?: number; renewsAt?: number; willRenew?: boolean; @@ -41,6 +49,7 @@ export type KitSubscription = { startedAt: number; updatedAt: number; purchaseToken: string; + originalTransactionId?: string; userId?: string; }; @@ -85,6 +94,13 @@ export type KitProduct = { type: "Subscription" | "NonConsumable" | "Consumable"; title: string; description?: string; + baseLocale?: string; + localizations?: Array<{ + locale: string; + title: string; + description?: string; + }>; + regions?: "all" | string[]; priceAmountMicros?: number; currency?: string; state: "Draft" | "Ready" | "Active" | "Removed"; @@ -114,6 +130,108 @@ export type KitProductsResponse = { nextCursor?: string; }; +export type KitSubscriptionsResponse = { + items: KitSubscription[]; + total: number; +}; + +export type KitMrrCurrencyEntry = { + currency: string; + mrrMicros: number; +}; + +export type KitMetricsResponse = { + activeSubs: number; + inGracePeriod: number; + inBillingRetry: number; + refunded30d: number; + canceled30d: number; + mrrMicros: number; + currency: string; + reportingCurrency: string; + mrrByCurrency: KitMrrCurrencyEntry[]; + excludedMrrByCurrency: KitMrrCurrencyEntry[]; +}; + +export type KitRevenueMetricsResponse = { + days: Array<{ + day: string; + currency: string; + productId: string; + platform: KitProductPlatform; + activeSubs: number; + newSubs: number; + renewals: number; + cancellations: number; + refunds: number; + revenueMicros: number; + }>; + currencies: string[]; + productIds: string[]; + platforms: KitProductPlatform[]; + truncated: boolean; +}; + +export type KitProductUpsertResponse = { id: string; created: boolean }; +export type KitProductStateResponse = { + id: string; + state: KitProduct["state"]; +}; +export type KitClientPayloadStateResponse = { + expectedVersion: number; + clientPayload?: KitProductClientPayload; +}; +export type KitSetClientPayloadResponse = { + id: string; + created: boolean; + changed: boolean; + version: number; + updatedAt: number; +}; +export type KitRemoveClientPayloadResponse = { ok: boolean }; +export type KitProductSyncResponse = { jobId: string; deduped: boolean }; +export type KitProductSyncJobResponse = { + _id: string; + _creationTime: number; + projectId: string; + platform: KitProductPlatform; + direction: "pull" | "push" | "both" | "purge-local"; + dryRun: boolean; + status: "queued" | "running" | "succeeded" | "failed"; + progress: { + phase: string; + current?: number; + total?: number; + failuresCount?: number; + }; + result?: { + pulled: number; + pushed: number; + deleted?: number; + failures: Array<{ productId: string; reason: string }>; + failuresTruncated?: boolean; + plannedWrites?: Array<{ + productId: string; + step: string; + detail?: string; + }>; + plannedWritesTruncated?: boolean; + manualActions?: Array<{ + productId: string; + code: string; + message: string; + }>; + manualActionsTruncated?: boolean; + }; + error?: string; + cancelRequested?: boolean; + expectedDeadline?: number; + createdBy?: string; + startedAt?: number; + completedAt?: number; + createdAt: number; +}; + export type KitClientPayloadResponse = { clientPayload: KitProductClientPayload; }; @@ -136,7 +254,7 @@ const DEFAULT_BASE_URL = "https://kit.openiap.dev"; function mergeHeaders( callerHeaders: Record | undefined, hasBody: boolean, -): HeadersInit { +): RequestInit["headers"] { if (typeof Headers === "function") { const merged = new Headers(callerHeaders); if (!merged.has("accept")) merged.set("accept", "application/json"); diff --git a/packages/gql/src/kit-api.ts b/packages/gql/src/kit-api.ts index f5a78d7af..928c71f7d 100644 --- a/packages/gql/src/kit-api.ts +++ b/packages/gql/src/kit-api.ts @@ -31,7 +31,15 @@ export type KitSubscription = { id: string; productId: string; platform: "IOS" | "Android"; - state: string; + state: + | "Active" + | "InGracePeriod" + | "InBillingRetry" + | "Expired" + | "Revoked" + | "Refunded" + | "Paused" + | "Unknown"; expiresAt?: number; renewsAt?: number; willRenew?: boolean; @@ -41,6 +49,7 @@ export type KitSubscription = { startedAt: number; updatedAt: number; purchaseToken: string; + originalTransactionId?: string; userId?: string; }; @@ -85,6 +94,13 @@ export type KitProduct = { type: "Subscription" | "NonConsumable" | "Consumable"; title: string; description?: string; + baseLocale?: string; + localizations?: Array<{ + locale: string; + title: string; + description?: string; + }>; + regions?: "all" | string[]; priceAmountMicros?: number; currency?: string; state: "Draft" | "Ready" | "Active" | "Removed"; @@ -114,6 +130,108 @@ export type KitProductsResponse = { nextCursor?: string; }; +export type KitSubscriptionsResponse = { + items: KitSubscription[]; + total: number; +}; + +export type KitMrrCurrencyEntry = { + currency: string; + mrrMicros: number; +}; + +export type KitMetricsResponse = { + activeSubs: number; + inGracePeriod: number; + inBillingRetry: number; + refunded30d: number; + canceled30d: number; + mrrMicros: number; + currency: string; + reportingCurrency: string; + mrrByCurrency: KitMrrCurrencyEntry[]; + excludedMrrByCurrency: KitMrrCurrencyEntry[]; +}; + +export type KitRevenueMetricsResponse = { + days: Array<{ + day: string; + currency: string; + productId: string; + platform: KitProductPlatform; + activeSubs: number; + newSubs: number; + renewals: number; + cancellations: number; + refunds: number; + revenueMicros: number; + }>; + currencies: string[]; + productIds: string[]; + platforms: KitProductPlatform[]; + truncated: boolean; +}; + +export type KitProductUpsertResponse = { id: string; created: boolean }; +export type KitProductStateResponse = { + id: string; + state: KitProduct["state"]; +}; +export type KitClientPayloadStateResponse = { + expectedVersion: number; + clientPayload?: KitProductClientPayload; +}; +export type KitSetClientPayloadResponse = { + id: string; + created: boolean; + changed: boolean; + version: number; + updatedAt: number; +}; +export type KitRemoveClientPayloadResponse = { ok: boolean }; +export type KitProductSyncResponse = { jobId: string; deduped: boolean }; +export type KitProductSyncJobResponse = { + _id: string; + _creationTime: number; + projectId: string; + platform: KitProductPlatform; + direction: "pull" | "push" | "both" | "purge-local"; + dryRun: boolean; + status: "queued" | "running" | "succeeded" | "failed"; + progress: { + phase: string; + current?: number; + total?: number; + failuresCount?: number; + }; + result?: { + pulled: number; + pushed: number; + deleted?: number; + failures: Array<{ productId: string; reason: string }>; + failuresTruncated?: boolean; + plannedWrites?: Array<{ + productId: string; + step: string; + detail?: string; + }>; + plannedWritesTruncated?: boolean; + manualActions?: Array<{ + productId: string; + code: string; + message: string; + }>; + manualActionsTruncated?: boolean; + }; + error?: string; + cancelRequested?: boolean; + expectedDeadline?: number; + createdBy?: string; + startedAt?: number; + completedAt?: number; + createdAt: number; +}; + export type KitClientPayloadResponse = { clientPayload: KitProductClientPayload; }; @@ -136,7 +254,7 @@ const DEFAULT_BASE_URL = "https://kit.openiap.dev"; function mergeHeaders( callerHeaders: Record | undefined, hasBody: boolean, -): HeadersInit { +): RequestInit["headers"] { if (typeof Headers === "function") { const merged = new Headers(callerHeaders); if (!merged.has("accept")) merged.set("accept", "application/json"); diff --git a/packages/kit/fly.toml b/packages/kit/fly.toml index d9164e3db..3f3caf165 100644 --- a/packages/kit/fly.toml +++ b/packages/kit/fly.toml @@ -6,6 +6,7 @@ primary_region = 'iad' [env] IAPKIT_BASE_URL = 'http://127.0.0.1:3000' + IAPKIT_PUBLIC_BASE_URL = 'https://kit.openiap.dev' [http_service] internal_port = 3000 diff --git a/packages/kit/server/api/v1/mcp-contract.test.ts b/packages/kit/server/api/v1/mcp-contract.test.ts index 1c9f769e8..6b1a865c4 100644 --- a/packages/kit/server/api/v1/mcp-contract.test.ts +++ b/packages/kit/server/api/v1/mcp-contract.test.ts @@ -13,6 +13,10 @@ import type { KitProductsResponse as SdkProductsResponse } from "../../../../gql import { kitClient } from "../../../../mcp-server/src/kit-client"; import type { api } from "../../../convex/_generated/api"; import type { HealthPayload } from "../../health"; +import type { + SubscriptionEntitlementsResponse, + SubscriptionStatusResponse, +} from "./subscriptions"; const mocks = vi.hoisted(() => ({ action: vi.fn(), @@ -33,11 +37,28 @@ const { apiRoutes } = await import("./routes"); type McpClient = ReturnType; type McpProductsResponse = Awaited>; +type McpStatusResponse = Awaited>; +type McpEntitlementsResponse = Awaited>; type McpSubscriptionsResponse = Awaited< ReturnType >; type McpMetricsResponse = Awaited>; +type McpRevenueResponse = Awaited>; +type McpUpsertProductResponse = Awaited>; +type McpProductStateResponse = Awaited< + ReturnType +>; +type McpClientPayloadStateResponse = Awaited< + ReturnType +>; +type McpSetClientPayloadResponse = Awaited< + ReturnType +>; +type McpRemoveClientPayloadResponse = Awaited< + ReturnType +>; type McpSyncResponse = Awaited>; +type McpSyncJobResponse = Awaited>; type McpHealthResponse = Awaited>; type ServerProductsResponse = FunctionReturnType< @@ -49,13 +70,51 @@ type ServerSubscriptionsResponse = FunctionReturnType< type ServerMetricsResponse = FunctionReturnType< typeof api.subscriptions.query.metricsSummary >; +type ServerRevenueResponse = FunctionReturnType< + typeof api.subscriptions.query.getRevenueMetrics +>; +type ServerUpsertProductResponse = FunctionReturnType< + typeof api.products.mutation.upsertProduct +>; +type ServerProductStateResponse = FunctionReturnType< + typeof api.products.mutation.setProductState +>; +type ServerClientPayloadStateResponse = NonNullable< + FunctionReturnType< + typeof api.products.query.getProductClientPayloadEditorStateWithApiKey + > +>; +type ServerSetClientPayloadResponse = FunctionReturnType< + typeof api.products.mutation.upsertProductClientPayloadWithApiKey +>; +type ServerRemoveClientPayloadResponse = FunctionReturnType< + typeof api.products.mutation.removeProductClientPayloadWithApiKey +>; type ServerSyncResponse = FunctionReturnType< typeof api.products.jobs.enqueueProductSync >; +type ServerSyncJobResponse = NonNullable< + FunctionReturnType +>; -type OptionalKeys = { - [Key in keyof T]-?: object extends Pick ? Key : never; -}[keyof T]; +type NormalizeContract = T extends { __tableName: string } + ? string + : T extends readonly (infer Item)[] + ? NormalizeContract[] + : T extends { + format: string; + body: string; + version: number; + updatedAt: number; + } + ? { + [Key in keyof T]: Key extends "format" + ? string + : NormalizeContract; + } + : T extends object + ? { [Key in keyof T]: NormalizeContract } + : T; describe("MCP Kit response contracts", () => { beforeEach(() => { @@ -79,34 +138,46 @@ describe("MCP Kit response contracts", () => { vi.unstubAllGlobals(); }); - it("keeps product pagination envelopes aligned", () => { - expectTypeOf().toEqualTypeOf< - keyof ServerProductsResponse + it("keeps app-readable response types aligned", () => { + expectTypeOf().toEqualTypeOf< + NormalizeContract >(); - expectTypeOf().toEqualTypeOf< - keyof SdkProductsResponse + expectTypeOf().toEqualTypeOf< + NormalizeContract >(); - expectTypeOf>().toEqualTypeOf< - OptionalKeys - >(); - expectTypeOf>().toEqualTypeOf< - OptionalKeys + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + NormalizeContract >(); }); - it("keeps administrative response fields and optionality aligned", () => { + it("keeps administrative read response types aligned", () => { expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf< - keyof ServerSubscriptionsResponse + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + }); + + it("keeps administrative write response types aligned", () => { + expectTypeOf().toEqualTypeOf< + NormalizeContract >(); - expectTypeOf>().toEqualTypeOf< - OptionalKeys + expectTypeOf().toEqualTypeOf< + NormalizeContract >(); - expectTypeOf().toEqualTypeOf< - keyof ServerSyncResponse + expectTypeOf().toEqualTypeOf< + NormalizeContract >(); - expectTypeOf>().toEqualTypeOf< - OptionalKeys + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + NormalizeContract >(); }); diff --git a/packages/kit/server/api/v1/rate-limit.test.ts b/packages/kit/server/api/v1/rate-limit.test.ts index 9c4ebb34d..80e9c4b90 100644 --- a/packages/kit/server/api/v1/rate-limit.test.ts +++ b/packages/kit/server/api/v1/rate-limit.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { Hono } from "hono"; +import { + IAPKIT_MCP_LOOPBACK_HEADER, + kitClient, +} from "@hyodotdev/openiap-mcp-server/kit-client"; + import { apiKeyMiddleware } from "./middleware"; +import { mcpRateLimitResponse } from "../../mcp"; import { getRequestIp, hashApiKey, @@ -13,6 +19,10 @@ import { type Bucket, } from "./rate-limit"; +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe("getRequestIp", () => { test("trusts Fly's ingress header over caller-controlled forwarding headers", async () => { const app = new Hono(); @@ -435,6 +445,98 @@ describe("multiAxisRateLimitMiddleware", () => { } expect(keyStore.size).toBe(3); }); + + test("skips only the IP axis for trusted MCP loopback calls", async () => { + const trusted = buildMultiAxisApp({ + keyCapacity: 10, + ipCapacity: 1, + globalCapacity: 10, + }); + const request = (app: Hono, key: string, remoteAddress: string) => + app.request( + "/public", + { + headers: { + authorization: `Bearer ${key}`, + "x-test-ip": "shared-ip", + [IAPKIT_MCP_LOOPBACK_HEADER]: "1", + }, + }, + { requestIP: () => ({ address: remoteAddress }) }, + ); + + expect((await request(trusted.app, "key-a", "127.0.0.1")).status).toBe(200); + expect((await request(trusted.app, "key-b", "127.0.0.1")).status).toBe(200); + expect(trusted.ipStore.size).toBe(0); + expect(trusted.keyStore.size).toBe(2); + expect(trusted.globalStore.size).toBe(1); + + const spoofed = buildMultiAxisApp({ + keyCapacity: 10, + ipCapacity: 1, + globalCapacity: 10, + }); + expect((await request(spoofed.app, "key-a", "203.0.113.9")).status).toBe( + 200, + ); + const denied = await request(spoofed.app, "key-b", "203.0.113.9"); + expect(denied.status).toBe(429); + expect(denied.headers.get("x-ratelimit-scope")).toBe("ip"); + }); + + test("composes loopback kitClient calls without a shared inner IP bucket", async () => { + const keyStore = new Map(); + const ipStore = new Map(); + const globalStore = new Map(); + const app = new Hono(); + app.get( + "/v1/products", + apiKeyMiddleware, + multiAxisRateLimitMiddleware({ + now: () => 1_000, + getIp: () => "unknown", + key: { + capacity: 10, + refillPerSecond: 1, + maxStoreSize: 10, + store: keyStore, + }, + ip: { + capacity: 1, + refillPerSecond: 1, + maxStoreSize: 10, + store: ipStore, + }, + global: { + capacity: 10, + refillPerSecond: 1, + maxStoreSize: 1, + store: globalStore, + }, + }), + (c) => c.json({ products: [], hasMore: false }), + ); + vi.stubGlobal( + "fetch", + (input: string | URL | Request, init?: RequestInit) => + app.request(new Request(input, init), undefined, { + requestIP: () => ({ address: "127.0.0.1" }), + }), + ); + + await kitClient({ + apiKey: "openiap-kit_sk_a", + baseUrl: "http://127.0.0.1:3000", + }).listProducts(); + await kitClient({ + apiKey: "openiap-kit_sk_b", + baseUrl: "http://127.0.0.1:3000", + }).listProducts(); + + expect(keyStore.size).toBe(2); + expect(ipStore.size).toBe(0); + expect(globalStore.size).toBe(1); + }); }); describe("sourceRateLimitMiddleware", () => { @@ -518,4 +620,49 @@ describe("sourceRateLimitMiddleware", () => { expect(limited.status).toBe(429); expect(limited.headers.get("X-RateLimit-Scope")).toBe("global"); }); + + test("adds MCP CORS headers to rate-limit responses", async () => { + const app = new Hono(); + app.post( + "/mcp", + sourceRateLimitMiddleware({ + now: () => 1_000, + getIp: () => "203.0.113.1", + ip: { + capacity: 1, + refillPerSecond: 1, + maxStoreSize: 10, + store: new Map(), + }, + global: { + capacity: 10, + refillPerSecond: 1, + maxStoreSize: 1, + store: new Map(), + }, + respond: mcpRateLimitResponse, + }), + (c) => c.json({ ok: true }), + ); + const request = () => + app.request("/mcp", { + method: "POST", + headers: { origin: "https://chatgpt.com" }, + }); + + expect((await request()).status).toBe(200); + const limited = await request(); + expect(limited.status).toBe(429); + expect(limited.headers.get("access-control-allow-origin")).toBe( + "https://chatgpt.com", + ); + expect(limited.headers.get("retry-after")).toBe("1"); + expect(limited.headers.get("access-control-expose-headers")).toContain( + "retry-after", + ); + await expect(limited.json()).resolves.toMatchObject({ + jsonrpc: "2.0", + error: { code: -32000 }, + }); + }); }); diff --git a/packages/kit/server/api/v1/rate-limit.ts b/packages/kit/server/api/v1/rate-limit.ts index 03972b058..e1aaf496e 100644 --- a/packages/kit/server/api/v1/rate-limit.ts +++ b/packages/kit/server/api/v1/rate-limit.ts @@ -2,6 +2,8 @@ import { createMiddleware } from "hono/factory"; import type { Context } from "hono"; import * as crypto from "node:crypto"; +import { IAPKIT_MCP_LOOPBACK_HEADER } from "@hyodotdev/openiap-mcp-server/kit-client"; + import { parsePositiveNumber } from "../../utils/env"; // Per-machine, in-memory token bucket protecting /api/v1/* from abuse @@ -453,49 +455,84 @@ export function multiAxisRateLimitMiddleware( const nowMs = clock(); c.set("apiKeyHash", apiKeyHash); - const axes = [ - resolveAxis( - "key", - apiKeyHash, - config.key, - { - capacity: DEFAULT_CAPACITY, - refillPerSecond: DEFAULT_REFILL_PER_SEC, - maxStoreSize: DEFAULT_MAX_STORE_SIZE, - store: sharedStore, - }, - c, - ), - resolveAxis( - "ip", - hashApiKey(`ip:${ip}`), - config.ip, - { - capacity: DEFAULT_IP_CAPACITY, - refillPerSecond: DEFAULT_IP_REFILL_PER_SEC, - maxStoreSize: DEFAULT_IP_MAX_STORE_SIZE, - store: sharedIpStore, - }, - c, - ), - resolveAxis( - "global", - "process", - config.global, - { - capacity: DEFAULT_GLOBAL_CAPACITY, - refillPerSecond: DEFAULT_GLOBAL_REFILL_PER_SEC, - maxStoreSize: 1, - store: sharedGlobalStore, - }, - c, - ), - ] satisfies AxisRuntime[]; + const keyAxis = resolveAxis( + "key", + apiKeyHash, + config.key, + { + capacity: DEFAULT_CAPACITY, + refillPerSecond: DEFAULT_REFILL_PER_SEC, + maxStoreSize: DEFAULT_MAX_STORE_SIZE, + store: sharedStore, + }, + c, + ); + const ipAxis = resolveAxis( + "ip", + hashApiKey(`ip:${ip}`), + config.ip, + { + capacity: DEFAULT_IP_CAPACITY, + refillPerSecond: DEFAULT_IP_REFILL_PER_SEC, + maxStoreSize: DEFAULT_IP_MAX_STORE_SIZE, + store: sharedIpStore, + }, + c, + ); + const globalAxis = resolveAxis( + "global", + "process", + config.global, + { + capacity: DEFAULT_GLOBAL_CAPACITY, + refillPerSecond: DEFAULT_GLOBAL_REFILL_PER_SEC, + maxStoreSize: 1, + store: sharedGlobalStore, + }, + c, + ); + const axes = isTrustedMcpLoopback(c) + ? [keyAxis, globalAxis] + : [keyAxis, ipAxis, globalAxis]; return applyRateLimitAxes(c, next, axes, nowMs, "key"); }); } +function isLoopbackAddress(address: string | undefined): boolean { + const normalized = address?.replace(/^\[(.*)\]$/, "$1"); + return ( + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "::ffff:127.0.0.1" + ); +} + +function isTrustedMcpLoopback(c: Context): boolean { + if (c.req.header(IAPKIT_MCP_LOOPBACK_HEADER) !== "1") return false; + const environment: unknown = c.env; + const server = + typeof environment === "object" && + environment !== null && + "server" in environment + ? (environment as { server?: unknown }).server + : environment; + if ( + typeof server !== "object" || + server === null || + !("requestIP" in server) || + typeof server.requestIP !== "function" + ) { + return false; + } + try { + const info = server.requestIP(c.req.raw) as { address?: string } | null; + return isLoopbackAddress(info?.address); + } catch { + return false; + } +} + /** Protects unauthenticated transport surfaces by source IP and process. */ export function sourceRateLimitMiddleware( config: SourceRateLimitConfig = {}, diff --git a/packages/kit/server/api/v1/subscriptions.ts b/packages/kit/server/api/v1/subscriptions.ts index d51712a45..a284013c0 100644 --- a/packages/kit/server/api/v1/subscriptions.ts +++ b/packages/kit/server/api/v1/subscriptions.ts @@ -538,6 +538,13 @@ function evaluateEntitlements( }; } +export type SubscriptionStatusResponse = ReturnType< + typeof evaluateSubscriptionStatus +>; +export type SubscriptionEntitlementsResponse = ReturnType< + typeof evaluateEntitlements +>; + function selectMostRecentlyUpdatedSnapshot( subscriptions: readonly SubscriptionSnapshotRow[], ): SubscriptionSnapshotRow | null { diff --git a/packages/kit/server/mcp.test.ts b/packages/kit/server/mcp.test.ts index aed929754..214723c0d 100644 --- a/packages/kit/server/mcp.test.ts +++ b/packages/kit/server/mcp.test.ts @@ -75,8 +75,7 @@ describe("IAPKit MCP route handler", () => { await expect(response.json()).resolves.toMatchObject({ error: { code: -32003, - message: - "This operation requires a secret admin key. Publishable mobile keys cannot access MCP administrative operations.", + message: expect.stringContaining("openiap-kit_sk_"), }, }); }); diff --git a/packages/kit/server/mcp.ts b/packages/kit/server/mcp.ts index b4e433ede..d7e454803 100644 --- a/packages/kit/server/mcp.ts +++ b/packages/kit/server/mcp.ts @@ -1,4 +1,31 @@ -import { createIapKitWebMcpHandler } from "@hyodotdev/openiap-mcp-server/web"; +import { + createIapKitWebMcpHandler, + withIapKitMcpCors, +} from "@hyodotdev/openiap-mcp-server/web"; +import type { Context } from "hono"; + +import type { ConsumeResult } from "./api/v1/rate-limit"; /** Handles MCP HTTP requests for the Kit-hosted IAPKit MCP endpoint. */ export const handleIapKitMcpRequest = createIapKitWebMcpHandler(); + +/** Preserves MCP JSON-RPC and CORS semantics for admission failures. */ +export function mcpRateLimitResponse( + c: Context, + result: ConsumeResult, +): Response { + return withIapKitMcpCors( + c.req.raw, + c.json( + { + jsonrpc: "2.0", + error: { + code: -32000, + message: `Too many requests. Retry after ${result.retryAfterSec}s.`, + }, + id: null, + }, + 429, + ), + ); +} diff --git a/packages/kit/server/server.ts b/packages/kit/server/server.ts index 8a191447d..626cd9aab 100644 --- a/packages/kit/server/server.ts +++ b/packages/kit/server/server.ts @@ -6,12 +6,9 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { apiRoutes } from "./api/v1/routes"; -import { - sourceRateLimitMiddleware, - type ConsumeResult, -} from "./api/v1/rate-limit"; +import { sourceRateLimitMiddleware } from "./api/v1/rate-limit"; import { handleHealthRequest } from "./health"; -import { handleIapKitMcpRequest } from "./mcp"; +import { handleIapKitMcpRequest, mcpRateLimitResponse } from "./mcp"; import { shouldReturnNotFoundForMissingStaticPath } from "./staticPaths"; import { parsePort } from "./utils/env"; @@ -26,20 +23,6 @@ function mcpRequestCost(c: Context): number { return c.req.method === "OPTIONS" ? 0 : 1; } -function mcpRateLimitResponse(c: Context, result: ConsumeResult): Response { - return c.json( - { - jsonrpc: "2.0", - error: { - code: -32000, - message: `Too many requests. Retry after ${result.retryAfterSec}s.`, - }, - id: null, - }, - 429, - ); -} - // Liveness/readiness probe. No DB hit — Fly.io health checks fire // frequently and hitting Convex from here would both mask real // outages (a slow backend should still report "proc is alive") and diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 7669b215a..e76a86cc1 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -33,6 +33,9 @@ IAPKIT_API_KEY="openiap-kit_sk_" bun run start:http `PORT` / `IAPKIT_MCP_PORT` override the HTTP port and `IAPKIT_MCP_ALLOWED_ORIGINS` overrides the CORS allow-list. +`IAPKIT_BASE_URL` selects the Kit API endpoint, while +`IAPKIT_PUBLIC_BASE_URL` keeps generated webhook URLs public when API calls use +a loopback address. ## Client configuration diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 6b7f1b575..b8fe41a53 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -12,6 +12,7 @@ "exports": { ".": "./src/index.ts", "./http": "./src/http.ts", + "./kit-client": "./src/kit-client.ts", "./mcp": "./src/mcp.ts", "./web": "./src/web.ts" }, @@ -24,6 +25,7 @@ "start:http": "bun run src/http.ts" }, "dependencies": { + "@hyodotdev/openiap-gql": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^3.23.8" }, diff --git a/packages/mcp-server/src/auth.ts b/packages/mcp-server/src/auth.ts index 78e883d73..8544a005b 100644 --- a/packages/mcp-server/src/auth.ts +++ b/packages/mcp-server/src/auth.ts @@ -1,12 +1,7 @@ export const INSUFFICIENT_API_KEY_SCOPE_MESSAGE = - "This operation requires a secret admin key. Publishable mobile keys cannot access MCP administrative operations."; + "This operation requires an IAPKit secret admin key (openiap-kit_sk_...). Publishable and legacy keys cannot access MCP administrative operations."; -/** - * A publishable prefix is safe to use as an early-deny signal. Successful - * authorization still belongs to IAPKit's persisted key lookup, so an - * arbitrary secret-looking or legacy token never gains access from its - * prefix alone. - */ -export function isPublishableApiKey(apiKey: string | null | undefined) { - return apiKey?.startsWith("openiap-kit_pk_") === true; +/** MCP administration accepts only explicitly typed secret keys. */ +export function isSecretApiKey(apiKey: string | null | undefined): boolean { + return apiKey?.startsWith("openiap-kit_sk_") === true; } diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index a436b1fd1..de7e24e80 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -12,10 +12,7 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; -import { - INSUFFICIENT_API_KEY_SCOPE_MESSAGE, - isPublishableApiKey, -} from "./auth.js"; +import { INSUFFICIENT_API_KEY_SCOPE_MESSAGE, isSecretApiKey } from "./auth.js"; import { createIapKitMcpServer, IAPKIT_MCP_SERVER_NAME, @@ -28,6 +25,8 @@ import { } from "./session-routing.js"; import { createMcpSessionStore, + MCP_SESSION_CAPACITY_MESSAGE, + MCP_SESSION_CAPACITY_RETRY_AFTER_SECONDS, type BoundedSessionStore, } from "./session-store.js"; @@ -139,7 +138,7 @@ export function createRemoteMcpHttpServer( const bearerToken = parseBearerToken( headerString(req.headers.authorization), ); - if (isPublishableApiKey(bearerToken)) { + if (bearerToken && !isSecretApiKey(bearerToken)) { writeJsonRpcError(res, 403, -32003, INSUFFICIENT_API_KEY_SCOPE_MESSAGE); return; } @@ -265,11 +264,23 @@ async function handleMcpPost( return; } + const reservation = transports.reserve(); + if (!reservation) { + res.setHeader( + "Retry-After", + String(MCP_SESSION_CAPACITY_RETRY_AFTER_SECONDS), + ); + writeJsonRpcError(res, 503, -32000, MCP_SESSION_CAPACITY_MESSAGE); + return; + } + + let sessionStored = false; let transport!: StreamableHTTPServerTransport; transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => buildSessionId(machineId, randomUUID()), onsessioninitialized: (initializedSessionId) => { - transports.set(initializedSessionId, transport); + reservation.commit(initializedSessionId, transport); + sessionStored = true; logger.info(`IAPKit MCP session initialized: ${initializedSessionId}`); }, }); @@ -283,8 +294,19 @@ async function handleMcpPost( }; const mcpServer = createIapKitMcpServer(); - await mcpServer.connect(transport); - await transport.handleRequest(req, res, body); + try { + await mcpServer.connect(transport); + await transport.handleRequest(req, res, body); + } finally { + if (!sessionStored) { + reservation.release(); + await transport + .close() + .catch((error: unknown) => + logger.error("IAPKit MCP session cleanup failed:", error), + ); + } + } } async function handleExistingMcpSession( @@ -411,7 +433,10 @@ function setCorsHeaders( "authorization, content-type, last-event-id, mcp-protocol-version, mcp-session-id", ); res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); - res.setHeader("Access-Control-Expose-Headers", "mcp-session-id"); + res.setHeader( + "Access-Control-Expose-Headers", + "mcp-session-id, retry-after, x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-scope", + ); } function parseAllowedOrigins(raw: string | undefined): string[] { diff --git a/packages/mcp-server/src/kit-client.ts b/packages/mcp-server/src/kit-client.ts index 17e2f4fba..20b01397f 100644 --- a/packages/mcp-server/src/kit-client.ts +++ b/packages/mcp-server/src/kit-client.ts @@ -3,6 +3,23 @@ // failure mode (kit unreachable, bad apiKey, validation errors) is the // same shape across every tool. +import type { + EntitlementsResponse, + KitClientPayloadStateResponse, + KitMetricsResponse as SharedKitMetricsResponse, + KitMrrCurrencyEntry as SharedKitMrrCurrencyEntry, + KitProductsResponse as SharedKitProductsResponse, + KitProductStateResponse, + KitProductSyncJobResponse, + KitProductSyncResponse, + KitProductUpsertResponse, + KitRemoveClientPayloadResponse, + KitRevenueMetricsResponse, + KitSetClientPayloadResponse, + KitSubscriptionsResponse, + StatusResponse, +} from "@hyodotdev/openiap-gql/kit-api"; + export type KitClientOptions = { baseUrl?: string; apiKey: string; @@ -14,29 +31,9 @@ export interface KitProductListParams { cursor?: string; } -export interface KitProductsResponse { - products: unknown[]; - hasMore: boolean; - nextCursor?: string; -} - -export interface KitMrrCurrencyEntry { - currency: string; - mrrMicros: number; -} - -export interface KitMetricsResponse { - activeSubs: number; - inGracePeriod: number; - inBillingRetry: number; - refunded30d: number; - canceled30d: number; - mrrMicros: number; - currency: string; - reportingCurrency: string; - mrrByCurrency: KitMrrCurrencyEntry[]; - excludedMrrByCurrency: KitMrrCurrencyEntry[]; -} +export type KitProductsResponse = SharedKitProductsResponse; +export type KitMetricsResponse = SharedKitMetricsResponse; +export type KitMrrCurrencyEntry = SharedKitMrrCurrencyEntry; export interface KitHealthResponse { ok: true; @@ -49,6 +46,7 @@ export interface KitHealthResponse { } const DEFAULT_BASE_URL = "https://kit.openiap.dev"; +export const IAPKIT_MCP_LOOPBACK_HEADER = "x-iapkit-mcp-loopback"; export function normalizeKitBaseUrl(baseUrl?: string): string { let url: URL; @@ -95,6 +93,12 @@ export class KitHttpError extends Error { export function kitClient({ baseUrl, apiKey }: KitClientOptions) { const root = normalizeKitBaseUrl(baseUrl); + const hostname = new URL(root).hostname; + const loopback = + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "[::1]" || + hostname === "::1"; async function call(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${root}${path}`, { @@ -102,6 +106,7 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { headers: { "content-type": "application/json", accept: "application/json", + ...(loopback ? { [IAPKIT_MCP_LOOPBACK_HEADER]: "1" } : {}), ...(init.headers as Record | undefined), }, }); @@ -140,15 +145,13 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { apiKey, baseUrl: root, status: (userId: string) => - adminCall<{ active: boolean; subscription: unknown }>( + adminCall( `/v1/subscriptions/status?userId=${encodeURIComponent(userId)}`, ), entitlements: (userId: string) => - adminCall<{ - userId: string; - productIds: string[]; - subscriptions: unknown[]; - }>(`/v1/subscriptions/entitlements?userId=${encodeURIComponent(userId)}`), + adminCall( + `/v1/subscriptions/entitlements?userId=${encodeURIComponent(userId)}`, + ), listSubscriptions: (params: { state?: string; productId?: string; @@ -161,7 +164,7 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { if (params.userId) usp.set("userId", params.userId); if (params.limit) usp.set("limit", String(params.limit)); const qs = usp.toString(); - return adminCall<{ items: unknown[]; total: number }>( + return adminCall( `/v1/subscriptions/list${qs ? `?${qs}` : ""}`, ); }, @@ -171,24 +174,9 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { fromDay: params.fromDay, toDay: params.toDay, }); - return adminCall<{ - days: Array<{ - day: string; - currency: string; - productId: string; - platform: "IOS" | "Android"; - activeSubs: number; - newSubs: number; - renewals: number; - cancellations: number; - refunds: number; - revenueMicros: number; - }>; - currencies: string[]; - productIds: string[]; - platforms: Array<"IOS" | "Android">; - truncated: boolean; - }>(`/v1/subscriptions/revenue?${usp.toString()}`); + return adminCall( + `/v1/subscriptions/revenue?${usp.toString()}`, + ); }, listProducts: (params: KitProductListParams = {}) => { const usp = new URLSearchParams(); @@ -218,7 +206,7 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { subscriptionGroupName?: string; reviewNote?: string; }) => - adminCall<{ id: string; created: boolean }>("/v1/products", { + adminCall("/v1/products", { method: "POST", body: JSON.stringify(product), }), @@ -227,7 +215,7 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { platform: "IOS" | "Android"; state: "Draft" | "Ready" | "Active" | "Removed"; }) => - adminCall<{ id: string; state: string }>("/v1/products/state", { + adminCall("/v1/products/state", { method: "POST", body: JSON.stringify(params), }), @@ -235,16 +223,7 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { productId: string; platform: "IOS" | "Android"; }) => - adminCall<{ - expectedVersion: number; - clientPayload?: { - // Opaque: IAPKit owns the format value space (IapkitClientPayloadFormat). - format: string; - body: string; - version: number; - updatedAt: number; - }; - }>( + adminCall( `/v1/products/client-payload/${encodeURIComponent(params.productId)}?platform=${encodeURIComponent(params.platform)}`, ), setClientPayload: (params: { @@ -254,13 +233,7 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { body: string; expectedVersion?: number; }) => - adminCall<{ - id: string; - created: boolean; - changed: boolean; - version: number; - updatedAt: number; - }>( + adminCall( `/v1/products/client-payload/${encodeURIComponent(params.productId)}?platform=${encodeURIComponent(params.platform)}`, { method: "PUT", @@ -282,7 +255,7 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { if (params.expectedVersion !== undefined) { query.set("expectedVersion", String(params.expectedVersion)); } - return adminCall<{ ok: boolean }>( + return adminCall( `/v1/products/client-payload/${encodeURIComponent(params.productId)}?${query.toString()}`, { method: "DELETE", @@ -299,13 +272,15 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { direction: params.direction, dryRun: String(params.dryRun), }); - return adminCall<{ jobId: string; deduped: boolean }>( + return adminCall( `/v1/products/sync/${platformPath}?${usp.toString()}`, { method: "POST" }, ); }, syncJob: (jobId: string) => - adminCall(`/v1/products/sync/jobs/${encodeURIComponent(jobId)}`), + adminCall( + `/v1/products/sync/jobs/${encodeURIComponent(jobId)}`, + ), health: () => call("/health"), }; } diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts index 32ba704ea..a1fab866f 100644 --- a/packages/mcp-server/src/mcp.ts +++ b/packages/mcp-server/src/mcp.ts @@ -7,10 +7,7 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; -import { - INSUFFICIENT_API_KEY_SCOPE_MESSAGE, - isPublishableApiKey, -} from "./auth.js"; +import { INSUFFICIENT_API_KEY_SCOPE_MESSAGE, isSecretApiKey } from "./auth.js"; import { kitClient, KitHttpError, normalizeKitBaseUrl } from "./kit-client.js"; // MCP server for IAPKit. Every tool funnels through `withClient` @@ -124,7 +121,7 @@ function withClient( if (validationError) { throw new Error(validationError); } - if (isPublishableApiKey(apiKey)) { + if (!isSecretApiKey(apiKey)) { throw new Error(INSUFFICIENT_API_KEY_SCOPE_MESSAGE); } return kitClient({ @@ -195,6 +192,7 @@ function redactSecretString(value: string, apiKey?: string): string { for (const secret of knownSecrets) { redacted = redacted.split(secret).join(API_KEY_PLACEHOLDER); } + // Admin credentials are bearer-only; known webhook path keys are replaced above. return redacted.replace( /(Authorization:\s*Bearer\s+)[^\s"]+/gi, `$1${API_KEY_PLACEHOLDER}`, @@ -630,6 +628,9 @@ function registerIapKitTools(server: McpServer) { async (args, extra) => { try { const client = withClient(args, extra); + const publicBaseUrl = normalizeKitBaseUrl( + args.baseUrl ?? process.env.IAPKIT_PUBLIC_BASE_URL, + ); const [metrics, products] = await Promise.all([ client .metrics() @@ -642,7 +643,7 @@ function registerIapKitTools(server: McpServer) { metrics, products, webhookUrls: { - lifecycle: `${client.baseUrl}/v1/webhooks/${PUBLISHABLE_KEY_PLACEHOLDER}`, + lifecycle: `${publicBaseUrl}/v1/webhooks/${PUBLISHABLE_KEY_PLACEHOLDER}`, }, note: "Use webhookUrls.lifecycle for inbound Apple ASN v2 and Google Pub/Sub RTDN delivery. IAPKit does not expose an outbound webhook stream.", }); diff --git a/packages/mcp-server/src/session-store.ts b/packages/mcp-server/src/session-store.ts index 01f71676b..9c338ae19 100644 --- a/packages/mcp-server/src/session-store.ts +++ b/packages/mcp-server/src/session-store.ts @@ -5,6 +5,9 @@ interface SessionEntry { const MAX_MCP_SESSIONS = 256; const MCP_SESSION_IDLE_TTL_MS = 15 * 60 * 1000; +export const MCP_SESSION_CAPACITY_RETRY_AFTER_SECONDS = 5; +export const MCP_SESSION_CAPACITY_MESSAGE = + "MCP session capacity reached. Retry after 5s."; interface ClosableSession { close: () => void | Promise; @@ -18,9 +21,15 @@ export interface BoundedSessionStoreOptions { onDisposeError?: (error: unknown) => void; } +export interface SessionReservation { + commit: (sessionId: string, value: T) => void; + release: () => void; +} + export class BoundedSessionStore { private readonly entries = new Map>(); private readonly now: () => number; + private pendingReservations = 0; constructor(private readonly options: BoundedSessionStoreOptions) { if (!Number.isSafeInteger(options.maxSize) || options.maxSize < 1) { @@ -47,24 +56,43 @@ export class BoundedSessionStore { return entry.value; } - set(sessionId: string, value: T): void { + reserve(): SessionReservation | null { const now = this.now(); this.pruneExpired(now); - const previous = this.entries.get(sessionId); - this.entries.delete(sessionId); - if (previous && previous.value !== value) { - void this.dispose(previous.value); + if (this.entries.size + this.pendingReservations >= this.options.maxSize) { + return null; } - this.entries.set(sessionId, { value, lastAccessedAt: now }); - while (this.entries.size > this.options.maxSize) { - const oldestSessionId = this.entries.keys().next().value; - if (oldestSessionId === undefined) break; - this.evict(oldestSessionId); - } + this.pendingReservations += 1; + let pending = true; + const release = (): void => { + if (!pending) return; + pending = false; + this.pendingReservations -= 1; + }; + + return { + commit: (sessionId, value) => { + if (!pending) { + throw new Error("session reservation is no longer active"); + } + release(); + const previous = this.entries.get(sessionId); + this.entries.delete(sessionId); + if (previous && previous.value !== value) { + void this.dispose(previous.value); + } + this.entries.set(sessionId, { + value, + lastAccessedAt: this.now(), + }); + }, + release, + }; } delete(sessionId: string): boolean { + // Transport close callbacks own disposal; this only forgets the closed entry. return this.entries.delete(sessionId); } diff --git a/packages/mcp-server/src/web.ts b/packages/mcp-server/src/web.ts index 36692d11b..c446447fd 100644 --- a/packages/mcp-server/src/web.ts +++ b/packages/mcp-server/src/web.ts @@ -4,10 +4,7 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; -import { - INSUFFICIENT_API_KEY_SCOPE_MESSAGE, - isPublishableApiKey, -} from "./auth.js"; +import { INSUFFICIENT_API_KEY_SCOPE_MESSAGE, isSecretApiKey } from "./auth.js"; import { createIapKitMcpServer } from "./mcp.js"; import { buildSessionId, @@ -16,6 +13,8 @@ import { } from "./session-routing.js"; import { createMcpSessionStore, + MCP_SESSION_CAPACITY_MESSAGE, + MCP_SESSION_CAPACITY_RETRY_AFTER_SECONDS, type BoundedSessionStore, } from "./session-store.js"; @@ -58,7 +57,7 @@ export function createIapKitWebMcpHandler( ): Promise { try { if (request.method === "OPTIONS") { - return withCors( + return withIapKitMcpCors( request, new Response(null, { status: 204 }), allowedOrigins, @@ -68,8 +67,8 @@ export function createIapKitWebMcpHandler( const bearerToken = parseBearerToken( request.headers.get("authorization"), ); - if (isPublishableApiKey(bearerToken)) { - return withCors( + if (bearerToken && !isSecretApiKey(bearerToken)) { + return withIapKitMcpCors( request, jsonRpcError(403, -32003, INSUFFICIENT_API_KEY_SCOPE_MESSAGE), allowedOrigins, @@ -86,7 +85,7 @@ export function createIapKitWebMcpHandler( authInfo, machineId, ); - return withCors(request, response, allowedOrigins); + return withIapKitMcpCors(request, response, allowedOrigins); } if (request.method === "GET" || request.method === "DELETE") { @@ -96,31 +95,31 @@ export function createIapKitWebMcpHandler( authInfo, machineId, ); - return withCors(request, response, allowedOrigins); + return withIapKitMcpCors(request, response, allowedOrigins); } - return withCors( + return withIapKitMcpCors( request, jsonRpcError(405, -32000, "Method not allowed"), allowedOrigins, ); } catch (error) { if (error instanceof SyntaxError) { - return withCors( + return withIapKitMcpCors( request, jsonRpcError(400, -32700, "Parse error: Invalid JSON"), allowedOrigins, ); } if (isMcpBodyTooLargeError(error)) { - return withCors( + return withIapKitMcpCors( request, jsonRpcError(413, -32000, "Payload Too Large"), allowedOrigins, ); } logger.error("IAPKit MCP request failed:", error); - return withCors( + return withIapKitMcpCors( request, jsonRpcError(500, -32603, "Internal server error"), allowedOrigins, @@ -159,11 +158,20 @@ async function handlePost( ); } + const reservation = transports.reserve(); + if (!reservation) { + return jsonRpcError(503, -32000, MCP_SESSION_CAPACITY_MESSAGE, { + "retry-after": String(MCP_SESSION_CAPACITY_RETRY_AFTER_SECONDS), + }); + } + + let sessionStored = false; let transport!: WebStandardStreamableHTTPServerTransport; transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => buildSessionId(machineId, randomUUID()), onsessioninitialized: (initializedSessionId) => { - transports.set(initializedSessionId, transport); + reservation.commit(initializedSessionId, transport); + sessionStored = true; logger.info(`IAPKit MCP session initialized: ${initializedSessionId}`); }, onsessionclosed: (closedSessionId) => { @@ -178,11 +186,22 @@ async function handlePost( }; const server = createIapKitMcpServer(); - await server.connect(transport); - return transport.handleRequest(request, { - parsedBody: body, - authInfo, - }); + try { + await server.connect(transport); + return await transport.handleRequest(request, { + parsedBody: body, + authInfo, + }); + } finally { + if (!sessionStored) { + reservation.release(); + await transport + .close() + .catch((error: unknown) => + logger.error("IAPKit MCP session cleanup failed:", error), + ); + } + } } async function handleExistingSession( @@ -282,10 +301,12 @@ function isMcpBodyTooLargeError(error: unknown): boolean { return error instanceof Error && error.message === MCP_BODY_TOO_LARGE_ERROR; } -function withCors( +export function withIapKitMcpCors( request: Request, response: Response, - allowedOrigins: string[], + allowedOrigins: string[] = parseAllowedOrigins( + process.env.IAPKIT_MCP_ALLOWED_ORIGINS, + ), ): Response { const headers = new Headers(response.headers); const origin = request.headers.get("origin"); @@ -300,7 +321,10 @@ function withCors( "authorization, content-type, last-event-id, mcp-protocol-version, mcp-session-id", ); headers.set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); - headers.set("Access-Control-Expose-Headers", "mcp-session-id"); + headers.set( + "Access-Control-Expose-Headers", + "mcp-session-id, retry-after, x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-scope", + ); return new Response(response.body, { status: response.status, @@ -322,6 +346,7 @@ function jsonRpcError( statusCode: number, code: number, message: string, + extraHeaders: Record = {}, ): Response { return new Response( JSON.stringify({ @@ -331,7 +356,7 @@ function jsonRpcError( }), { status: statusCode, - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...extraHeaders }, }, ); } diff --git a/packages/mcp-server/test/http.test.ts b/packages/mcp-server/test/http.test.ts index fdfbb55f2..4af04a866 100644 --- a/packages/mcp-server/test/http.test.ts +++ b/packages/mcp-server/test/http.test.ts @@ -186,49 +186,70 @@ describe("remote MCP HTTP server", () => { ).toContain("nextCursor"); }); - it("returns 403 before a publishable key can initialize the admin MCP surface", async () => { - const baseUrl = await startServer(); - const response = await postMcp( - baseUrl, - { - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2025-06-18", - capabilities: {}, - clientInfo: { name: "vitest", version: "0.0.0" }, + it.each(["openiap-kit_pk_mobile", "openiap-kit_legacy"])( + "returns 403 before non-secret key %s can initialize the admin MCP surface", + async (apiKey) => { + const baseUrl = await startServer(); + const response = await postMcp( + baseUrl, + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "vitest", version: "0.0.0" }, + }, }, - }, - undefined, - { authorization: "Bearer openiap-kit_pk_mobile" }, - ); + undefined, + { authorization: `Bearer ${apiKey}` }, + ); - expect(response.status).toBe(403); - expect(response.headers.get("mcp-session-id")).toBeNull(); - await expect(response.json()).resolves.toMatchObject({ - error: { - code: -32003, - message: - "This operation requires a secret admin key. Publishable mobile keys cannot access MCP administrative operations.", - }, - }); - }); + expect(response.status).toBe(403); + expect(response.headers.get("mcp-session-id")).toBeNull(); + await expect(response.json()).resolves.toMatchObject({ + error: { + code: -32003, + message: expect.stringContaining("openiap-kit_sk_"), + }, + }); + }, + ); - it("rejects publishable keys supplied through tool arguments", async () => { - const { baseUrl, sessionId } = await initializeMcpSession(); + it.each(["openiap-kit_pk_mobile", "openiap-kit_legacy"])( + "rejects non-secret key %s supplied through tool arguments", + async (apiKey) => { + const { baseUrl, sessionId } = await initializeMcpSession(); - const payload = await callTool<{ - ok: false; - error: { message: string }; - }>(baseUrl, sessionId, "iapkit_list_products", { - apiKey: "openiap-kit_pk_mobile", - }); + const payload = await callTool<{ + ok: false; + error: { message: string }; + }>(baseUrl, sessionId, "iapkit_list_products", { + apiKey, + }); - expect(payload.ok).toBe(false); - expect(payload.error.message).toBe( - "This operation requires a secret admin key. Publishable mobile keys cannot access MCP administrative operations.", - ); + expect(payload.ok).toBe(false); + expect(payload.error.message).toContain("openiap-kit_sk_"); + }, + ); + + it("rejects a legacy key supplied through IAPKIT_API_KEY", async () => { + const previousApiKey = process.env.IAPKIT_API_KEY; + process.env.IAPKIT_API_KEY = "openiap-kit_legacy"; + try { + const { baseUrl, sessionId } = await initializeMcpSession(); + const payload = await callTool<{ + ok: false; + error: { message: string }; + }>(baseUrl, sessionId, "iapkit_list_products", {}); + + expect(payload.ok).toBe(false); + expect(payload.error.message).toContain("openiap-kit_sk_"); + } finally { + if (previousApiKey === undefined) delete process.env.IAPKIT_API_KEY; + else process.env.IAPKIT_API_KEY = previousApiKey; + } }); it("paginates the product catalog through MCP", async () => { @@ -272,8 +293,46 @@ describe("remote MCP HTTP server", () => { } }); + it("keeps public webhook URLs separate from the loopback Kit API URL", async () => { + const previousBaseUrl = process.env.IAPKIT_BASE_URL; + const previousPublicBaseUrl = process.env.IAPKIT_PUBLIC_BASE_URL; + process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + if (req.url === "/v1/subscriptions/metrics") { + res.end(JSON.stringify({ activeSubs: 0 })); + return; + } + res.end(JSON.stringify({ products: [], hasMore: false })); + }); + process.env.IAPKIT_PUBLIC_BASE_URL = "https://public.kit.example/"; + + try { + const { baseUrl, sessionId } = await initializeMcpSession( + "openiap-kit_sk_inspect", + ); + const payload = await callTool<{ + webhookUrls: { lifecycle: string }; + }>(baseUrl, sessionId, "iapkit_inspect_state", { + apiKey: "openiap-kit_sk_inspect", + }); + + expect(payload.webhookUrls.lifecycle).toBe( + "https://public.kit.example/v1/webhooks/", + ); + expect(payload.webhookUrls.lifecycle).not.toContain("127.0.0.1"); + } finally { + if (previousBaseUrl === undefined) delete process.env.IAPKIT_BASE_URL; + else process.env.IAPKIT_BASE_URL = previousBaseUrl; + if (previousPublicBaseUrl === undefined) { + delete process.env.IAPKIT_PUBLIC_BASE_URL; + } else { + process.env.IAPKIT_PUBLIC_BASE_URL = previousPublicBaseUrl; + } + } + }); + it("summarizes revenue analytics through the bearer-authenticated Kit API", async () => { - const apiKey = "openiap-kit_secret_revenue"; + const apiKey = "openiap-kit_sk_revenue"; const previousBaseUrl = process.env.IAPKIT_BASE_URL; process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => { expect(req.method).toBe("GET"); @@ -342,7 +401,7 @@ describe("remote MCP HTTP server", () => { }); it("generates Expo setup snippets compatible with current SDK types", async () => { - const apiKey = "openiap-kit_secret_setup"; + const apiKey = "openiap-kit_sk_setup"; const { baseUrl, sessionId } = await initializeMcpSession(apiKey); const expoPayload = await callTool( @@ -368,7 +427,7 @@ describe("remote MCP HTTP server", () => { }); it("generates native iOS and Android setup snippets", async () => { - const apiKey = "openiap-kit_secret_setup"; + const apiKey = "openiap-kit_sk_setup"; const { baseUrl, sessionId } = await initializeMcpSession(apiKey); const iosPayload = await callTool( @@ -430,7 +489,7 @@ describe("remote MCP HTTP server", () => { }); it("generates framework snippets without server secrets or undefined clients", async () => { - const apiKey = "openiap-kit_secret_setup"; + const apiKey = "openiap-kit_sk_setup"; const { baseUrl, sessionId } = await initializeMcpSession(apiKey); for (const framework of ["flutter", "kmp", "godot"] as const) { @@ -486,7 +545,7 @@ describe("remote MCP HTTP server", () => { }); it("enqueues store sync jobs through the bearer-authenticated Kit API", async () => { - const apiKey = "openiap-kit_secret_sync"; + const apiKey = "openiap-kit_sk_sync"; const previousBaseUrl = process.env.IAPKIT_BASE_URL; process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => { expect(req.method).toBe("POST"); @@ -849,7 +908,7 @@ describe("remote MCP HTTP server", () => { }); it("redacts bearer API keys from tool error responses", async () => { - const apiKey = "openiap-kit_secret_http"; + const apiKey = "openiap-kit_sk_http"; const previousBaseUrl = process.env.IAPKIT_BASE_URL; process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => { res.writeHead(500, { "content-type": "application/json" }); @@ -949,7 +1008,7 @@ describe("remote MCP HTTP server", () => { }); it("redacts API keys from partial diagnostic errors", async () => { - const apiKey = "openiap-kit_secret_diagnostic"; + const apiKey = "openiap-kit_sk_diagnostic"; const previousBaseUrl = process.env.IAPKIT_BASE_URL; process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => { if (req.url === "/health") { diff --git a/packages/mcp-server/test/kit-client.test.ts b/packages/mcp-server/test/kit-client.test.ts index 335473d91..c60b7c74d 100644 --- a/packages/mcp-server/test/kit-client.test.ts +++ b/packages/mcp-server/test/kit-client.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { kitClient, normalizeKitBaseUrl } from "../src/kit-client"; +import { + IAPKIT_MCP_LOOPBACK_HEADER, + kitClient, + normalizeKitBaseUrl, +} from "../src/kit-client"; afterEach(() => { vi.unstubAllGlobals(); @@ -30,6 +34,33 @@ describe("normalizeKitBaseUrl", () => { }); describe("kitClient", () => { + it("marks only loopback API calls as internal MCP traffic", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ products: [], hasMore: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await kitClient({ + apiKey: "openiap-kit_sk_local", + baseUrl: "http://127.0.0.1:3000", + }).listProducts(); + await kitClient({ + apiKey: "openiap-kit_sk_public", + baseUrl: "https://kit.example", + }).listProducts(); + + expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ + [IAPKIT_MCP_LOOPBACK_HEADER]: "1", + }); + expect(fetchMock.mock.calls[1]?.[1]?.headers).not.toHaveProperty( + IAPKIT_MCP_LOOPBACK_HEADER, + ); + }); + it("forwards subscription metadata when creating products", async () => { const fetchMock = vi.fn(async () => { return new Response(JSON.stringify({ id: "product-id", created: true }), { diff --git a/packages/mcp-server/test/session-store.test.ts b/packages/mcp-server/test/session-store.test.ts index ccfbb4cf3..db79c50f7 100644 --- a/packages/mcp-server/test/session-store.test.ts +++ b/packages/mcp-server/test/session-store.test.ts @@ -2,6 +2,16 @@ import { describe, expect, it, vi } from "vitest"; import { BoundedSessionStore } from "../src/session-store"; +function addSession( + store: BoundedSessionStore, + sessionId: string, + value: T, +): void { + const reservation = store.reserve(); + expect(reservation).not.toBeNull(); + reservation?.commit(sessionId, value); +} + describe("BoundedSessionStore", () => { it("expires idle sessions and refreshes active sessions", async () => { let now = 0; @@ -13,11 +23,11 @@ describe("BoundedSessionStore", () => { dispose, }); - store.set("active", "active-transport"); + addSession(store, "active", "active-transport"); now = 50; expect(store.get("active")).toBe("active-transport"); now = 120; - store.set("next", "next-transport"); + addSession(store, "next", "next-transport"); expect(store.get("active")).toBe("active-transport"); now = 221; @@ -27,7 +37,7 @@ describe("BoundedSessionStore", () => { ); }); - it("evicts the least recently used session at the cap", async () => { + it("rejects admission at the cap without evicting active sessions", () => { let now = 0; const dispose = vi.fn(async () => undefined); const store = new BoundedSessionStore({ @@ -37,20 +47,32 @@ describe("BoundedSessionStore", () => { dispose, }); - store.set("oldest", "oldest-transport"); + addSession(store, "oldest", "oldest-transport"); now = 1; - store.set("recent", "recent-transport"); + addSession(store, "recent", "recent-transport"); now = 2; expect(store.get("oldest")).toBe("oldest-transport"); now = 3; - store.set("new", "new-transport"); + expect(store.reserve()).toBeNull(); - expect(store.get("recent")).toBeUndefined(); + expect(store.get("recent")).toBe("recent-transport"); expect(store.get("oldest")).toBe("oldest-transport"); - expect(store.get("new")).toBe("new-transport"); - await vi.waitFor(() => - expect(dispose).toHaveBeenCalledWith("recent-transport"), - ); + expect(dispose).not.toHaveBeenCalled(); + }); + + it("counts pending reservations and releases unused capacity", () => { + const store = new BoundedSessionStore({ + maxSize: 1, + idleTtlMs: 100, + dispose: vi.fn(), + }); + + const reservation = store.reserve(); + expect(reservation).not.toBeNull(); + expect(store.reserve()).toBeNull(); + + reservation?.release(); + expect(store.reserve()).not.toBeNull(); }); it("closes every remaining session during shutdown", async () => { @@ -60,8 +82,8 @@ describe("BoundedSessionStore", () => { idleTtlMs: 100, dispose, }); - store.set("one", "transport-one"); - store.set("two", "transport-two"); + addSession(store, "one", "transport-one"); + addSession(store, "two", "transport-two"); await store.closeAll(); diff --git a/packages/mcp-server/test/web.test.ts b/packages/mcp-server/test/web.test.ts index b2b0d37a2..a71782cbd 100644 --- a/packages/mcp-server/test/web.test.ts +++ b/packages/mcp-server/test/web.test.ts @@ -16,7 +16,10 @@ function createHandler(machineId?: string) { return createIapKitWebMcpHandler({ logger: silentLogger, machineId }); } -function initializeRequest(sessionId?: string): Request { +function initializeRequest( + sessionId?: string, + headers: Record = {}, +): Request { return mcpRequest( { jsonrpc: "2.0", @@ -29,6 +32,7 @@ function initializeRequest(sessionId?: string): Request { }, }, sessionId, + headers, ); } @@ -88,6 +92,30 @@ describe("web MCP handler session routing", () => { expect(list.status).toBe(200); }); + it("rejects new sessions at capacity without evicting active sessions", async () => { + const handler = createHandler("self42"); + let firstSessionId = ""; + + for (let index = 0; index < 256; index += 1) { + const response = await handler(initializeRequest()); + expect(response.status).toBe(200); + firstSessionId ||= response.headers.get("mcp-session-id") ?? ""; + await response.text(); + } + + const denied = await handler( + initializeRequest(undefined, { origin: "https://chatgpt.com" }), + ); + expect(denied.status).toBe(503); + expect(denied.headers.get("retry-after")).toBe("5"); + expect(denied.headers.get("access-control-allow-origin")).toBe( + "https://chatgpt.com", + ); + + const followUp = await handler(toolsListRequest(firstSessionId)); + expect(followUp.status).toBe(200); + }); + it("replays a foreign machine's session via fly-replay", async () => { const handler = createHandler("self42"); const response = await handler( From e9baacea11409735dcd4f4aa6fe4160b6413df92 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 13 Aug 2026 18:02:58 +0900 Subject: [PATCH 3/3] fix(mcp): invalidate shutdown reservations --- .../kit/server/api/v1/mcp-contract.test.ts | 10 ++++++++-- packages/mcp-server/src/session-store.ts | 19 +++++++++++++++++-- .../mcp-server/test/session-store.test.ts | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/kit/server/api/v1/mcp-contract.test.ts b/packages/kit/server/api/v1/mcp-contract.test.ts index 6b1a865c4..b55b3c4d7 100644 --- a/packages/kit/server/api/v1/mcp-contract.test.ts +++ b/packages/kit/server/api/v1/mcp-contract.test.ts @@ -123,12 +123,18 @@ describe("MCP Kit response contracts", () => { mocks.query.mockReset(); vi.stubGlobal( "fetch", - (input: string | URL | Request, init?: RequestInit) => { + async (input: string | URL | Request, init?: RequestInit) => { const request = new Request(input, init); const url = new URL(request.url); return apiRoutes.request( `${url.pathname.replace(/^\/v1/, "")}${url.search}`, - { method: request.method, headers: request.headers }, + { + method: request.method, + headers: request.headers, + ...(request.method === "GET" || request.method === "HEAD" + ? {} + : { body: await request.text() }), + }, ); }, ); diff --git a/packages/mcp-server/src/session-store.ts b/packages/mcp-server/src/session-store.ts index 9c338ae19..c8b7d769a 100644 --- a/packages/mcp-server/src/session-store.ts +++ b/packages/mcp-server/src/session-store.ts @@ -30,6 +30,8 @@ export class BoundedSessionStore { private readonly entries = new Map>(); private readonly now: () => number; private pendingReservations = 0; + private reservationGeneration = 0; + private closed = false; constructor(private readonly options: BoundedSessionStoreOptions) { if (!Number.isSafeInteger(options.maxSize) || options.maxSize < 1) { @@ -57,6 +59,8 @@ export class BoundedSessionStore { } reserve(): SessionReservation | null { + if (this.closed) return null; + const now = this.now(); this.pruneExpired(now); if (this.entries.size + this.pendingReservations >= this.options.maxSize) { @@ -64,16 +68,24 @@ export class BoundedSessionStore { } this.pendingReservations += 1; + const generation = this.reservationGeneration; let pending = true; const release = (): void => { if (!pending) return; pending = false; - this.pendingReservations -= 1; + if (generation === this.reservationGeneration) { + this.pendingReservations -= 1; + } }; return { commit: (sessionId, value) => { - if (!pending) { + if ( + !pending || + this.closed || + generation !== this.reservationGeneration + ) { + pending = false; throw new Error("session reservation is no longer active"); } release(); @@ -109,6 +121,9 @@ export class BoundedSessionStore { } async closeAll(): Promise { + this.closed = true; + this.reservationGeneration += 1; + this.pendingReservations = 0; const values = Array.from(this.entries.values(), (entry) => entry.value); this.entries.clear(); await Promise.all(values.map((value) => this.dispose(value))); diff --git a/packages/mcp-server/test/session-store.test.ts b/packages/mcp-server/test/session-store.test.ts index db79c50f7..df0b3b0eb 100644 --- a/packages/mcp-server/test/session-store.test.ts +++ b/packages/mcp-server/test/session-store.test.ts @@ -92,4 +92,22 @@ describe("BoundedSessionStore", () => { expect(dispose).toHaveBeenCalledWith("transport-one"); expect(dispose).toHaveBeenCalledWith("transport-two"); }); + + it("invalidates pending reservations during shutdown", async () => { + const store = new BoundedSessionStore({ + maxSize: 1, + idleTtlMs: 100, + dispose: vi.fn(), + }); + const reservation = store.reserve(); + expect(reservation).not.toBeNull(); + + await store.closeAll(); + + expect(() => reservation?.commit("late", "late-transport")).toThrow( + "session reservation is no longer active", + ); + expect(store.size).toBe(0); + expect(store.reserve()).toBeNull(); + }); });