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 5e3b578b4..3f3caf165 100644 --- a/packages/kit/fly.toml +++ b/packages/kit/fly.toml @@ -4,6 +4,10 @@ primary_region = 'iad' [build] dockerfile = 'Dockerfile' +[env] + IAPKIT_BASE_URL = 'http://127.0.0.1:3000' + IAPKIT_PUBLIC_BASE_URL = 'https://kit.openiap.dev' + [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..b55b3c4d7 --- /dev/null +++ b/packages/kit/server/api/v1/mcp-contract.test.ts @@ -0,0 +1,220 @@ +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"; +import type { + SubscriptionEntitlementsResponse, + SubscriptionStatusResponse, +} from "./subscriptions"; + +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 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< + typeof api.products.query.listProductsPage +>; +type ServerSubscriptionsResponse = FunctionReturnType< + typeof api.subscriptions.query.listSubscriptions +>; +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 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(() => { + mocks.action.mockReset(); + mocks.mutation.mockReset(); + mocks.query.mockReset(); + vi.stubGlobal( + "fetch", + 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, + ...(request.method === "GET" || request.method === "HEAD" + ? {} + : { body: await request.text() }), + }, + ); + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps app-readable response types aligned", () => { + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + }); + + it("keeps administrative read response types aligned", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + }); + + it("keeps administrative write response types aligned", () => { + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + NormalizeContract + >(); + }); + + 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..80e9c4b90 100644 --- a/packages/kit/server/api/v1/rate-limit.test.ts +++ b/packages/kit/server/api/v1/rate-limit.test.ts @@ -1,17 +1,28 @@ -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, multiAxisRateLimitMiddleware, parsePositiveNumber, rateLimitMiddleware, + sourceRateLimitMiddleware, tryConsume, 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(); @@ -434,4 +445,224 @@ 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", () => { + 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"); + }); + + 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 8ab7abab4..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 @@ -55,6 +57,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 +214,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 +363,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 +377,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. * @@ -406,76 +455,129 @@ export function multiAxisRateLimitMiddleware( const nowMs = clock(); c.set("apiKeyHash", apiKeyHash); + 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 = {}, +): 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( - "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}`), + hashApiKey(`source:${ip}`), config.ip, { capacity: DEFAULT_IP_CAPACITY, refillPerSecond: DEFAULT_IP_REFILL_PER_SEC, maxStoreSize: DEFAULT_IP_MAX_STORE_SIZE, - store: sharedIpStore, + store: sharedSourceIpStore, }, c, ), resolveAxis( "global", - "process", + "source-process", config.global, { capacity: DEFAULT_GLOBAL_CAPACITY, refillPerSecond: DEFAULT_GLOBAL_REFILL_PER_SEC, maxStoreSize: 1, - store: sharedGlobalStore, + store: sharedSourceGlobalStore, }, c, ), ] satisfies AxisRuntime[]; - if (axes.every((axis) => axis.cost <= 0)) { - await next(); - return; - } - - 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); - } - } - - 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/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 e2382f879..626cd9aab 100644 --- a/packages/kit/server/server.ts +++ b/packages/kit/server/server.ts @@ -1,17 +1,27 @@ 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 } 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"; 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; +} // Liveness/readiness probe. No DB hit — Fly.io health checks fire // frequently and hitting Convex from here would both mask real @@ -28,6 +38,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/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 eefb6e070..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, @@ -26,6 +23,12 @@ import { currentMachineId, routeUnknownSession, } from "./session-routing.js"; +import { + createMcpSessionStore, + MCP_SESSION_CAPACITY_MESSAGE, + MCP_SESSION_CAPACITY_RETRY_AFTER_SECONDS, + type BoundedSessionStore, +} from "./session-store.js"; const DEFAULT_MCP_PATH = "/mcp"; const DEFAULT_PORT = 3939; @@ -85,7 +88,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 { @@ -134,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; } @@ -179,10 +183,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 +236,7 @@ export async function startRemoteMcpHttpServer( async function handleMcpPost( req: AuthenticatedRequest, res: ServerResponse, - transports: Map, + transports: BoundedSessionStore, logger: Pick, machineId: string | undefined, ): Promise { @@ -263,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}`); }, }); @@ -281,14 +294,25 @@ 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( req: IncomingMessage, res: ServerResponse, - transports: Map, + transports: BoundedSessionStore, machineId: string | undefined, ): Promise { const sessionId = headerString(req.headers["mcp-session-id"]); @@ -409,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 4733c0051..20b01397f 100644 --- a/packages/mcp-server/src/kit-client.ts +++ b/packages/mcp-server/src/kit-client.ts @@ -3,12 +3,50 @@ // 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; }; +export interface KitProductListParams { + platform?: "IOS" | "Android"; + limit?: number; + cursor?: string; +} + +export type KitProductsResponse = SharedKitProductsResponse; +export type KitMetricsResponse = SharedKitMetricsResponse; +export type KitMrrCurrencyEntry = SharedKitMrrCurrencyEntry; + +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 const IAPKIT_MCP_LOOPBACK_HEADER = "x-iapkit-mcp-loopback"; export function normalizeKitBaseUrl(baseUrl?: string): string { let url: URL; @@ -55,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}`, { @@ -62,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), }, }); @@ -100,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; @@ -121,49 +164,27 @@ 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}` : ""}`, ); }, - 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, 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: { 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}` : ""}`, ); }, @@ -185,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), }), @@ -194,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), }), @@ -202,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: { @@ -221,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", @@ -249,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", @@ -266,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)}`), - health: () => call<{ ok: boolean }>("/health"), + 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 bfeda62d3..a1fab866f 100644 --- a/packages/mcp-server/src/mcp.ts +++ b/packages/mcp-server/src/mcp.ts @@ -7,6 +7,7 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +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` @@ -42,6 +43,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 +114,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 (!isSecretApiKey(apiKey)) { + throw new Error(INSUFFICIENT_API_KEY_SCOPE_MESSAGE); + } return kitClient({ apiKey, baseUrl: opts.baseUrl ?? process.env.IAPKIT_BASE_URL, @@ -186,15 +192,11 @@ 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}`, - ); + // 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}`, + ); } function registerTool( @@ -433,9 +435,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 +451,8 @@ function registerIapKitTools(server: McpServer) { return ok( await withClient(args, extra).listProducts({ platform: args.platform, + limit: args.limit, + cursor: args.cursor, }), ); } catch (error) { @@ -620,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() @@ -632,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 new file mode 100644 index 000000000..c8b7d769a --- /dev/null +++ b/packages/mcp-server/src/session-store.ts @@ -0,0 +1,158 @@ +interface SessionEntry { + value: T; + lastAccessedAt: number; +} + +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; +} + +export interface BoundedSessionStoreOptions { + maxSize: number; + idleTtlMs: number; + dispose: (value: T) => void | Promise; + now?: () => number; + 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; + private reservationGeneration = 0; + private closed = false; + + 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; + } + + reserve(): SessionReservation | null { + if (this.closed) return null; + + const now = this.now(); + this.pruneExpired(now); + if (this.entries.size + this.pendingReservations >= this.options.maxSize) { + return null; + } + + this.pendingReservations += 1; + const generation = this.reservationGeneration; + let pending = true; + const release = (): void => { + if (!pending) return; + pending = false; + if (generation === this.reservationGeneration) { + this.pendingReservations -= 1; + } + }; + + return { + commit: (sessionId, value) => { + if ( + !pending || + this.closed || + generation !== this.reservationGeneration + ) { + pending = false; + 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); + } + + 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 { + 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))); + } + + 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..c446447fd 100644 --- a/packages/mcp-server/src/web.ts +++ b/packages/mcp-server/src/web.ts @@ -4,16 +4,19 @@ 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, currentMachineId, routeUnknownSession, } from "./session-routing.js"; +import { + createMcpSessionStore, + MCP_SESSION_CAPACITY_MESSAGE, + MCP_SESSION_CAPACITY_RETRY_AFTER_SECONDS, + 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,17 +49,15 @@ 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, ): Promise { try { if (request.method === "OPTIONS") { - return withCors( + return withIapKitMcpCors( request, new Response(null, { status: 204 }), allowedOrigins, @@ -66,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, @@ -84,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") { @@ -94,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, @@ -129,7 +130,7 @@ export function createIapKitWebMcpHandler( async function handlePost( request: Request, - transports: Map, + transports: BoundedSessionStore, logger: Pick, authInfo: AuthInfo | undefined, machineId: string | undefined, @@ -157,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) => { @@ -176,16 +186,27 @@ 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( request: Request, - transports: Map, + transports: BoundedSessionStore, authInfo: AuthInfo | undefined, machineId: string | undefined, ): Promise { @@ -280,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"); @@ -298,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, @@ -320,6 +346,7 @@ function jsonRpcError( statusCode: number, code: number, message: string, + extraHeaders: Record = {}, ): Response { return new Response( JSON.stringify({ @@ -329,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 5deb39df6..4af04a866 100644 --- a/packages/mcp-server/test/http.test.ts +++ b/packages/mcp-server/test/http.test.ts @@ -169,39 +169,170 @@ 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 () => { - 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.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, + }); + + 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 () => { + 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("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"); @@ -270,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( @@ -296,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( @@ -358,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) { @@ -414,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"); @@ -777,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" }); @@ -835,8 +966,49 @@ 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 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 17864a4a2..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 }), { @@ -115,12 +146,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 +167,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..df0b3b0eb --- /dev/null +++ b/packages/mcp-server/test/session-store.test.ts @@ -0,0 +1,113 @@ +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; + const dispose = vi.fn(async () => undefined); + const store = new BoundedSessionStore({ + maxSize: 2, + idleTtlMs: 100, + now: () => now, + dispose, + }); + + addSession(store, "active", "active-transport"); + now = 50; + expect(store.get("active")).toBe("active-transport"); + now = 120; + addSession(store, "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("rejects admission at the cap without evicting active sessions", () => { + let now = 0; + const dispose = vi.fn(async () => undefined); + const store = new BoundedSessionStore({ + maxSize: 2, + idleTtlMs: 1_000, + now: () => now, + dispose, + }); + + addSession(store, "oldest", "oldest-transport"); + now = 1; + addSession(store, "recent", "recent-transport"); + now = 2; + expect(store.get("oldest")).toBe("oldest-transport"); + now = 3; + expect(store.reserve()).toBeNull(); + + expect(store.get("recent")).toBe("recent-transport"); + expect(store.get("oldest")).toBe("oldest-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 () => { + const dispose = vi.fn(async () => undefined); + const store = new BoundedSessionStore({ + maxSize: 2, + idleTtlMs: 100, + dispose, + }); + addSession(store, "one", "transport-one"); + addSession(store, "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"); + }); + + 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(); + }); +}); 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(