From c53828b2b0a43452d85b2764aef4f3a83c65d73c Mon Sep 17 00:00:00 2001
From: Hyo
Date: Thu, 4 Jun 2026 14:08:43 +0900
Subject: [PATCH 01/18] feat(kit): add IAPKit ChatGPT MCP connector
Add an IAPKit-branded MCP server entrypoint for ChatGPT connectors, expose the hosted Kit /mcp endpoint, and document the ChatGPT plugin setup flow with a captured docs screenshot.
Validation: bun run --filter @hyodotdev/openiap-mcp-server lint; bun run --filter @hyodotdev/openiap-mcp-server test; bun run --filter @hyodotdev/openiap-mcp-server build; bun run audit:docs; bun run --filter @hyodotdev/openiap-kit lint; bun run --filter @hyodotdev/openiap-kit test; VITE_KIT_CONVEX_URL=https://example.convex.cloud bun run --filter @hyodotdev/openiap-kit smoke:server
---
bun.lock | 3 +
packages/kit/Dockerfile | 2 +
packages/kit/README.md | 8 +
packages/kit/package.json | 1 +
.../docs/screenshots/chatgpt-plugin.webp | Bin 0 -> 7224 bytes
packages/kit/public/llms-full.txt | 15 +
packages/kit/public/llms.txt | 2 +
packages/kit/public/sitemap.xml | 8 +-
packages/kit/server/mcp.test.ts | 63 ++
packages/kit/server/mcp.ts | 5 +
packages/kit/server/server.ts | 5 +
packages/kit/src/pages/docs/nav.ts | 9 +-
packages/kit/src/pages/docs/routes.tsx | 5 +
.../src/pages/docs/sections/ai-assistants.tsx | 16 +
.../pages/docs/sections/chatgpt-plugin.tsx | 147 ++++
packages/kit/vite.config.ts | 4 +
packages/mcp-server/package.json | 13 +-
packages/mcp-server/src/http.ts | 365 +++++++++
packages/mcp-server/src/index.ts | 586 +-------------
packages/mcp-server/src/mcp.ts | 744 ++++++++++++++++++
packages/mcp-server/src/web.ts | 240 ++++++
packages/mcp-server/test/http.test.ts | 124 +++
22 files changed, 1780 insertions(+), 585 deletions(-)
create mode 100644 packages/kit/public/docs/screenshots/chatgpt-plugin.webp
create mode 100644 packages/kit/server/mcp.test.ts
create mode 100644 packages/kit/server/mcp.ts
create mode 100644 packages/kit/src/pages/docs/sections/chatgpt-plugin.tsx
create mode 100644 packages/mcp-server/src/http.ts
create mode 100644 packages/mcp-server/src/mcp.ts
create mode 100644 packages/mcp-server/src/web.ts
create mode 100644 packages/mcp-server/test/http.test.ts
diff --git a/bun.lock b/bun.lock
index 6783494d..23712b95 100644
--- a/bun.lock
+++ b/bun.lock
@@ -84,6 +84,7 @@
"@convex-dev/auth": "^0.0.92",
"@convex-dev/migrations": "^0.3.4",
"@hono/standard-validator": "^0.2.2",
+ "@hyodotdev/openiap-mcp-server": "workspace:*",
"@icons-pack/react-simple-icons": "^13.7.0",
"@preact/signals-react": "^3.2.1",
"@sentry/bun": "^10.26.0",
@@ -149,6 +150,8 @@
"name": "@hyodotdev/openiap-mcp-server",
"version": "0.1.0",
"bin": {
+ "iapkit-mcp": "./dist/index.js",
+ "iapkit-mcp-http": "./dist/http.js",
"openiap-mcp": "./dist/index.js",
},
"dependencies": {
diff --git a/packages/kit/Dockerfile b/packages/kit/Dockerfile
index 536f90e8..de91eba7 100644
--- a/packages/kit/Dockerfile
+++ b/packages/kit/Dockerfile
@@ -31,6 +31,7 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json bun.lock ./
COPY packages/kit ./packages/kit
+COPY packages/mcp-server ./packages/mcp-server
# bun 1.3.13 + workspaces installs some deps under each package's
# local node_modules (e.g. `vite`, `@vitejs/plugin-react`) instead of
# fully hoisting. Pull kit's local node_modules from the deps stage
@@ -38,6 +39,7 @@ COPY packages/kit ./packages/kit
# build fails with `vite: command not found` (PR #124 (https://github.com/hyodotdev/openiap/pull/124) review
# fallout).
COPY --from=deps /app/packages/kit/node_modules ./packages/kit/node_modules
+COPY --from=deps /app/packages/mcp-server/node_modules ./packages/mcp-server/node_modules
WORKDIR /app/packages/kit
ARG VITE_KIT_CONVEX_URL
ENV VITE_KIT_CONVEX_URL=${VITE_KIT_CONVEX_URL}
diff --git a/packages/kit/README.md b/packages/kit/README.md
index d58b21af..e7391ed6 100644
--- a/packages/kit/README.md
+++ b/packages/kit/README.md
@@ -41,6 +41,7 @@ One package, one binary, one Fly.io app.
- **Free for everyone** — no paywall, no usage caps. Sustained by sponsors at [openiap.dev/sponsors](https://openiap.dev/sponsors)
- **Email OTP (Resend) + GitHub OAuth** via `@convex-dev/auth`
- **OpenAPI spec** auto-generated by `hono-openapi`
+- **ChatGPT / MCP connector** at `/mcp` for IAPKit project inspection and product-management workflows
## Quick Start
@@ -79,6 +80,13 @@ For API-only work:
bun run dev:server # Hono on http://localhost:3000
```
+The dev server also exposes the ChatGPT / MCP connector at
+`http://localhost:3000/mcp`. Use an IAPKit project key, not an OpenAI API key:
+
+```bash
+IAPKIT_API_KEY="openiap-kit_your-project-key" bun run dev:server
+```
+
### 4. Production build
```bash
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 93677801..f6bc22c3 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -34,6 +34,7 @@
"@convex-dev/auth": "^0.0.92",
"@convex-dev/migrations": "^0.3.4",
"@hono/standard-validator": "^0.2.2",
+ "@hyodotdev/openiap-mcp-server": "workspace:*",
"@icons-pack/react-simple-icons": "^13.7.0",
"@preact/signals-react": "^3.2.1",
"@sentry/bun": "^10.26.0",
diff --git a/packages/kit/public/docs/screenshots/chatgpt-plugin.webp b/packages/kit/public/docs/screenshots/chatgpt-plugin.webp
new file mode 100644
index 0000000000000000000000000000000000000000..ad6788c6d9d06c9064a8cb2d180a5ea3236c2572
GIT binary patch
literal 7224
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
literal 0
HcmV?d00001
diff --git a/packages/kit/public/llms-full.txt b/packages/kit/public/llms-full.txt
index 4273c7e1..e72a7b27 100644
--- a/packages/kit/public/llms-full.txt
+++ b/packages/kit/public/llms-full.txt
@@ -12,6 +12,8 @@ Single-package repo deployed as one Fly.io machine:
- `src/` — React 19 SPA (dashboard, auth, usage, projects, API keys, docs)
- `server/` — Hono + Bun server for `/api/v1/*` + `/v1/*` + SPA fallback
+- `packages/mcp-server/` — MCP server used by `/mcp`, ChatGPT connectors,
+ Claude/Cursor/Codex, and self-hosted IAPKit assistant workflows
- `convex/` — Convex functions (auth, orgs, projects, receipts)
- `public/` — static assets (served by Vite in dev, Hono in prod)
@@ -31,6 +33,11 @@ Each project has its own API key. Keys are stored in Convex
dashboard. Dashboard users sign in via GitHub / Google OAuth or email-OTP
(Resend).
+The MCP endpoint at `/mcp` uses the same IAPKit project key. MCP clients may
+send it as `Authorization: Bearer `. A self-hosted MCP server can
+instead set `IAPKIT_API_KEY` so ChatGPT never receives the project key directly.
+This is not an OpenAI API key.
+
## Endpoints
Mounted at both `/v1/*` (canonical) and `/api/v1/*` (alias).
@@ -81,6 +88,14 @@ Intentionally cheap — no Convex round-trip. Used by Fly.io liveness /
readiness probes. Sentry is configured to drop `/health` transactions so
probe traffic doesn't dominate trace quota.
+### POST /mcp
+
+MCP Streamable HTTP endpoint for ChatGPT and other MCP clients. Exposes tools
+with the `iapkit_` prefix: setup snippets, status checks, diagnostics, product
+catalog reads/writes, subscriber views, sandbox guidance, webhook simulation,
+and project inspection. Full setup guide:
+`/docs/ai-assistants/chatgpt-plugin`.
+
## Harmonized purchase states
| State | `isValid` | Meaning |
diff --git a/packages/kit/public/llms.txt b/packages/kit/public/llms.txt
index 2f2c090f..9c90b64e 100644
--- a/packages/kit/public/llms.txt
+++ b/packages/kit/public/llms.txt
@@ -20,6 +20,7 @@ Auth: `Authorization: Bearer openiap-kit_`
- [GET /v1/openapi](https://kit.openiap.dev/v1/openapi) — machine-readable OpenAPI spec
- [GET /v1](https://kit.openiap.dev/v1) — Redoc UI for the OpenAPI spec
- [GET /health](https://kit.openiap.dev/health) — liveness probe (no Convex round-trip)
+- [POST /mcp](https://kit.openiap.dev/docs/ai-assistants/chatgpt-plugin) — MCP Streamable HTTP endpoint for ChatGPT and other MCP clients. Uses an IAPKit project API key, not an OpenAI API key.
Also mounted at `/api/v1/*` for backwards compatibility. `/v1/verify-purchase`
is an alias of `/v1/purchase/verify`. Pick `/v1/purchase/verify` for new code.
@@ -69,4 +70,5 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`,
- [/docs/operations](https://kit.openiap.dev/docs/operations) — rate limits, logs, `/health`, graceful shutdown
- [openiap.dev/docs/webhooks](https://openiap.dev/docs/webhooks) — operator setup steps for the lifecycle webhook URL (Apple ASN v2 + Google RTDN) and SDK code for consuming the SSE stream
- [/docs/ai-assistants](https://kit.openiap.dev/docs/ai-assistants) — how to point Claude / Cursor / etc. at this file
+- [/docs/ai-assistants/chatgpt-plugin](https://kit.openiap.dev/docs/ai-assistants/chatgpt-plugin) — ChatGPT MCP connector setup and self-hosted IAPKit MCP server option
- [/docs/release-notes](https://kit.openiap.dev/docs/release-notes) — changelog
diff --git a/packages/kit/public/sitemap.xml b/packages/kit/public/sitemap.xml
index cd3778ed..7b1e894d 100644
--- a/packages/kit/public/sitemap.xml
+++ b/packages/kit/public/sitemap.xml
@@ -70,7 +70,13 @@
https://kit.openiap.dev/docs/ai-assistants
- 2026-04-22
+ 2026-06-04
+ monthly
+ 0.6
+
+
+ https://kit.openiap.dev/docs/ai-assistants/chatgpt-plugin
+ 2026-06-04
monthly
0.6
diff --git a/packages/kit/server/mcp.test.ts b/packages/kit/server/mcp.test.ts
new file mode 100644
index 00000000..39ec3b66
--- /dev/null
+++ b/packages/kit/server/mcp.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+
+import { handleIapKitMcpRequest } from "./mcp";
+
+describe("IAPKit MCP route handler", () => {
+ it("initializes a ChatGPT-compatible MCP session", async () => {
+ const initResponse = await postMcp({
+ jsonrpc: "2.0",
+ id: 1,
+ method: "initialize",
+ params: {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "vitest", version: "0.0.0" },
+ },
+ });
+
+ expect(initResponse.status).toBe(200);
+ const sessionId = initResponse.headers.get("mcp-session-id");
+ expect(sessionId).toBeTruthy();
+
+ const initEvent = parseSseJson(await initResponse.text());
+ expect(initEvent.result.serverInfo).toMatchObject({
+ name: "iapkit-mcp",
+ websiteUrl: "https://kit.openiap.dev",
+ });
+
+ const toolsResponse = await postMcp(
+ { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
+ sessionId ?? undefined,
+ );
+ const toolsEvent = parseSseJson(await toolsResponse.text());
+ const toolNames = toolsEvent.result.tools.map(
+ (tool: { name: string }) => tool.name,
+ );
+
+ expect(toolNames).toContain("iapkit_inspect_state");
+ expect(toolNames).toContain("iapkit_manage_product");
+ expect(toolNames).not.toContain("openiap_inspect_state");
+ });
+});
+
+function postMcp(body: unknown, sessionId?: string): Promise {
+ return handleIapKitMcpRequest(
+ new Request("http://localhost/mcp", {
+ method: "POST",
+ headers: {
+ accept: "application/json, text/event-stream",
+ "content-type": "application/json",
+ ...(sessionId ? { "mcp-session-id": sessionId } : {}),
+ },
+ body: JSON.stringify(body),
+ }),
+ );
+}
+
+function parseSseJson(raw: string): any {
+ const dataLine = raw.split("\n").find((line) => line.startsWith("data: "));
+ if (!dataLine) {
+ throw new Error(`No SSE data line found: ${raw}`);
+ }
+ return JSON.parse(dataLine.slice("data: ".length));
+}
diff --git a/packages/kit/server/mcp.ts b/packages/kit/server/mcp.ts
new file mode 100644
index 00000000..01f7d325
--- /dev/null
+++ b/packages/kit/server/mcp.ts
@@ -0,0 +1,5 @@
+import { createIapKitWebMcpHandler } from "@hyodotdev/openiap-mcp-server/web";
+
+export const handleIapKitMcpRequest = createIapKitWebMcpHandler({
+ includeLegacyOpenIapAliases: process.env.IAPKIT_MCP_LEGACY_ALIASES === "true",
+});
diff --git a/packages/kit/server/server.ts b/packages/kit/server/server.ts
index 8ac87b2f..7cf9f4a9 100644
--- a/packages/kit/server/server.ts
+++ b/packages/kit/server/server.ts
@@ -6,6 +6,7 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { apiRoutes } from "./api/v1/routes";
+import { handleIapKitMcpRequest } from "./mcp";
import { shouldReturnNotFoundForMissingStaticPath } from "./staticPaths";
import { parsePort } from "./utils/env";
@@ -23,6 +24,10 @@ app.get("/health", (c) => c.json({ ok: true }));
app.route("/api/v1", apiRoutes);
app.route("/v1", apiRoutes);
+// ChatGPT / MCP connector endpoint for IAPKit. This must sit before
+// static serving so `/mcp` never falls through to the React Router SPA.
+app.all("/mcp", (c) => handleIapKitMcpRequest(c.req.raw));
+
const STATIC_ROOT = process.env.STATIC_ROOT ?? "./dist";
// Serve the built SPA (hashed assets, favicons, llms.txt, etc.).
diff --git a/packages/kit/src/pages/docs/nav.ts b/packages/kit/src/pages/docs/nav.ts
index 4bdf8310..ec15796e 100644
--- a/packages/kit/src/pages/docs/nav.ts
+++ b/packages/kit/src/pages/docs/nav.ts
@@ -70,7 +70,14 @@ export const DOCS_NAV: DocsNavEntry[] = [
{
slug: "ai-assistants",
title: "AI assistants",
- summary: "Plain-text llms.txt / llms-full.txt for Claude, Cursor, etc.",
+ summary: "llms.txt, MCP, and ChatGPT connector setup.",
+ children: [
+ {
+ slug: "ai-assistants/chatgpt-plugin",
+ title: "ChatGPT plugin",
+ summary: "Connect ChatGPT to IAPKit through the /mcp endpoint.",
+ },
+ ],
},
{
slug: "release-notes",
diff --git a/packages/kit/src/pages/docs/routes.tsx b/packages/kit/src/pages/docs/routes.tsx
index bd0fed17..374b85dd 100644
--- a/packages/kit/src/pages/docs/routes.tsx
+++ b/packages/kit/src/pages/docs/routes.tsx
@@ -10,6 +10,7 @@ import ApiReferencePage from "./sections/api";
import AnalyticsPage from "./sections/analytics";
import OperationsPage from "./sections/operations";
import AiAssistantsPage from "./sections/ai-assistants";
+import ChatGptPluginPage from "./sections/chatgpt-plugin";
import ReleaseNotesPage from "./sections/release-notes";
/**
@@ -45,6 +46,10 @@ export const docsChildRoutes = (
} />
} />
} />
+ }
+ />
} />
{/* Unknown sub-paths bounce back to the docs index so the user
never ends up in the authed organization routes by accident. */}
diff --git a/packages/kit/src/pages/docs/sections/ai-assistants.tsx b/packages/kit/src/pages/docs/sections/ai-assistants.tsx
index d03ce17a..3f7121fe 100644
--- a/packages/kit/src/pages/docs/sections/ai-assistants.tsx
+++ b/packages/kit/src/pages/docs/sections/ai-assistants.tsx
@@ -1,3 +1,5 @@
+import { Link } from "react-router-dom";
+
import { Callout } from "../components/Callout";
import { CodeBlock } from "../components/CodeBlock";
import { DocsPage } from "../components/DocsPage";
@@ -83,6 +85,20 @@ export default function AiAssistantsPage() {
+ ChatGPT plugin
+
+ ChatGPT can use IAPKit as an MCP connector through{" "}
+ https://kit.openiap.dev/mcp. The connector uses your IAPKit
+ project API key, not an OpenAI API key. See the{" "}
+
+ ChatGPT plugin guide
+ {" "}
+ for the exact setup flow, self-hosted option, and tool list.
+
+
Using the files
From a prompt
diff --git a/packages/kit/src/pages/docs/sections/chatgpt-plugin.tsx b/packages/kit/src/pages/docs/sections/chatgpt-plugin.tsx
new file mode 100644
index 00000000..b6cfbdda
--- /dev/null
+++ b/packages/kit/src/pages/docs/sections/chatgpt-plugin.tsx
@@ -0,0 +1,147 @@
+import { Callout } from "../components/Callout";
+import { CodeBlock } from "../components/CodeBlock";
+import { DocsPage } from "../components/DocsPage";
+import { DocsScreenshot } from "../components/DocsScreenshot";
+
+export default function ChatGptPluginPage() {
+ return (
+
+
+ The IAPKit ChatGPT plugin is an MCP connector. ChatGPT talks to{" "}
+ /mcp, the connector exposes IAPKit tools, and those tools
+ call the same /v1 API that the dashboard and SDK helpers
+ use.
+
+
+
+
+ Do not use an OpenAI API key for this connector. Authentication is an
+ IAPKit project API key: either send it as{" "}
+ Authorization: Bearer <IAPKit project key> from an
+ MCP client that supports bearer auth, or set{" "}
+ IAPKIT_API_KEY on your private MCP server so ChatGPT
+ never sees the secret.
+
+
+
+
+
Connector settings
+
+
+
+ Remote MCP URL
+
+
+ https://kit.openiap.dev/mcp
+
+
+
+
+ Authentication
+
+
+ Bearer token = IAPKit project API key
+
+
+
+
+ Tool prefix
+
+
+ iapkit_*
+
+
+
+
+
+
+
+ Connect from ChatGPT
+
+ Open ChatGPT's connector or developer-mode connector settings.
+
+ Create a new MCP connector named IAPKit .
+
+
+ Set the MCP URL to https://kit.openiap.dev/mcp.
+
+
+ If the connector UI asks for bearer authentication, paste the IAPKit
+ project API key from your project's API keys {" "}
+ tab. If it does not support bearer auth, self-host the MCP server and
+ set IAPKIT_API_KEY in that server's environment.
+
+
+ Save the connector, open a new ChatGPT thread, and ask it to inspect
+ your IAPKit project.
+
+
+
+
+ {`Use the IAPKit connector.
+
+List my products and tell me which ones are missing a billing period.
+Then show the lifecycle webhook URL I need to paste into App Store Connect.`}
+
+
+ Self-hosted connector
+
+ Self-hosting is the safest path for a single project because the IAPKit
+ project key stays in your MCP server process instead of being typed into
+ a chat. The server still talks to the hosted IAPKit API by default.
+
+
+ {`# From the monorepo root
+IAPKIT_API_KEY="openiap-kit_your-project-key" \\
+ bun run --filter @hyodotdev/openiap-mcp-server start:http
+
+# Public HTTPS tunnel or deployment should forward to:
+# http://localhost:3939/mcp`}
+
+
+ Available tools
+
+ The connector exposes read-only tools for project inspection and
+ write-capable tools for product catalog changes or webhook simulation.
+ ChatGPT sees them with the iapkit_ prefix:
+
+
+
+ iapkit_inspect_state - metrics, product catalog, webhook
+ URLs.
+
+
+ iapkit_list_products and{" "}
+ iapkit_view_subscribers - catalog and subscriber reads.
+
+
+ iapkit_create_product and{" "}
+ iapkit_manage_product - product catalog writes.
+
+
+ iapkit_troubleshoot - health, metrics, and optional user
+ status probes.
+
+
+
+
+
+ Product management tools call the live IAPKit product endpoints. Ask
+ ChatGPT to inspect state first, then review the proposed product ID,
+ platform, type, price, and billing period before allowing a write.
+
+
+
+ );
+}
diff --git a/packages/kit/vite.config.ts b/packages/kit/vite.config.ts
index 67e4c185..53c27de5 100644
--- a/packages/kit/vite.config.ts
+++ b/packages/kit/vite.config.ts
@@ -78,6 +78,10 @@ export default defineConfig({
target: "http://localhost:3000",
changeOrigin: true,
},
+ "/mcp": {
+ target: "http://localhost:3000",
+ changeOrigin: true,
+ },
},
},
build: {
diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json
index 1991af14..60f41ef9 100644
--- a/packages/mcp-server/package.json
+++ b/packages/mcp-server/package.json
@@ -1,18 +1,27 @@
{
"name": "@hyodotdev/openiap-mcp-server",
"version": "0.1.0",
- "description": "Model Context Protocol server for OpenIAP — wires Claude / Cursor / Codex into kit's product, subscription, and webhook surfaces.",
+ "description": "Model Context Protocol server for IAPKit — wires ChatGPT, Claude, Cursor, and Codex into IAPKit's product, subscription, and webhook surfaces.",
"type": "module",
"private": true,
"bin": {
+ "iapkit-mcp": "./dist/index.js",
+ "iapkit-mcp-http": "./dist/http.js",
"openiap-mcp": "./dist/index.js"
},
+ "exports": {
+ ".": "./src/index.ts",
+ "./http": "./src/http.ts",
+ "./mcp": "./src/mcp.ts",
+ "./web": "./src/web.ts"
+ },
"main": "src/index.ts",
"scripts": {
"build": "tsc -p .",
"lint": "tsc -p . --noEmit",
"test": "vitest run --passWithNoTests",
- "start": "bun run src/index.ts"
+ "start": "bun run src/index.ts",
+ "start:http": "bun run src/http.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts
new file mode 100644
index 00000000..4cf32056
--- /dev/null
+++ b/packages/mcp-server/src/http.ts
@@ -0,0 +1,365 @@
+#!/usr/bin/env node
+import { randomUUID } from "node:crypto";
+import {
+ createServer,
+ type IncomingMessage,
+ type Server as NodeHttpServer,
+ type ServerResponse,
+} from "node:http";
+import { pathToFileURL } from "node:url";
+
+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 {
+ createIapKitMcpServer,
+ IAPKIT_MCP_SERVER_NAME,
+ IAPKIT_MCP_SERVER_VERSION,
+} from "./mcp.js";
+
+const DEFAULT_MCP_PATH = "/mcp";
+const DEFAULT_PORT = 3939;
+const MAX_MCP_BODY_BYTES = 1024 * 1024;
+const DEFAULT_ALLOWED_ORIGINS = [
+ "https://chatgpt.com",
+ "https://chat.openai.com",
+ "http://localhost:3000",
+ "http://localhost:5173",
+ "http://127.0.0.1:3000",
+ "http://127.0.0.1:5173",
+];
+
+type AuthenticatedRequest = IncomingMessage & { auth?: AuthInfo };
+
+export interface RemoteMcpHttpServerOptions {
+ host?: string;
+ port?: number;
+ mcpPath?: string;
+ allowedOrigins?: string[];
+ includeLegacyOpenIapAliases?: boolean;
+ logger?: Pick;
+}
+
+export interface RemoteMcpHttpServer {
+ server: NodeHttpServer;
+ close: () => Promise;
+}
+
+export function createRemoteMcpHttpServer(
+ options: RemoteMcpHttpServerOptions = {},
+): RemoteMcpHttpServer {
+ const logger = options.logger ?? console;
+ const mcpPath = normalizePath(options.mcpPath ?? DEFAULT_MCP_PATH);
+ const allowedOrigins =
+ options.allowedOrigins ??
+ parseAllowedOrigins(process.env.IAPKIT_MCP_ALLOWED_ORIGINS);
+ const transports = new Map();
+
+ const server = createServer(async (req, res) => {
+ try {
+ setCorsHeaders(req, res, allowedOrigins);
+
+ if (req.method === "OPTIONS") {
+ res.writeHead(204).end();
+ return;
+ }
+
+ const pathname = requestPathname(req);
+
+ if (pathname === "/health") {
+ writeJson(res, 200, {
+ ok: true,
+ name: IAPKIT_MCP_SERVER_NAME,
+ version: IAPKIT_MCP_SERVER_VERSION,
+ transport: "streamable-http",
+ mcpPath,
+ });
+ return;
+ }
+
+ if (pathname === "/") {
+ writeJson(res, 200, {
+ name: IAPKIT_MCP_SERVER_NAME,
+ version: IAPKIT_MCP_SERVER_VERSION,
+ service: "IAPKit",
+ endpoints: {
+ mcp: mcpPath,
+ health: "/health",
+ },
+ authentication: [
+ "Authorization: Bearer ",
+ "IAPKIT_API_KEY environment variable",
+ "OPENIAP_API_KEY environment variable for backward compatibility",
+ ],
+ });
+ return;
+ }
+
+ if (pathname !== mcpPath) {
+ writeJson(res, 404, { ok: false, error: "Not found" });
+ return;
+ }
+
+ attachAuthInfo(req as AuthenticatedRequest);
+
+ if (req.method === "POST") {
+ await handleMcpPost(
+ req as AuthenticatedRequest,
+ res,
+ transports,
+ logger,
+ Boolean(options.includeLegacyOpenIapAliases),
+ );
+ return;
+ }
+
+ if (req.method === "GET" || req.method === "DELETE") {
+ await handleExistingMcpSession(req, res, transports);
+ return;
+ }
+
+ writeJsonRpcError(res, 405, -32000, "Method not allowed");
+ } catch (error) {
+ logger.error("IAPKit MCP HTTP error:", error);
+ if (!res.headersSent) {
+ writeJsonRpcError(res, 500, -32603, "Internal server error");
+ }
+ }
+ });
+
+ async function close(): Promise {
+ await Promise.all(
+ Array.from(transports.values()).map((transport) => transport.close()),
+ );
+ transports.clear();
+ await new Promise((resolve, reject) => {
+ server.close((error) => {
+ if (error) reject(error);
+ else resolve();
+ });
+ });
+ }
+
+ return { server, close };
+}
+
+export async function startRemoteMcpHttpServer(
+ options: RemoteMcpHttpServerOptions = {},
+): Promise {
+ const host = options.host ?? process.env.HOST ?? "0.0.0.0";
+ const port =
+ options.port ??
+ parsePort(process.env.PORT) ??
+ parsePort(process.env.IAPKIT_MCP_PORT) ??
+ DEFAULT_PORT;
+ const remote = createRemoteMcpHttpServer(options);
+
+ await new Promise((resolve) => {
+ remote.server.listen(port, host, resolve);
+ });
+
+ (options.logger ?? console).info(
+ `IAPKit MCP server listening on http://${host}:${port}${options.mcpPath ?? DEFAULT_MCP_PATH}`,
+ );
+ return remote;
+}
+
+async function handleMcpPost(
+ req: AuthenticatedRequest,
+ res: ServerResponse,
+ transports: Map,
+ logger: Pick,
+ includeLegacyOpenIapAliases: boolean,
+): Promise {
+ const sessionId = headerString(req.headers["mcp-session-id"]);
+ const body = await readJsonBody(req);
+ const existingTransport = sessionId ? transports.get(sessionId) : undefined;
+
+ if (existingTransport) {
+ await existingTransport.handleRequest(req, res, body);
+ return;
+ }
+
+ if (sessionId || !isInitializeRequest(body)) {
+ writeJsonRpcError(
+ res,
+ 400,
+ -32000,
+ "Bad Request: initialize first, then send mcp-session-id on follow-up requests.",
+ );
+ return;
+ }
+
+ let transport!: StreamableHTTPServerTransport;
+ transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ onsessioninitialized: (initializedSessionId) => {
+ transports.set(initializedSessionId, transport);
+ logger.info(`IAPKit MCP session initialized: ${initializedSessionId}`);
+ },
+ });
+
+ transport.onclose = () => {
+ const initializedSessionId = transport.sessionId;
+ if (initializedSessionId) {
+ transports.delete(initializedSessionId);
+ logger.info(`IAPKit MCP session closed: ${initializedSessionId}`);
+ }
+ };
+
+ const mcpServer = createIapKitMcpServer({
+ includeLegacyOpenIapAliases,
+ });
+ await mcpServer.connect(transport);
+ await transport.handleRequest(req, res, body);
+}
+
+async function handleExistingMcpSession(
+ req: IncomingMessage,
+ res: ServerResponse,
+ transports: Map,
+): Promise {
+ const sessionId = headerString(req.headers["mcp-session-id"]);
+ const transport = sessionId ? transports.get(sessionId) : undefined;
+
+ if (!transport) {
+ writeJsonRpcError(res, 400, -32000, "Invalid or missing mcp-session-id");
+ return;
+ }
+
+ await transport.handleRequest(req as AuthenticatedRequest, res);
+}
+
+function attachAuthInfo(req: AuthenticatedRequest): void {
+ const bearerToken = parseBearerToken(headerString(req.headers.authorization));
+ if (!bearerToken) return;
+
+ req.auth = {
+ token: bearerToken,
+ clientId: "iapkit-project-api-key",
+ scopes: ["iapkit:project"],
+ };
+}
+
+function parseBearerToken(authorization: string | undefined): string | null {
+ if (!authorization) return null;
+ const match = authorization.match(/^Bearer\s+(.+)$/i);
+ const token = match?.[1]?.trim();
+ return token || null;
+}
+
+async function readJsonBody(req: IncomingMessage): Promise {
+ const chunks: Buffer[] = [];
+ let byteLength = 0;
+
+ for await (const chunk of req) {
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ byteLength += buffer.byteLength;
+ if (byteLength > MAX_MCP_BODY_BYTES) {
+ throw new Error("MCP request body is too large");
+ }
+ chunks.push(buffer);
+ }
+
+ const raw = Buffer.concat(chunks).toString("utf8");
+ if (!raw.trim()) return undefined;
+ return JSON.parse(raw);
+}
+
+function setCorsHeaders(
+ req: IncomingMessage,
+ res: ServerResponse,
+ allowedOrigins: string[],
+): void {
+ const origin = headerString(req.headers.origin);
+ const allowAll = allowedOrigins.includes("*");
+ const allowOrigin =
+ origin && (allowAll || allowedOrigins.includes(origin)) ? origin : null;
+
+ if (allowOrigin) {
+ res.setHeader("Access-Control-Allow-Origin", allowOrigin);
+ res.setHeader("Vary", "Origin");
+ }
+ res.setHeader(
+ "Access-Control-Allow-Headers",
+ "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");
+}
+
+function parseAllowedOrigins(raw: string | undefined): string[] {
+ if (!raw) return DEFAULT_ALLOWED_ORIGINS;
+ const origins = raw
+ .split(",")
+ .map((origin) => origin.trim())
+ .filter((origin) => origin.length > 0);
+ return origins.length > 0 ? origins : DEFAULT_ALLOWED_ORIGINS;
+}
+
+function requestPathname(req: IncomingMessage): string {
+ const url = new URL(req.url ?? "/", "http://localhost");
+ return url.pathname;
+}
+
+function normalizePath(path: string): string {
+ if (!path.startsWith("/")) return `/${path}`;
+ return path;
+}
+
+function parsePort(raw: string | undefined): number | undefined {
+ if (!raw) return undefined;
+ const value = Number(raw);
+ if (!Number.isInteger(value) || value <= 0 || value > 65535) {
+ throw new Error(`Invalid port: ${raw}`);
+ }
+ return value;
+}
+
+function headerString(
+ value: string | string[] | undefined,
+): string | undefined {
+ if (Array.isArray(value)) return value[0];
+ return value;
+}
+
+function writeJson(
+ res: ServerResponse,
+ statusCode: number,
+ body: unknown,
+): void {
+ res.writeHead(statusCode, { "content-type": "application/json" });
+ res.end(JSON.stringify(body));
+}
+
+function writeJsonRpcError(
+ res: ServerResponse,
+ statusCode: number,
+ code: number,
+ message: string,
+): void {
+ writeJson(res, statusCode, {
+ jsonrpc: "2.0",
+ error: { code, message },
+ id: null,
+ });
+}
+
+function isMainModule(): boolean {
+ return process.argv[1]
+ ? import.meta.url === pathToFileURL(process.argv[1]).href
+ : false;
+}
+
+if (isMainModule()) {
+ const remote = await startRemoteMcpHttpServer();
+
+ const shutdown = async () => {
+ await remote.close();
+ process.exit(0);
+ };
+
+ process.once("SIGINT", () => void shutdown());
+ process.once("SIGTERM", () => void shutdown());
+}
diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts
index ea224128..15fd9061 100644
--- a/packages/mcp-server/src/index.ts
+++ b/packages/mcp-server/src/index.ts
@@ -1,588 +1,12 @@
#!/usr/bin/env node
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
-import { z } from "zod";
-import {
- kitClient,
- KitHttpError,
- normalizeKitBaseUrl,
-} from "./kit-client.js";
+import { createIapKitMcpServer } from "./mcp.js";
-// 10-tool MCP server for openiap. Every tool funnels through
-// `withClient` so `OPENIAP_API_KEY` / `OPENIAP_BASE_URL` env config is
-// consistent and errors surface in a uniform `{ ok: false, error }`
-// shape that LLMs handle predictably.
-
-const server = new McpServer({
- name: "openiap-mcp",
- version: "0.1.0",
+const server = createIapKitMcpServer({
+ includeLegacyOpenIapAliases:
+ process.env.IAPKIT_MCP_LEGACY_ALIASES !== "false",
});
-
-const OPTIONAL_BASE_URL = z
- .string()
- .url()
- .optional()
- .describe(
- "Override kit base URL. Defaults to OPENIAP_BASE_URL env var, then https://kit.openiap.dev.",
- );
-
-const API_KEY_PLACEHOLDER = "";
-const MAX_API_KEY_LENGTH = 128;
-const MAX_KIT_ID_LENGTH = 256;
-const MAX_PRICE_AMOUNT_MICROS = Number.MAX_SAFE_INTEGER;
-
-function kitTextParam(name: string, maxLength?: number) {
- const schema =
- maxLength === undefined ? z.string() : z.string().max(maxLength);
- return schema.refine((value) => value.trim().length > 0, {
- message: `${name} must not be blank`,
- });
-}
-
-const PRODUCT_ID_PARAM = kitTextParam("productId", MAX_KIT_ID_LENGTH);
-const USER_ID_PARAM = kitTextParam("userId", MAX_KIT_ID_LENGTH);
-const TITLE_PARAM = kitTextParam("title");
-const PRICE_AMOUNT_MICROS_PARAM = z
- .number()
- .int()
- .nonnegative()
- .max(MAX_PRICE_AMOUNT_MICROS);
-const API_KEY_PARAM = z
- .string()
- .max(MAX_API_KEY_LENGTH)
- .refine((value) => value.trim().length > 0, {
- message: "apiKey must not be blank",
- })
- .refine((value) => !/\s/.test(value), {
- message: "apiKey must not contain whitespace",
- });
-
-const OPTIONAL_API_KEY = API_KEY_PARAM.optional().describe(
- "Project API key. Defaults to OPENIAP_API_KEY env var. The MCP server is single-project per process — set this once via env when launching from a config.",
-);
-
-function validateApiKey(apiKey: string): string | null {
- if (!apiKey.trim()) return "apiKey must not be blank";
- if (/\s/.test(apiKey)) return "apiKey must not contain whitespace";
- if (apiKey.length > MAX_API_KEY_LENGTH) {
- return `apiKey must be at most ${MAX_API_KEY_LENGTH} characters`;
- }
- return null;
-}
-
-function withClient(opts: { apiKey?: string; baseUrl?: string }) {
- const apiKey = opts.apiKey ?? process.env.OPENIAP_API_KEY;
- if (!apiKey) {
- throw new Error(
- "OPENIAP_API_KEY is not set and `apiKey` was not provided to the tool.",
- );
- }
- const validationError = validateApiKey(apiKey);
- if (validationError) {
- throw new Error(validationError);
- }
- return kitClient({
- apiKey,
- baseUrl: opts.baseUrl ?? process.env.OPENIAP_BASE_URL,
- });
-}
-
-function ok(payload: unknown) {
- return {
- content: [
- {
- type: "text" as const,
- text: JSON.stringify(payload, null, 2),
- },
- ],
- };
-}
-
-function err(error: unknown) {
- const detail =
- error instanceof KitHttpError
- ? { status: error.status, body: error.body, message: error.message }
- : { message: error instanceof Error ? error.message : String(error) };
- return {
- isError: true,
- content: [
- {
- type: "text" as const,
- text: JSON.stringify({ ok: false, error: detail }, null, 2),
- },
- ],
- };
-}
-
-// ---------------------------------------------------------------------------
-// 1. setup — generate per-framework integration snippet.
-// ---------------------------------------------------------------------------
-server.tool(
- "openiap_setup",
- "Print a copy/pasteable openiap integration snippet for a given framework. Does not modify files — emit code for the LLM / human to apply.",
- {
- framework: z
- .enum(["expo", "react-native", "flutter", "kmp", "godot"])
- .describe("Which framework SDK to wire."),
- apiKey: z
- .string()
- .optional()
- .describe(
- "Accepted for compatibility, but never embedded in generated snippets. Configure OPENIAP_API_KEY in the runtime environment instead.",
- ),
- productId: PRODUCT_ID_PARAM.optional().describe(
- "Default productId to seed.",
- ),
- },
- async (args) => {
- const productId = args.productId ?? "com.example.premium_monthly";
- const snippet = renderSetupSnippet(
- args.framework,
- API_KEY_PLACEHOLDER,
- productId,
- );
- return ok({
- framework: args.framework,
- snippet,
- note: "API keys are intentionally left as OPENIAP_API_KEY placeholders so tool output does not leak project credentials.",
- });
- },
-);
-
-// ---------------------------------------------------------------------------
-// 2. check_status — entitlement check for one user.
-// ---------------------------------------------------------------------------
-server.tool(
- "openiap_check_status",
- "Return whether a userId currently has an active subscription, plus the latest subscription record.",
- {
- userId: USER_ID_PARAM,
- apiKey: OPTIONAL_API_KEY,
- baseUrl: OPTIONAL_BASE_URL,
- },
- async (args) => {
- try {
- return ok(await withClient(args).status(args.userId));
- } catch (error) {
- return err(error);
- }
- },
-);
-
-// ---------------------------------------------------------------------------
-// 3. troubleshoot — quick diagnostics.
-// ---------------------------------------------------------------------------
-server.tool(
- "openiap_troubleshoot",
- "Run a fast diagnostic against the configured kit deployment: health probe, sample status query, sample entitlement query.",
- {
- sampleUserId: USER_ID_PARAM
- .optional()
- .describe("If provided, runs status + entitlements for this id."),
- apiKey: OPTIONAL_API_KEY,
- baseUrl: OPTIONAL_BASE_URL,
- },
- async (args) => {
- try {
- const client = withClient(args);
- const [health, metrics] = await Promise.all([
- client.health().catch((e) => ({ error: stringifyError(e) })),
- client.metrics().catch((e) => ({ error: stringifyError(e) })),
- ]);
- // The tool description promises status + entitlement checks. Run
- // both in parallel when a sampleUserId is supplied so diagnostics
- // surface entitlement-specific failures (e.g. webhook-state drift)
- // alongside the basic status probe — running just `status` left
- // those blind.
- const userProbe = args.sampleUserId
- ? await Promise.all([
- client
- .status(args.sampleUserId)
- .catch((e) => ({ error: stringifyError(e) })),
- client
- .entitlements(args.sampleUserId)
- .catch((e) => ({ error: stringifyError(e) })),
- ]).then(([status, entitlements]) => ({ status, entitlements }))
- : null;
- return ok({ health, metrics, userProbe });
- } catch (error) {
- return err(error);
- }
- },
-);
-
-// ---------------------------------------------------------------------------
-// 4. create_product — upsert a product in kit's catalog.
-// ---------------------------------------------------------------------------
-server.tool(
- "openiap_create_product",
- "Add or update a product in kit's local catalog. Note: this creates the kit-side row only — actual App Store Connect / Play Console creation is triggered by `openiap_manage_product` once the project's store credentials are configured.",
- {
- productId: PRODUCT_ID_PARAM,
- platform: z.enum(["IOS", "Android"]),
- type: z.enum(["Subscription", "NonConsumable", "Consumable"]),
- title: TITLE_PARAM,
- description: z.string().optional(),
- priceAmountMicros: PRICE_AMOUNT_MICROS_PARAM.optional(),
- currency: z.string().optional(),
- billingPeriod: z
- .enum(["P1W", "P1M", "P2M", "P3M", "P6M", "P1Y"])
- .optional(),
- subscriptionGroupName: z
- .string()
- .optional()
- .describe(
- "Required for iOS Subscription products. Reuse the same group name for related tiers.",
- ),
- reviewNote: z.string().optional(),
- apiKey: OPTIONAL_API_KEY,
- baseUrl: OPTIONAL_BASE_URL,
- },
- async (args) => {
- try {
- if (
- args.platform === "IOS" &&
- args.type === "Subscription" &&
- !args.subscriptionGroupName?.trim()
- ) {
- return err(
- new Error(
- "subscriptionGroupName is required for iOS Subscription products",
- ),
- );
- }
- return ok(
- await withClient(args).upsertProduct({
- productId: args.productId,
- platform: args.platform,
- type: args.type,
- title: args.title,
- description: args.description,
- priceAmountMicros: args.priceAmountMicros,
- currency: args.currency,
- billingPeriod: args.billingPeriod,
- subscriptionGroupName: args.subscriptionGroupName,
- reviewNote: args.reviewNote,
- }),
- );
- } catch (error) {
- return err(error);
- }
- },
-);
-
-// ---------------------------------------------------------------------------
-// 5. list_products — read kit's product catalog.
-// ---------------------------------------------------------------------------
-server.tool(
- "openiap_list_products",
- "List the project's product catalog stored in kit.",
- {
- platform: z.enum(["IOS", "Android"]).optional(),
- apiKey: OPTIONAL_API_KEY,
- baseUrl: OPTIONAL_BASE_URL,
- },
- async (args) => {
- try {
- return ok(
- await withClient(args).listProducts({ platform: args.platform }),
- );
- } catch (error) {
- return err(error);
- }
- },
-);
-
-// ---------------------------------------------------------------------------
-// 6. view_subscribers — paginated subscription list for the dashboard.
-// ---------------------------------------------------------------------------
-server.tool(
- "openiap_view_subscribers",
- "List subscription rows for the project. Filter by state / productId / userId.",
- {
- state: z
- .enum([
- "Active",
- "InGracePeriod",
- "InBillingRetry",
- "Expired",
- "Revoked",
- "Refunded",
- "Paused",
- "Unknown",
- ])
- .optional(),
- productId: PRODUCT_ID_PARAM.optional(),
- userId: USER_ID_PARAM.optional(),
- limit: z.number().int().min(1).max(200).optional(),
- apiKey: OPTIONAL_API_KEY,
- baseUrl: OPTIONAL_BASE_URL,
- },
- async (args) => {
- try {
- return ok(
- await withClient(args).listSubscriptions({
- state: args.state,
- productId: args.productId,
- userId: args.userId,
- limit: args.limit,
- }),
- );
- } catch (error) {
- return err(error);
- }
- },
-);
-
-// ---------------------------------------------------------------------------
-// 7. simulate_purchase — print sandbox-purchase guidance per platform.
-// ---------------------------------------------------------------------------
-server.tool(
- "openiap_simulate_purchase",
- "Print step-by-step instructions for triggering a sandbox purchase on Apple StoreKit Configuration / Google Play License Tester. Does not call live APIs — sandbox purchases must be initiated from the device itself.",
- {
- productId: PRODUCT_ID_PARAM,
- platform: z.enum(["IOS", "Android"]),
- },
- async (args) => ok({ steps: simulatePurchaseSteps(args) }),
-);
-
-// ---------------------------------------------------------------------------
-// 8. simulate_webhook — POST a synthetic webhook payload to kit.
-// ---------------------------------------------------------------------------
-server.tool(
- "openiap_simulate_webhook",
- "POST a synthetic test notification to kit's webhook endpoint. Android simulation is for local/dev deployments with KIT_ALLOW_UNAUTHENTICATED_PUBSUB=1; production Google RTDN requires Pub/Sub OIDC.",
- {
- platform: z.enum(["IOS", "Android"]),
- apiKey: OPTIONAL_API_KEY,
- baseUrl: OPTIONAL_BASE_URL,
- },
- async (args) => {
- const apiKey = args.apiKey ?? process.env.OPENIAP_API_KEY;
- if (!apiKey) return err(new Error("apiKey required"));
- const validationError = validateApiKey(apiKey);
- if (validationError) return err(new Error(validationError));
- let baseUrl: string;
- try {
- baseUrl = normalizeKitBaseUrl(
- args.baseUrl ?? process.env.OPENIAP_BASE_URL,
- );
- } catch (error) {
- return err(error);
- }
- if (args.platform === "Android") {
- const message = {
- version: "1.0",
- packageName: "com.example.app",
- eventTimeMillis: Date.now(),
- testNotification: { version: "1.0" },
- };
- const data = Buffer.from(JSON.stringify(message)).toString("base64");
- const body = {
- message: {
- data,
- messageId: `test-${Date.now()}`,
- publishTime: new Date().toISOString(),
- },
- subscription: "projects/local/subscriptions/openiap-test",
- };
- try {
- const response = await fetch(
- `${baseUrl}/v1/webhooks/${encodeURIComponent(apiKey)}`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- const responseBody = await response.text();
- if (!response.ok) {
- return err(
- new KitHttpError(
- response.status,
- responseBody,
- `kit /v1/webhooks/${API_KEY_PLACEHOLDER} returned ${response.status}`,
- ),
- );
- }
- return ok({ status: response.status, body: responseBody });
- } catch (error) {
- return err(error);
- }
- }
- return ok({
- info: "Apple ASN v2 simulation requires a real signed payload from App Store Connect Sandbox. Use App Store Connect → App Store Server Notifications → Send Test Notification, configured to POST to /v1/webhooks/{apiKey}.",
- });
- },
-);
-
-// ---------------------------------------------------------------------------
-// 9. inspect_state — high-level dashboard summary in one tool call.
-// ---------------------------------------------------------------------------
-server.tool(
- "openiap_inspect_state",
- "Return a dashboard-style summary: metrics, product catalog, configured webhooks endpoint URLs.",
- {
- apiKey: OPTIONAL_API_KEY,
- baseUrl: OPTIONAL_BASE_URL,
- },
- async (args) => {
- try {
- const client = withClient(args);
- const [metrics, products] = await Promise.all([
- client.metrics().catch((e) => ({ error: stringifyError(e) })),
- client.listProducts().catch((e) => ({ error: stringifyError(e) })),
- ]);
- return ok({
- metrics,
- products,
- webhookUrls: {
- lifecycle: `${client.baseUrl}/v1/webhooks/${API_KEY_PLACEHOLDER}`,
- stream: `${client.baseUrl}/v1/webhooks/stream/${API_KEY_PLACEHOLDER}`,
- legacyAliases: {
- apple: `${client.baseUrl}/v1/webhooks/apple/${API_KEY_PLACEHOLDER}`,
- google: `${client.baseUrl}/v1/webhooks/google/${API_KEY_PLACEHOLDER}`,
- },
- },
- note: "Use webhookUrls.lifecycle for both Apple ASN v2 and Google Pub/Sub RTDN. Legacy aliases remain supported for existing store-console wiring. URLs use an OPENIAP_API_KEY placeholder so tool output does not leak project credentials.",
- });
- } catch (error) {
- return err(error);
- }
- },
-);
-
-// ---------------------------------------------------------------------------
-// 10. manage_product — disable / refresh a product entry.
-// ---------------------------------------------------------------------------
-server.tool(
- "openiap_manage_product",
- "Update or remove a product in kit's catalog. `action: 'remove'` soft-removes via the product state endpoint.",
- {
- productId: PRODUCT_ID_PARAM,
- platform: z.enum(["IOS", "Android"]),
- action: z.enum(["disable", "enable", "remove"]),
- apiKey: OPTIONAL_API_KEY,
- baseUrl: OPTIONAL_BASE_URL,
- },
- async (args) => {
- try {
- const client = withClient(args);
- // Map the action onto the state-only endpoint. Using
- // `setProductState` instead of `upsertProduct` here avoids the
- // prior bug where calling upsertProduct with a hardcoded
- // `type: "Subscription"` + blank `title` would silently clobber
- // the existing row's product type and (depending on
- // upsertProduct's branch) its title.
- const stateMap = {
- disable: "Removed" as const,
- enable: "Active" as const,
- remove: "Removed" as const,
- };
- const next = await client.setProductState({
- productId: args.productId,
- platform: args.platform,
- state: stateMap[args.action],
- });
- return ok({ ...next, action: args.action });
- } catch (error) {
- return err(error);
- }
- },
-);
-
-function renderSetupSnippet(
- framework: "expo" | "react-native" | "flutter" | "kmp" | "godot",
- apiKey: string,
- productId: string,
-): string {
- const apiKeyLiteral = codeStringLiteral(apiKey);
- const productIdLiteral = codeStringLiteral(productId);
-
- switch (framework) {
- case "expo":
- case "react-native":
- return `import { useIAP, useWebhookEvents } from '${framework === "expo" ? "expo-iap" : "react-native-iap"}';
-import EventSource from 'react-native-sse';
-
-const { events } = useWebhookEvents({
- apiKey: ${apiKeyLiteral},
- eventSourceFactory: (url) => new EventSource(url),
- onEvent: (event) => {
- if (event.type === 'SubscriptionRenewed') grantEntitlement(event.purchaseToken);
- },
-});
-
-const { fetchProducts, requestPurchase } = useIAP({ skus: [${productIdLiteral}] });`;
- case "flutter":
- return `import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart';
-import 'package:flutter_inapp_purchase/webhook_client.dart';
-
-final listener = connectWebhookStream(apiKey: ${apiKeyLiteral});
-listener.events.listen((event) {
- if (event.type == WebhookEventType.SubscriptionRenewed) {
- grantEntitlement(event.purchaseToken);
- }
-});
-await FlutterInappPurchase.instance.requestPurchase(productId: ${productIdLiteral});`;
- case "kmp":
- return `import io.github.hyochan.kmpiap.openiap.WebhookEventParser
-import io.github.hyochan.kmpiap.openiap.WebhookEventType
-
-// Parse SSE frames from \`webhookStreamUrl(apiKey = ${apiKeyLiteral})\`
-// in your platform's HTTP client and feed each data frame to:
-val event = WebhookEventParser.parse(rawJson) ?: return
-when (event.type) {
- WebhookEventType.SubscriptionRenewed -> grantEntitlement(event.purchaseToken)
- else -> Unit
-}`;
- case "godot":
- return `extends Node
-
-@onready var webhook := preload("res://addons/godot-iap/webhook_client.gd").new()
-
-func _ready() -> void:
- webhook.api_key = ${apiKeyLiteral}
- webhook.event_received.connect(func(event):
- if event["type"] == "SubscriptionRenewed":
- grant_entitlement(event["purchaseToken"])
- )
- add_child(webhook)
- webhook.connect_stream()
- GodotIap.request_purchase(${productIdLiteral})`;
- }
-}
-
-function codeStringLiteral(value: string): string {
- return JSON.stringify(value);
-}
-
-function simulatePurchaseSteps(args: {
- productId: string;
- platform: "IOS" | "Android";
-}): string[] {
- if (args.platform === "IOS") {
- return [
- "Open the host app's Xcode scheme.",
- "Set Run > Options > StoreKit Configuration to a .storekit file containing the product.",
- `Run on Simulator and trigger the in-app purchase for ${args.productId}.`,
- "On purchase complete, kit's verifyReceipt route ingests the JWS; the matching ASN v2 TEST notification can be triggered from App Store Connect → App Store Server Notifications → Send Test Notification.",
- ];
- }
- return [
- "Open Google Play Console → Setup → License testing.",
- "Add your tester Google account.",
- `Sideload the host app and trigger the in-app purchase for ${args.productId} signed-in as the tester.`,
- "Pub/Sub will deliver an RTDN to /v1/webhooks/{apiKey} once the configured topic + subscription are wired.",
- ];
-}
-
-function stringifyError(e: unknown): string {
- if (e instanceof Error) return e.message;
- return String(e);
-}
-
const transport = new StdioServerTransport();
+
await server.connect(transport);
diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts
new file mode 100644
index 00000000..453e4199
--- /dev/null
+++ b/packages/mcp-server/src/mcp.ts
@@ -0,0 +1,744 @@
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
+import type {
+ ServerNotification,
+ ServerRequest,
+ ToolAnnotations,
+} from "@modelcontextprotocol/sdk/types.js";
+import { z } from "zod";
+
+import { kitClient, KitHttpError, normalizeKitBaseUrl } from "./kit-client.js";
+
+// 10-tool MCP server for IAPKit. Every tool funnels through `withClient`
+// so Authorization bearer / IAPKIT_API_KEY / OPENIAP_API_KEY config is
+// consistent and errors surface in a uniform `{ ok: false, error }` shape
+// that LLMs handle predictably.
+
+export const IAPKIT_MCP_SERVER_NAME = "iapkit-mcp";
+export const IAPKIT_MCP_SERVER_VERSION = "0.1.0";
+
+export interface IapKitMcpServerOptions {
+ toolNamePrefix?: "iapkit" | "openiap";
+ includeLegacyOpenIapAliases?: boolean;
+}
+
+type ToolExtra = RequestHandlerExtra;
+
+const OPTIONAL_BASE_URL = z
+ .string()
+ .url()
+ .optional()
+ .describe(
+ "Override IAPKit base URL. Defaults to IAPKIT_BASE_URL, then OPENIAP_BASE_URL, then https://kit.openiap.dev.",
+ );
+
+const API_KEY_PLACEHOLDER = "";
+const MAX_API_KEY_LENGTH = 128;
+const MAX_KIT_ID_LENGTH = 256;
+const MAX_PRICE_AMOUNT_MICROS = Number.MAX_SAFE_INTEGER;
+const READ_ONLY_TOOL: ToolAnnotations = {
+ readOnlyHint: true,
+ destructiveHint: false,
+ openWorldHint: true,
+};
+const WRITE_TOOL: ToolAnnotations = {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: false,
+ openWorldHint: true,
+};
+
+function kitTextParam(name: string, maxLength?: number) {
+ const schema =
+ maxLength === undefined ? z.string() : z.string().max(maxLength);
+ return schema.refine((value) => value.trim().length > 0, {
+ message: `${name} must not be blank`,
+ });
+}
+
+const PRODUCT_ID_PARAM = kitTextParam("productId", MAX_KIT_ID_LENGTH);
+const USER_ID_PARAM = kitTextParam("userId", MAX_KIT_ID_LENGTH);
+const TITLE_PARAM = kitTextParam("title");
+const PRICE_AMOUNT_MICROS_PARAM = z
+ .number()
+ .int()
+ .nonnegative()
+ .max(MAX_PRICE_AMOUNT_MICROS);
+const API_KEY_PARAM = z
+ .string()
+ .max(MAX_API_KEY_LENGTH)
+ .refine((value) => value.trim().length > 0, {
+ message: "apiKey must not be blank",
+ })
+ .refine((value) => !/\s/.test(value), {
+ message: "apiKey must not contain whitespace",
+ });
+
+const OPTIONAL_API_KEY = API_KEY_PARAM.optional().describe(
+ "IAPKit project API key. Defaults to the MCP Authorization bearer token, then IAPKIT_API_KEY, then OPENIAP_API_KEY.",
+);
+
+function validateApiKey(apiKey: string): string | null {
+ if (!apiKey.trim()) return "apiKey must not be blank";
+ if (/\s/.test(apiKey)) return "apiKey must not contain whitespace";
+ if (apiKey.length > MAX_API_KEY_LENGTH) {
+ return `apiKey must be at most ${MAX_API_KEY_LENGTH} characters`;
+ }
+ return null;
+}
+
+function withClient(
+ opts: { apiKey?: string; baseUrl?: string },
+ extra?: ToolExtra,
+) {
+ const apiKey =
+ opts.apiKey ??
+ extra?.authInfo?.token ??
+ process.env.IAPKIT_API_KEY ??
+ process.env.OPENIAP_API_KEY;
+ if (!apiKey) {
+ throw new Error(
+ "No IAPKit API key was provided. Set Authorization: Bearer , IAPKIT_API_KEY, OPENIAP_API_KEY, or the tool's apiKey argument.",
+ );
+ }
+ const validationError = validateApiKey(apiKey);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+ return kitClient({
+ apiKey,
+ baseUrl:
+ opts.baseUrl ??
+ process.env.IAPKIT_BASE_URL ??
+ process.env.OPENIAP_BASE_URL,
+ });
+}
+
+function ok(payload: unknown) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: JSON.stringify(payload, null, 2),
+ },
+ ],
+ };
+}
+
+function err(error: unknown) {
+ const detail =
+ error instanceof KitHttpError
+ ? {
+ status: error.status,
+ body: redactSecrets(error.body),
+ message: redactSecrets(error.message),
+ }
+ : {
+ message: redactSecrets(
+ error instanceof Error ? error.message : String(error),
+ ),
+ };
+ return {
+ isError: true,
+ content: [
+ {
+ type: "text" as const,
+ text: JSON.stringify({ ok: false, error: detail }, null, 2),
+ },
+ ],
+ };
+}
+
+function redactSecrets(value: unknown): unknown {
+ if (typeof value === "string") {
+ return redactSecretString(value);
+ }
+ if (Array.isArray(value)) {
+ return value.map((item) => redactSecrets(item));
+ }
+ if (value && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value).map(([key, item]) => [key, redactSecrets(item)]),
+ );
+ }
+ return value;
+}
+
+function redactSecretString(value: string): string {
+ const knownSecrets = [
+ process.env.IAPKIT_API_KEY,
+ process.env.OPENIAP_API_KEY,
+ ].filter((secret): secret is string => Boolean(secret));
+ let redacted = value;
+ for (const secret of knownSecrets) {
+ redacted = redacted.split(secret).join(API_KEY_PLACEHOLDER);
+ }
+ return redacted
+ .replace(
+ /(\/v1\/(?:subscriptions\/(?:status|entitlements|list|metrics)|products|webhooks(?:\/stream)?|webhooks\/(?:apple|google))\/)[^/?\s"]+/g,
+ `$1${API_KEY_PLACEHOLDER}`,
+ )
+ .replace(
+ /(Authorization:\s*Bearer\s+)[^\s"]+/gi,
+ `$1${API_KEY_PLACEHOLDER}`,
+ );
+}
+
+function registerTool(
+ server: McpServer,
+ options: Required,
+ localName: string,
+ description: string,
+ schema: Record,
+ annotations: ToolAnnotations,
+ handler: (args: any, extra: ToolExtra) => unknown,
+) {
+ server.tool(
+ `${options.toolNamePrefix}_${localName}`,
+ description,
+ schema,
+ annotations,
+ handler as any,
+ );
+ if (
+ options.includeLegacyOpenIapAliases &&
+ options.toolNamePrefix !== "openiap"
+ ) {
+ server.tool(
+ `openiap_${localName}`,
+ description,
+ schema,
+ annotations,
+ handler as any,
+ );
+ }
+}
+
+export function createIapKitMcpServer(
+ options: IapKitMcpServerOptions = {},
+): McpServer {
+ const resolvedOptions: Required = {
+ toolNamePrefix: options.toolNamePrefix ?? "iapkit",
+ includeLegacyOpenIapAliases: options.includeLegacyOpenIapAliases ?? false,
+ };
+ const server = new McpServer({
+ name: IAPKIT_MCP_SERVER_NAME,
+ version: IAPKIT_MCP_SERVER_VERSION,
+ websiteUrl: "https://kit.openiap.dev",
+ });
+ registerIapKitTools(server, resolvedOptions);
+ return server;
+}
+
+function registerIapKitTools(
+ server: McpServer,
+ options: Required,
+) {
+ // ---------------------------------------------------------------------------
+ // 1. setup — generate per-framework integration snippet.
+ // ---------------------------------------------------------------------------
+ registerTool(
+ server,
+ options,
+ "setup",
+ "Print a copy/pasteable IAPKit integration snippet for a given framework. Does not modify files — emit code for the LLM / human to apply.",
+ {
+ framework: z
+ .enum(["expo", "react-native", "flutter", "kmp", "godot"])
+ .describe("Which framework SDK to wire."),
+ apiKey: z
+ .string()
+ .optional()
+ .describe(
+ "Accepted for compatibility, but never embedded in generated snippets. Configure IAPKIT_API_KEY or the MCP Authorization bearer token instead.",
+ ),
+ productId: PRODUCT_ID_PARAM.optional().describe(
+ "Default productId to seed.",
+ ),
+ },
+ READ_ONLY_TOOL,
+ async (args) => {
+ const productId = args.productId ?? "com.example.premium_monthly";
+ const snippet = renderSetupSnippet(
+ args.framework,
+ API_KEY_PLACEHOLDER,
+ productId,
+ );
+ return ok({
+ framework: args.framework,
+ snippet,
+ note: "API keys are intentionally left as IAPKIT_API_KEY placeholders so tool output does not leak project credentials.",
+ });
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+ // 2. check_status — entitlement check for one user.
+ // ---------------------------------------------------------------------------
+ registerTool(
+ server,
+ options,
+ "check_status",
+ "Return whether a userId currently has an active subscription, plus the latest subscription record.",
+ {
+ userId: USER_ID_PARAM,
+ apiKey: OPTIONAL_API_KEY,
+ baseUrl: OPTIONAL_BASE_URL,
+ },
+ READ_ONLY_TOOL,
+ async (args, extra) => {
+ try {
+ return ok(await withClient(args, extra).status(args.userId));
+ } catch (error) {
+ return err(error);
+ }
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+ // 3. troubleshoot — quick diagnostics.
+ // ---------------------------------------------------------------------------
+ registerTool(
+ server,
+ options,
+ "troubleshoot",
+ "Run a fast diagnostic against the configured kit deployment: health probe, sample status query, sample entitlement query.",
+ {
+ sampleUserId: USER_ID_PARAM.optional().describe(
+ "If provided, runs status + entitlements for this id.",
+ ),
+ apiKey: OPTIONAL_API_KEY,
+ baseUrl: OPTIONAL_BASE_URL,
+ },
+ READ_ONLY_TOOL,
+ async (args, extra) => {
+ try {
+ const client = withClient(args, extra);
+ const [health, metrics] = await Promise.all([
+ client.health().catch((e) => ({ error: stringifyError(e) })),
+ client.metrics().catch((e) => ({ error: stringifyError(e) })),
+ ]);
+ // The tool description promises status + entitlement checks. Run
+ // both in parallel when a sampleUserId is supplied so diagnostics
+ // surface entitlement-specific failures (e.g. webhook-state drift)
+ // alongside the basic status probe — running just `status` left
+ // those blind.
+ const userProbe = args.sampleUserId
+ ? await Promise.all([
+ client
+ .status(args.sampleUserId)
+ .catch((e) => ({ error: stringifyError(e) })),
+ client
+ .entitlements(args.sampleUserId)
+ .catch((e) => ({ error: stringifyError(e) })),
+ ]).then(([status, entitlements]) => ({ status, entitlements }))
+ : null;
+ return ok({ health, metrics, userProbe });
+ } catch (error) {
+ return err(error);
+ }
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+ // 4. create_product — upsert a product in kit's catalog.
+ // ---------------------------------------------------------------------------
+ registerTool(
+ server,
+ options,
+ "create_product",
+ "Add or update a product in IAPKit's local catalog. Note: this creates the IAPKit-side row only — actual App Store Connect / Play Console creation is triggered by `iapkit_manage_product` once the project's store credentials are configured.",
+ {
+ productId: PRODUCT_ID_PARAM,
+ platform: z.enum(["IOS", "Android"]),
+ type: z.enum(["Subscription", "NonConsumable", "Consumable"]),
+ title: TITLE_PARAM,
+ description: z.string().optional(),
+ priceAmountMicros: PRICE_AMOUNT_MICROS_PARAM.optional(),
+ currency: z.string().optional(),
+ billingPeriod: z
+ .enum(["P1W", "P1M", "P2M", "P3M", "P6M", "P1Y"])
+ .optional(),
+ subscriptionGroupName: z
+ .string()
+ .optional()
+ .describe(
+ "Required for iOS Subscription products. Reuse the same group name for related tiers.",
+ ),
+ reviewNote: z.string().optional(),
+ apiKey: OPTIONAL_API_KEY,
+ baseUrl: OPTIONAL_BASE_URL,
+ },
+ WRITE_TOOL,
+ async (args, extra) => {
+ try {
+ if (
+ args.platform === "IOS" &&
+ args.type === "Subscription" &&
+ !args.subscriptionGroupName?.trim()
+ ) {
+ return err(
+ new Error(
+ "subscriptionGroupName is required for iOS Subscription products",
+ ),
+ );
+ }
+ return ok(
+ await withClient(args, extra).upsertProduct({
+ productId: args.productId,
+ platform: args.platform,
+ type: args.type,
+ title: args.title,
+ description: args.description,
+ priceAmountMicros: args.priceAmountMicros,
+ currency: args.currency,
+ billingPeriod: args.billingPeriod,
+ subscriptionGroupName: args.subscriptionGroupName,
+ reviewNote: args.reviewNote,
+ }),
+ );
+ } catch (error) {
+ return err(error);
+ }
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+ // 5. list_products — read kit's product catalog.
+ // ---------------------------------------------------------------------------
+ registerTool(
+ server,
+ options,
+ "list_products",
+ "List the project's product catalog stored in IAPKit.",
+ {
+ platform: z.enum(["IOS", "Android"]).optional(),
+ apiKey: OPTIONAL_API_KEY,
+ baseUrl: OPTIONAL_BASE_URL,
+ },
+ READ_ONLY_TOOL,
+ async (args, extra) => {
+ try {
+ return ok(
+ await withClient(args, extra).listProducts({
+ platform: args.platform,
+ }),
+ );
+ } catch (error) {
+ return err(error);
+ }
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+ // 6. view_subscribers — paginated subscription list for the dashboard.
+ // ---------------------------------------------------------------------------
+ registerTool(
+ server,
+ options,
+ "view_subscribers",
+ "List subscription rows for the project. Filter by state / productId / userId.",
+ {
+ state: z
+ .enum([
+ "Active",
+ "InGracePeriod",
+ "InBillingRetry",
+ "Expired",
+ "Revoked",
+ "Refunded",
+ "Paused",
+ "Unknown",
+ ])
+ .optional(),
+ productId: PRODUCT_ID_PARAM.optional(),
+ userId: USER_ID_PARAM.optional(),
+ limit: z.number().int().min(1).max(200).optional(),
+ apiKey: OPTIONAL_API_KEY,
+ baseUrl: OPTIONAL_BASE_URL,
+ },
+ READ_ONLY_TOOL,
+ async (args, extra) => {
+ try {
+ return ok(
+ await withClient(args, extra).listSubscriptions({
+ state: args.state,
+ productId: args.productId,
+ userId: args.userId,
+ limit: args.limit,
+ }),
+ );
+ } catch (error) {
+ return err(error);
+ }
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+ // 7. simulate_purchase — print sandbox-purchase guidance per platform.
+ // ---------------------------------------------------------------------------
+ registerTool(
+ server,
+ options,
+ "simulate_purchase",
+ "Print step-by-step instructions for triggering a sandbox purchase on Apple StoreKit Configuration / Google Play License Tester. Does not call live APIs — sandbox purchases must be initiated from the device itself.",
+ {
+ productId: PRODUCT_ID_PARAM,
+ platform: z.enum(["IOS", "Android"]),
+ },
+ READ_ONLY_TOOL,
+ async (args) => ok({ steps: simulatePurchaseSteps(args) }),
+ );
+
+ // ---------------------------------------------------------------------------
+ // 8. simulate_webhook — POST a synthetic webhook payload to kit.
+ // ---------------------------------------------------------------------------
+ registerTool(
+ server,
+ options,
+ "simulate_webhook",
+ "POST a synthetic test notification to kit's webhook endpoint. Android simulation is for local/dev deployments with KIT_ALLOW_UNAUTHENTICATED_PUBSUB=1; production Google RTDN requires Pub/Sub OIDC.",
+ {
+ platform: z.enum(["IOS", "Android"]),
+ apiKey: OPTIONAL_API_KEY,
+ baseUrl: OPTIONAL_BASE_URL,
+ },
+ WRITE_TOOL,
+ async (args, extra) => {
+ const apiKey =
+ args.apiKey ??
+ extra?.authInfo?.token ??
+ process.env.IAPKIT_API_KEY ??
+ process.env.OPENIAP_API_KEY;
+ if (!apiKey) return err(new Error("apiKey required"));
+ const validationError = validateApiKey(apiKey);
+ if (validationError) return err(new Error(validationError));
+ let baseUrl: string;
+ try {
+ baseUrl = normalizeKitBaseUrl(
+ args.baseUrl ??
+ process.env.IAPKIT_BASE_URL ??
+ process.env.OPENIAP_BASE_URL,
+ );
+ } catch (error) {
+ return err(error);
+ }
+ if (args.platform === "Android") {
+ const message = {
+ version: "1.0",
+ packageName: "com.example.app",
+ eventTimeMillis: Date.now(),
+ testNotification: { version: "1.0" },
+ };
+ const data = Buffer.from(JSON.stringify(message)).toString("base64");
+ const body = {
+ message: {
+ data,
+ messageId: `test-${Date.now()}`,
+ publishTime: new Date().toISOString(),
+ },
+ subscription: "projects/local/subscriptions/openiap-test",
+ };
+ try {
+ const response = await fetch(
+ `${baseUrl}/v1/webhooks/${encodeURIComponent(apiKey)}`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ const responseBody = await response.text();
+ if (!response.ok) {
+ return err(
+ new KitHttpError(
+ response.status,
+ responseBody,
+ `kit /v1/webhooks/${API_KEY_PLACEHOLDER} returned ${response.status}`,
+ ),
+ );
+ }
+ return ok({ status: response.status, body: responseBody });
+ } catch (error) {
+ return err(error);
+ }
+ }
+ return ok({
+ info: "Apple ASN v2 simulation requires a real signed payload from App Store Connect Sandbox. Use App Store Connect → App Store Server Notifications → Send Test Notification, configured to POST to /v1/webhooks/{apiKey}.",
+ });
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+ // 9. inspect_state — high-level dashboard summary in one tool call.
+ // ---------------------------------------------------------------------------
+ registerTool(
+ server,
+ options,
+ "inspect_state",
+ "Return a dashboard-style summary: metrics, product catalog, configured webhooks endpoint URLs.",
+ {
+ apiKey: OPTIONAL_API_KEY,
+ baseUrl: OPTIONAL_BASE_URL,
+ },
+ READ_ONLY_TOOL,
+ async (args, extra) => {
+ try {
+ const client = withClient(args, extra);
+ const [metrics, products] = await Promise.all([
+ client.metrics().catch((e) => ({ error: stringifyError(e) })),
+ client.listProducts().catch((e) => ({ error: stringifyError(e) })),
+ ]);
+ return ok({
+ metrics,
+ products,
+ webhookUrls: {
+ lifecycle: `${client.baseUrl}/v1/webhooks/${API_KEY_PLACEHOLDER}`,
+ stream: `${client.baseUrl}/v1/webhooks/stream/${API_KEY_PLACEHOLDER}`,
+ legacyAliases: {
+ apple: `${client.baseUrl}/v1/webhooks/apple/${API_KEY_PLACEHOLDER}`,
+ google: `${client.baseUrl}/v1/webhooks/google/${API_KEY_PLACEHOLDER}`,
+ },
+ },
+ note: "Use webhookUrls.lifecycle for both Apple ASN v2 and Google Pub/Sub RTDN. Legacy aliases remain supported for existing store-console wiring. URLs use an IAPKIT_API_KEY placeholder so tool output does not leak project credentials.",
+ });
+ } catch (error) {
+ return err(error);
+ }
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+ // 10. manage_product — disable / refresh a product entry.
+ // ---------------------------------------------------------------------------
+ registerTool(
+ server,
+ options,
+ "manage_product",
+ "Update or remove a product in IAPKit's catalog. `action: 'remove'` soft-removes via the product state endpoint.",
+ {
+ productId: PRODUCT_ID_PARAM,
+ platform: z.enum(["IOS", "Android"]),
+ action: z.enum(["disable", "enable", "remove"]),
+ apiKey: OPTIONAL_API_KEY,
+ baseUrl: OPTIONAL_BASE_URL,
+ },
+ WRITE_TOOL,
+ async (args, extra) => {
+ try {
+ const client = withClient(args, extra);
+ // Map the action onto the state-only endpoint. Using
+ // `setProductState` instead of `upsertProduct` here avoids the
+ // prior bug where calling upsertProduct with a hardcoded
+ // `type: "Subscription"` + blank `title` would silently clobber
+ // the existing row's product type and (depending on
+ // upsertProduct's branch) its title.
+ const stateMap = {
+ disable: "Removed" as const,
+ enable: "Active" as const,
+ remove: "Removed" as const,
+ };
+ const action = args.action as keyof typeof stateMap;
+ const next = await client.setProductState({
+ productId: args.productId,
+ platform: args.platform,
+ state: stateMap[action],
+ });
+ return ok({ ...next, action: args.action });
+ } catch (error) {
+ return err(error);
+ }
+ },
+ );
+}
+
+function renderSetupSnippet(
+ framework: "expo" | "react-native" | "flutter" | "kmp" | "godot",
+ apiKey: string,
+ productId: string,
+): string {
+ const apiKeyLiteral = codeStringLiteral(apiKey);
+ const productIdLiteral = codeStringLiteral(productId);
+
+ switch (framework) {
+ case "expo":
+ case "react-native":
+ return `import { useIAP, useWebhookEvents } from '${framework === "expo" ? "expo-iap" : "react-native-iap"}';
+import EventSource from 'react-native-sse';
+
+const { events } = useWebhookEvents({
+ apiKey: ${apiKeyLiteral},
+ eventSourceFactory: (url) => new EventSource(url),
+ onEvent: (event) => {
+ if (event.type === 'SubscriptionRenewed') grantEntitlement(event.purchaseToken);
+ },
+});
+
+const { fetchProducts, requestPurchase } = useIAP({ skus: [${productIdLiteral}] });`;
+ case "flutter":
+ return `import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart';
+import 'package:flutter_inapp_purchase/webhook_client.dart';
+
+final listener = connectWebhookStream(apiKey: ${apiKeyLiteral});
+listener.events.listen((event) {
+ if (event.type == WebhookEventType.SubscriptionRenewed) {
+ grantEntitlement(event.purchaseToken);
+ }
+});
+await FlutterInappPurchase.instance.requestPurchase(productId: ${productIdLiteral});`;
+ case "kmp":
+ return `import io.github.hyochan.kmpiap.openiap.WebhookEventParser
+import io.github.hyochan.kmpiap.openiap.WebhookEventType
+
+// Parse SSE frames from \`webhookStreamUrl(apiKey = ${apiKeyLiteral})\`
+// in your platform's HTTP client and feed each data frame to:
+val event = WebhookEventParser.parse(rawJson) ?: return
+when (event.type) {
+ WebhookEventType.SubscriptionRenewed -> grantEntitlement(event.purchaseToken)
+ else -> Unit
+}`;
+ case "godot":
+ return `extends Node
+
+@onready var webhook := preload("res://addons/godot-iap/webhook_client.gd").new()
+
+func _ready() -> void:
+ webhook.api_key = ${apiKeyLiteral}
+ webhook.event_received.connect(func(event):
+ if event["type"] == "SubscriptionRenewed":
+ grant_entitlement(event["purchaseToken"])
+ )
+ add_child(webhook)
+ webhook.connect_stream()
+ GodotIap.request_purchase(${productIdLiteral})`;
+ }
+}
+
+function codeStringLiteral(value: string): string {
+ return JSON.stringify(value);
+}
+
+function simulatePurchaseSteps(args: {
+ productId: string;
+ platform: "IOS" | "Android";
+}): string[] {
+ if (args.platform === "IOS") {
+ return [
+ "Open the host app's Xcode scheme.",
+ "Set Run > Options > StoreKit Configuration to a .storekit file containing the product.",
+ `Run on Simulator and trigger the in-app purchase for ${args.productId}.`,
+ "On purchase complete, kit's verifyReceipt route ingests the JWS; the matching ASN v2 TEST notification can be triggered from App Store Connect → App Store Server Notifications → Send Test Notification.",
+ ];
+ }
+ return [
+ "Open Google Play Console → Setup → License testing.",
+ "Add your tester Google account.",
+ `Sideload the host app and trigger the in-app purchase for ${args.productId} signed-in as the tester.`,
+ "Pub/Sub will deliver an RTDN to /v1/webhooks/{apiKey} once the configured topic + subscription are wired.",
+ ];
+}
+
+function stringifyError(e: unknown): string {
+ if (e instanceof Error) return e.message;
+ return String(e);
+}
diff --git a/packages/mcp-server/src/web.ts b/packages/mcp-server/src/web.ts
new file mode 100644
index 00000000..ddbe76cf
--- /dev/null
+++ b/packages/mcp-server/src/web.ts
@@ -0,0 +1,240 @@
+import { randomUUID } from "node:crypto";
+
+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 { createIapKitMcpServer } from "./mcp.js";
+
+const MAX_MCP_BODY_BYTES = 1024 * 1024;
+const DEFAULT_ALLOWED_ORIGINS = [
+ "https://chatgpt.com",
+ "https://chat.openai.com",
+ "http://localhost:3000",
+ "http://localhost:5173",
+ "http://127.0.0.1:3000",
+ "http://127.0.0.1:5173",
+];
+
+export interface IapKitWebMcpHandlerOptions {
+ allowedOrigins?: string[];
+ includeLegacyOpenIapAliases?: boolean;
+ logger?: Pick;
+}
+
+export function createIapKitWebMcpHandler(
+ options: IapKitWebMcpHandlerOptions = {},
+): (request: Request) => Promise {
+ const logger = options.logger ?? console;
+ const allowedOrigins =
+ options.allowedOrigins ??
+ parseAllowedOrigins(process.env.IAPKIT_MCP_ALLOWED_ORIGINS);
+ const transports = new Map<
+ string,
+ WebStandardStreamableHTTPServerTransport
+ >();
+
+ return async function handleIapKitMcpRequest(
+ request: Request,
+ ): Promise {
+ try {
+ if (request.method === "OPTIONS") {
+ return withCors(
+ request,
+ new Response(null, { status: 204 }),
+ allowedOrigins,
+ );
+ }
+
+ const authInfo = authInfoFromRequest(request);
+
+ if (request.method === "POST") {
+ const response = await handlePost(
+ request,
+ transports,
+ logger,
+ Boolean(options.includeLegacyOpenIapAliases),
+ authInfo,
+ );
+ return withCors(request, response, allowedOrigins);
+ }
+
+ if (request.method === "GET" || request.method === "DELETE") {
+ const response = await handleExistingSession(
+ request,
+ transports,
+ authInfo,
+ );
+ return withCors(request, response, allowedOrigins);
+ }
+
+ return withCors(
+ request,
+ jsonRpcError(405, -32000, "Method not allowed"),
+ allowedOrigins,
+ );
+ } catch (error) {
+ logger.error("IAPKit MCP request failed:", error);
+ return withCors(
+ request,
+ jsonRpcError(500, -32603, "Internal server error"),
+ allowedOrigins,
+ );
+ }
+ };
+}
+
+async function handlePost(
+ request: Request,
+ transports: Map,
+ logger: Pick,
+ includeLegacyOpenIapAliases: boolean,
+ authInfo: AuthInfo | undefined,
+): Promise {
+ const sessionId = request.headers.get("mcp-session-id") ?? undefined;
+ const body = await readJsonBody(request);
+ const existingTransport = sessionId ? transports.get(sessionId) : undefined;
+
+ if (existingTransport) {
+ return existingTransport.handleRequest(request, {
+ parsedBody: body,
+ authInfo,
+ });
+ }
+
+ if (sessionId || !isInitializeRequest(body)) {
+ return jsonRpcError(
+ 400,
+ -32000,
+ "Bad Request: initialize first, then send mcp-session-id on follow-up requests.",
+ );
+ }
+
+ let transport!: WebStandardStreamableHTTPServerTransport;
+ transport = new WebStandardStreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ onsessioninitialized: (initializedSessionId) => {
+ transports.set(initializedSessionId, transport);
+ logger.info(`IAPKit MCP session initialized: ${initializedSessionId}`);
+ },
+ onsessionclosed: (closedSessionId) => {
+ transports.delete(closedSessionId);
+ logger.info(`IAPKit MCP session closed: ${closedSessionId}`);
+ },
+ });
+
+ transport.onclose = () => {
+ const initializedSessionId = transport.sessionId;
+ if (initializedSessionId) transports.delete(initializedSessionId);
+ };
+
+ const server = createIapKitMcpServer({
+ includeLegacyOpenIapAliases,
+ });
+ await server.connect(transport);
+ return transport.handleRequest(request, {
+ parsedBody: body,
+ authInfo,
+ });
+}
+
+async function handleExistingSession(
+ request: Request,
+ transports: Map,
+ authInfo: AuthInfo | undefined,
+): Promise {
+ const sessionId = request.headers.get("mcp-session-id") ?? undefined;
+ const transport = sessionId ? transports.get(sessionId) : undefined;
+
+ if (!transport) {
+ return jsonRpcError(400, -32000, "Invalid or missing mcp-session-id");
+ }
+
+ return transport.handleRequest(request, { authInfo });
+}
+
+function authInfoFromRequest(request: Request): AuthInfo | undefined {
+ const token = parseBearerToken(request.headers.get("authorization"));
+ if (!token) return undefined;
+
+ return {
+ token,
+ clientId: "iapkit-project-api-key",
+ scopes: ["iapkit:project"],
+ };
+}
+
+function parseBearerToken(authorization: string | null): string | null {
+ if (!authorization) return null;
+ const match = authorization.match(/^Bearer\s+(.+)$/i);
+ const token = match?.[1]?.trim();
+ return token || null;
+}
+
+async function readJsonBody(request: Request): Promise {
+ const contentLength = Number(request.headers.get("content-length") ?? 0);
+ if (contentLength > MAX_MCP_BODY_BYTES) {
+ throw new Error("MCP request body is too large");
+ }
+
+ const raw = await request.clone().text();
+ if (Buffer.byteLength(raw, "utf8") > MAX_MCP_BODY_BYTES) {
+ throw new Error("MCP request body is too large");
+ }
+ if (!raw.trim()) return undefined;
+ return JSON.parse(raw);
+}
+
+function withCors(
+ request: Request,
+ response: Response,
+ allowedOrigins: string[],
+): Response {
+ const headers = new Headers(response.headers);
+ const origin = request.headers.get("origin");
+ const allowAll = allowedOrigins.includes("*");
+
+ if (origin && (allowAll || allowedOrigins.includes(origin))) {
+ headers.set("Access-Control-Allow-Origin", origin);
+ headers.set("Vary", "Origin");
+ }
+ headers.set(
+ "Access-Control-Allow-Headers",
+ "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");
+
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
+}
+
+function parseAllowedOrigins(raw: string | undefined): string[] {
+ if (!raw) return DEFAULT_ALLOWED_ORIGINS;
+ const origins = raw
+ .split(",")
+ .map((origin) => origin.trim())
+ .filter((origin) => origin.length > 0);
+ return origins.length > 0 ? origins : DEFAULT_ALLOWED_ORIGINS;
+}
+
+function jsonRpcError(
+ statusCode: number,
+ code: number,
+ message: string,
+): Response {
+ return new Response(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ error: { code, message },
+ id: null,
+ }),
+ {
+ status: statusCode,
+ headers: { "content-type": "application/json" },
+ },
+ );
+}
diff --git a/packages/mcp-server/test/http.test.ts b/packages/mcp-server/test/http.test.ts
new file mode 100644
index 00000000..5b4e72aa
--- /dev/null
+++ b/packages/mcp-server/test/http.test.ts
@@ -0,0 +1,124 @@
+import type { AddressInfo } from "node:net";
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ createRemoteMcpHttpServer,
+ type RemoteMcpHttpServer,
+} from "../src/http";
+
+let remote: RemoteMcpHttpServer | null = null;
+
+afterEach(async () => {
+ if (remote) {
+ await remote.close();
+ remote = null;
+ }
+});
+
+describe("remote MCP HTTP server", () => {
+ it("serves health and connection metadata", async () => {
+ const baseUrl = await startServer();
+
+ await expect(fetchJson(`${baseUrl}/health`)).resolves.toEqual({
+ ok: true,
+ name: "iapkit-mcp",
+ version: "0.1.0",
+ transport: "streamable-http",
+ mcpPath: "/mcp",
+ });
+
+ const root = await fetchJson(`${baseUrl}/`);
+ expect(root).toMatchObject({
+ name: "iapkit-mcp",
+ service: "IAPKit",
+ endpoints: {
+ mcp: "/mcp",
+ health: "/health",
+ },
+ });
+ });
+
+ it("initializes an MCP session and exposes IAPKit tool names", async () => {
+ const baseUrl = await startServer();
+ const initResponse = await postMcp(baseUrl, {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "initialize",
+ params: {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "vitest", version: "0.0.0" },
+ },
+ });
+
+ expect(initResponse.status).toBe(200);
+ const sessionId = initResponse.headers.get("mcp-session-id");
+ expect(sessionId).toBeTruthy();
+
+ const initEvent = parseSseJson(await initResponse.text());
+ expect(initEvent.result.serverInfo).toMatchObject({
+ name: "iapkit-mcp",
+ websiteUrl: "https://kit.openiap.dev",
+ });
+
+ const listResponse = await postMcp(
+ baseUrl,
+ { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
+ sessionId ?? undefined,
+ );
+ const listEvent = parseSseJson(await listResponse.text());
+ const toolNames = listEvent.result.tools.map(
+ (tool: { name: string }) => tool.name,
+ );
+
+ expect(toolNames).toContain("iapkit_inspect_state");
+ expect(toolNames).toContain("iapkit_create_product");
+ expect(toolNames).not.toContain("openiap_inspect_state");
+ });
+});
+
+async function startServer(): Promise {
+ remote = createRemoteMcpHttpServer({
+ logger: {
+ error: () => undefined,
+ info: () => undefined,
+ },
+ });
+
+ await new Promise((resolve) => {
+ remote?.server.listen(0, "127.0.0.1", resolve);
+ });
+
+ const address = remote.server.address() as AddressInfo;
+ return `http://127.0.0.1:${address.port}`;
+}
+
+async function fetchJson(url: string): Promise {
+ const response = await fetch(url);
+ expect(response.status).toBe(200);
+ return response.json();
+}
+
+async function postMcp(
+ baseUrl: string,
+ body: unknown,
+ sessionId?: string,
+): Promise {
+ return fetch(`${baseUrl}/mcp`, {
+ method: "POST",
+ headers: {
+ accept: "application/json, text/event-stream",
+ "content-type": "application/json",
+ ...(sessionId ? { "mcp-session-id": sessionId } : {}),
+ },
+ body: JSON.stringify(body),
+ });
+}
+
+function parseSseJson(raw: string): any {
+ const dataLine = raw.split("\n").find((line) => line.startsWith("data: "));
+ if (!dataLine) {
+ throw new Error(`No SSE data line found: ${raw}`);
+ }
+ return JSON.parse(dataLine.slice("data: ".length));
+}
From 4d36a57b9cba08b6add75bed698461197a575ef0 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Thu, 4 Jun 2026 14:26:24 +0900
Subject: [PATCH 02/18] fix(mcp): harden IAPKit HTTP error handling
Address PR review findings by removing the Web-standard Buffer dependency, returning 400/413 for client JSON and payload errors, and redacting bearer project keys from MCP tool error payloads.
Validation: bun run --filter @hyodotdev/openiap-mcp-server lint; bun run --filter @hyodotdev/openiap-mcp-server test; bun run --filter @hyodotdev/openiap-mcp-server build; bun run audit:docs; bun run --filter @hyodotdev/openiap-kit lint; bun run --filter @hyodotdev/openiap-kit test; VITE_KIT_CONVEX_URL=https://example.convex.cloud bun run --filter @hyodotdev/openiap-kit smoke:server
---
packages/kit/server/mcp.test.ts | 31 +++++++
packages/mcp-server/src/http.ts | 19 +++-
packages/mcp-server/src/mcp.ts | 61 ++++++++-----
packages/mcp-server/src/web.ts | 25 +++++-
packages/mcp-server/test/http.test.ts | 121 ++++++++++++++++++++++++++
5 files changed, 230 insertions(+), 27 deletions(-)
diff --git a/packages/kit/server/mcp.test.ts b/packages/kit/server/mcp.test.ts
index 39ec3b66..0d8ef5de 100644
--- a/packages/kit/server/mcp.test.ts
+++ b/packages/kit/server/mcp.test.ts
@@ -38,6 +38,24 @@ describe("IAPKit MCP route handler", () => {
expect(toolNames).toContain("iapkit_manage_product");
expect(toolNames).not.toContain("openiap_inspect_state");
});
+
+ it("returns 400 for invalid MCP JSON", async () => {
+ const response = await rawPostMcp("{");
+
+ expect(response.status).toBe(400);
+ await expect(response.json()).resolves.toMatchObject({
+ error: { code: -32700, message: "Parse error: Invalid JSON" },
+ });
+ });
+
+ it("returns 413 for oversized MCP JSON bodies", async () => {
+ const response = await rawPostMcp("x".repeat(1024 * 1024 + 1));
+
+ expect(response.status).toBe(413);
+ await expect(response.json()).resolves.toMatchObject({
+ error: { code: -32000, message: "Payload Too Large" },
+ });
+ });
});
function postMcp(body: unknown, sessionId?: string): Promise {
@@ -54,6 +72,19 @@ function postMcp(body: unknown, sessionId?: string): Promise {
);
}
+function rawPostMcp(body: string): Promise {
+ return handleIapKitMcpRequest(
+ new Request("http://localhost/mcp", {
+ method: "POST",
+ headers: {
+ accept: "application/json, text/event-stream",
+ "content-type": "application/json",
+ },
+ body,
+ }),
+ );
+}
+
function parseSseJson(raw: string): any {
const dataLine = raw.split("\n").find((line) => line.startsWith("data: "));
if (!dataLine) {
diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts
index 4cf32056..7811e426 100644
--- a/packages/mcp-server/src/http.ts
+++ b/packages/mcp-server/src/http.ts
@@ -21,6 +21,7 @@ import {
const DEFAULT_MCP_PATH = "/mcp";
const DEFAULT_PORT = 3939;
const MAX_MCP_BODY_BYTES = 1024 * 1024;
+const MCP_BODY_TOO_LARGE_ERROR = "MCP request body is too large";
const DEFAULT_ALLOWED_ORIGINS = [
"https://chatgpt.com",
"https://chat.openai.com",
@@ -121,6 +122,18 @@ export function createRemoteMcpHttpServer(
writeJsonRpcError(res, 405, -32000, "Method not allowed");
} catch (error) {
+ if (error instanceof SyntaxError) {
+ if (!res.headersSent) {
+ writeJsonRpcError(res, 400, -32700, "Parse error: Invalid JSON");
+ }
+ return;
+ }
+ if (isMcpBodyTooLargeError(error)) {
+ if (!res.headersSent) {
+ writeJsonRpcError(res, 413, -32000, "Payload Too Large");
+ }
+ return;
+ }
logger.error("IAPKit MCP HTTP error:", error);
if (!res.headersSent) {
writeJsonRpcError(res, 500, -32603, "Internal server error");
@@ -257,7 +270,7 @@ async function readJsonBody(req: IncomingMessage): Promise {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
byteLength += buffer.byteLength;
if (byteLength > MAX_MCP_BODY_BYTES) {
- throw new Error("MCP request body is too large");
+ throw new Error(MCP_BODY_TOO_LARGE_ERROR);
}
chunks.push(buffer);
}
@@ -267,6 +280,10 @@ async function readJsonBody(req: IncomingMessage): Promise {
return JSON.parse(raw);
}
+function isMcpBodyTooLargeError(error: unknown): boolean {
+ return error instanceof Error && error.message === MCP_BODY_TOO_LARGE_ERROR;
+}
+
function setCorsHeaders(
req: IncomingMessage,
res: ServerResponse,
diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts
index 453e4199..d92e028d 100644
--- a/packages/mcp-server/src/mcp.ts
+++ b/packages/mcp-server/src/mcp.ts
@@ -87,15 +87,23 @@ function validateApiKey(apiKey: string): string | null {
return null;
}
-function withClient(
+function resolveApiKey(
opts: { apiKey?: string; baseUrl?: string },
extra?: ToolExtra,
-) {
- const apiKey =
+): string | undefined {
+ return (
opts.apiKey ??
extra?.authInfo?.token ??
process.env.IAPKIT_API_KEY ??
- process.env.OPENIAP_API_KEY;
+ process.env.OPENIAP_API_KEY
+ );
+}
+
+function withClient(
+ opts: { apiKey?: string; baseUrl?: string },
+ extra?: ToolExtra,
+) {
+ const apiKey = resolveApiKey(opts, extra);
if (!apiKey) {
throw new Error(
"No IAPKit API key was provided. Set Authorization: Bearer , IAPKIT_API_KEY, OPENIAP_API_KEY, or the tool's apiKey argument.",
@@ -125,17 +133,18 @@ function ok(payload: unknown) {
};
}
-function err(error: unknown) {
+function err(error: unknown, apiKey?: string) {
const detail =
error instanceof KitHttpError
? {
status: error.status,
- body: redactSecrets(error.body),
- message: redactSecrets(error.message),
+ body: redactSecrets(error.body, apiKey),
+ message: redactSecrets(error.message, apiKey),
}
: {
message: redactSecrets(
error instanceof Error ? error.message : String(error),
+ apiKey,
),
};
return {
@@ -149,26 +158,30 @@ function err(error: unknown) {
};
}
-function redactSecrets(value: unknown): unknown {
+function redactSecrets(value: unknown, apiKey?: string): unknown {
if (typeof value === "string") {
- return redactSecretString(value);
+ return redactSecretString(value, apiKey);
}
if (Array.isArray(value)) {
- return value.map((item) => redactSecrets(item));
+ return value.map((item) => redactSecrets(item, apiKey));
}
if (value && typeof value === "object") {
return Object.fromEntries(
- Object.entries(value).map(([key, item]) => [key, redactSecrets(item)]),
+ Object.entries(value).map(([key, item]) => [
+ key,
+ redactSecrets(item, apiKey),
+ ]),
);
}
return value;
}
-function redactSecretString(value: string): string {
+function redactSecretString(value: string, apiKey?: string): string {
const knownSecrets = [
+ apiKey,
process.env.IAPKIT_API_KEY,
process.env.OPENIAP_API_KEY,
- ].filter((secret): secret is string => Boolean(secret));
+ ].filter((secret): secret is string => Boolean(secret?.trim()));
let redacted = value;
for (const secret of knownSecrets) {
redacted = redacted.split(secret).join(API_KEY_PLACEHOLDER);
@@ -290,7 +303,7 @@ function registerIapKitTools(
try {
return ok(await withClient(args, extra).status(args.userId));
} catch (error) {
- return err(error);
+ return err(error, resolveApiKey(args, extra));
}
},
);
@@ -335,7 +348,7 @@ function registerIapKitTools(
: null;
return ok({ health, metrics, userProbe });
} catch (error) {
- return err(error);
+ return err(error, resolveApiKey(args, extra));
}
},
);
@@ -381,6 +394,7 @@ function registerIapKitTools(
new Error(
"subscriptionGroupName is required for iOS Subscription products",
),
+ resolveApiKey(args, extra),
);
}
return ok(
@@ -398,7 +412,7 @@ function registerIapKitTools(
}),
);
} catch (error) {
- return err(error);
+ return err(error, resolveApiKey(args, extra));
}
},
);
@@ -425,7 +439,7 @@ function registerIapKitTools(
}),
);
} catch (error) {
- return err(error);
+ return err(error, resolveApiKey(args, extra));
}
},
);
@@ -469,7 +483,7 @@ function registerIapKitTools(
}),
);
} catch (error) {
- return err(error);
+ return err(error, resolveApiKey(args, extra));
}
},
);
@@ -512,7 +526,7 @@ function registerIapKitTools(
process.env.OPENIAP_API_KEY;
if (!apiKey) return err(new Error("apiKey required"));
const validationError = validateApiKey(apiKey);
- if (validationError) return err(new Error(validationError));
+ if (validationError) return err(new Error(validationError), apiKey);
let baseUrl: string;
try {
baseUrl = normalizeKitBaseUrl(
@@ -521,7 +535,7 @@ function registerIapKitTools(
process.env.OPENIAP_BASE_URL,
);
} catch (error) {
- return err(error);
+ return err(error, apiKey);
}
if (args.platform === "Android") {
const message = {
@@ -556,11 +570,12 @@ function registerIapKitTools(
responseBody,
`kit /v1/webhooks/${API_KEY_PLACEHOLDER} returned ${response.status}`,
),
+ apiKey,
);
}
return ok({ status: response.status, body: responseBody });
} catch (error) {
- return err(error);
+ return err(error, apiKey);
}
}
return ok({
@@ -603,7 +618,7 @@ function registerIapKitTools(
note: "Use webhookUrls.lifecycle for both Apple ASN v2 and Google Pub/Sub RTDN. Legacy aliases remain supported for existing store-console wiring. URLs use an IAPKIT_API_KEY placeholder so tool output does not leak project credentials.",
});
} catch (error) {
- return err(error);
+ return err(error, resolveApiKey(args, extra));
}
},
);
@@ -646,7 +661,7 @@ function registerIapKitTools(
});
return ok({ ...next, action: args.action });
} catch (error) {
- return err(error);
+ return err(error, resolveApiKey(args, extra));
}
},
);
diff --git a/packages/mcp-server/src/web.ts b/packages/mcp-server/src/web.ts
index ddbe76cf..d37422c1 100644
--- a/packages/mcp-server/src/web.ts
+++ b/packages/mcp-server/src/web.ts
@@ -7,6 +7,7 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { createIapKitMcpServer } from "./mcp.js";
const MAX_MCP_BODY_BYTES = 1024 * 1024;
+const MCP_BODY_TOO_LARGE_ERROR = "MCP request body is too large";
const DEFAULT_ALLOWED_ORIGINS = [
"https://chatgpt.com",
"https://chat.openai.com",
@@ -74,6 +75,20 @@ export function createIapKitWebMcpHandler(
allowedOrigins,
);
} catch (error) {
+ if (error instanceof SyntaxError) {
+ return withCors(
+ request,
+ jsonRpcError(400, -32700, "Parse error: Invalid JSON"),
+ allowedOrigins,
+ );
+ }
+ if (isMcpBodyTooLargeError(error)) {
+ return withCors(
+ request,
+ jsonRpcError(413, -32000, "Payload Too Large"),
+ allowedOrigins,
+ );
+ }
logger.error("IAPKit MCP request failed:", error);
return withCors(
request,
@@ -174,17 +189,21 @@ function parseBearerToken(authorization: string | null): string | null {
async function readJsonBody(request: Request): Promise {
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > MAX_MCP_BODY_BYTES) {
- throw new Error("MCP request body is too large");
+ throw new Error(MCP_BODY_TOO_LARGE_ERROR);
}
const raw = await request.clone().text();
- if (Buffer.byteLength(raw, "utf8") > MAX_MCP_BODY_BYTES) {
- throw new Error("MCP request body is too large");
+ if (new TextEncoder().encode(raw).length > MAX_MCP_BODY_BYTES) {
+ throw new Error(MCP_BODY_TOO_LARGE_ERROR);
}
if (!raw.trim()) return undefined;
return JSON.parse(raw);
}
+function isMcpBodyTooLargeError(error: unknown): boolean {
+ return error instanceof Error && error.message === MCP_BODY_TOO_LARGE_ERROR;
+}
+
function withCors(
request: Request,
response: Response,
diff --git a/packages/mcp-server/test/http.test.ts b/packages/mcp-server/test/http.test.ts
index 5b4e72aa..4fdb14ca 100644
--- a/packages/mcp-server/test/http.test.ts
+++ b/packages/mcp-server/test/http.test.ts
@@ -1,3 +1,9 @@
+import {
+ createServer,
+ type IncomingMessage,
+ type Server,
+ type ServerResponse,
+} from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
@@ -7,12 +13,17 @@ import {
} from "../src/http";
let remote: RemoteMcpHttpServer | null = null;
+let kitApi: Server | null = null;
afterEach(async () => {
if (remote) {
await remote.close();
remote = null;
}
+ if (kitApi) {
+ await closeServer(kitApi);
+ kitApi = null;
+ }
});
describe("remote MCP HTTP server", () => {
@@ -75,6 +86,81 @@ describe("remote MCP HTTP server", () => {
expect(toolNames).toContain("iapkit_create_product");
expect(toolNames).not.toContain("openiap_inspect_state");
});
+
+ it("returns client errors for invalid JSON and oversized payloads", async () => {
+ const baseUrl = await startServer();
+
+ const invalidJson = await rawPostMcp(baseUrl, "{");
+ expect(invalidJson.status).toBe(400);
+ await expect(invalidJson.json()).resolves.toMatchObject({
+ error: { code: -32700, message: "Parse error: Invalid JSON" },
+ });
+
+ const oversized = await rawPostMcp(baseUrl, "x".repeat(1024 * 1024 + 1));
+ expect(oversized.status).toBe(413);
+ await expect(oversized.json()).resolves.toMatchObject({
+ error: { code: -32000, message: "Payload Too Large" },
+ });
+ });
+
+ it("redacts bearer API keys from tool error responses", async () => {
+ const apiKey = "openiap-kit_secret_http";
+ const previousBaseUrl = process.env.IAPKIT_BASE_URL;
+ process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => {
+ res.writeHead(500, { "content-type": "application/json" });
+ res.end(
+ JSON.stringify({
+ error: `upstream saw bearer key ${apiKey}`,
+ path: req.url,
+ }),
+ );
+ });
+
+ try {
+ const baseUrl = await startServer();
+ const authHeaders = { authorization: `Bearer ${apiKey}` };
+ const initResponse = await postMcp(
+ baseUrl,
+ {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "initialize",
+ params: {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "vitest", version: "0.0.0" },
+ },
+ },
+ undefined,
+ authHeaders,
+ );
+ const sessionId = initResponse.headers.get("mcp-session-id");
+ expect(sessionId).toBeTruthy();
+ await initResponse.text();
+
+ const callResponse = await postMcp(
+ baseUrl,
+ {
+ jsonrpc: "2.0",
+ id: 2,
+ method: "tools/call",
+ params: {
+ name: "iapkit_check_status",
+ arguments: { userId: "user_1" },
+ },
+ },
+ sessionId ?? undefined,
+ authHeaders,
+ );
+ const callBody = await callResponse.text();
+
+ expect(callBody).not.toContain(apiKey);
+ expect(callBody).toContain("");
+ } finally {
+ if (previousBaseUrl === undefined) delete process.env.IAPKIT_BASE_URL;
+ else process.env.IAPKIT_BASE_URL = previousBaseUrl;
+ }
+ });
});
async function startServer(): Promise {
@@ -93,6 +179,28 @@ async function startServer(): Promise {
return `http://127.0.0.1:${address.port}`;
}
+async function startKitApi(
+ handler: (req: IncomingMessage, res: ServerResponse) => void,
+): Promise {
+ kitApi = createServer(handler);
+
+ await new Promise((resolve) => {
+ kitApi?.listen(0, "127.0.0.1", resolve);
+ });
+
+ const address = kitApi.address() as AddressInfo;
+ return `http://127.0.0.1:${address.port}`;
+}
+
+async function closeServer(server: Server): Promise {
+ await new Promise((resolve, reject) => {
+ server.close((error) => {
+ if (error) reject(error);
+ else resolve();
+ });
+ });
+}
+
async function fetchJson(url: string): Promise {
const response = await fetch(url);
expect(response.status).toBe(200);
@@ -103,18 +211,31 @@ async function postMcp(
baseUrl: string,
body: unknown,
sessionId?: string,
+ headers: Record = {},
): Promise {
return fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
accept: "application/json, text/event-stream",
"content-type": "application/json",
+ ...headers,
...(sessionId ? { "mcp-session-id": sessionId } : {}),
},
body: JSON.stringify(body),
});
}
+function rawPostMcp(baseUrl: string, body: string): Promise {
+ return fetch(`${baseUrl}/mcp`, {
+ method: "POST",
+ headers: {
+ accept: "application/json, text/event-stream",
+ "content-type": "application/json",
+ },
+ body,
+ });
+}
+
function parseSseJson(raw: string): any {
const dataLine = raw.split("\n").find((line) => line.startsWith("data: "));
if (!dataLine) {
From b2db701f90c2c9891fd652e86d96f91231357f19 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Thu, 4 Jun 2026 21:44:41 +0900
Subject: [PATCH 03/18] feat(kit): add codex iapkit tools
---
packages/kit/README.md | 7 +-
.../docs/screenshots/chatgpt-plugin.webp | Bin 7224 -> 0 bytes
.../public/docs/screenshots/codex-plugin.webp | Bin 0 -> 56342 bytes
packages/kit/public/llms-full.txt | 19 +-
packages/kit/public/llms.txt | 6 +-
packages/kit/public/sitemap.xml | 2 +-
packages/kit/server/api/v1/products.test.ts | 27 ++
packages/kit/server/api/v1/products.ts | 6 +-
.../kit/server/api/v1/subscriptions.test.ts | 57 ++++
packages/kit/server/api/v1/subscriptions.ts | 70 +++++
packages/kit/server/mcp.test.ts | 5 +-
packages/kit/server/server.ts | 2 +-
packages/kit/src/pages/docs/nav.ts | 8 +-
packages/kit/src/pages/docs/routes.tsx | 7 +-
.../src/pages/docs/sections/ai-assistants.tsx | 14 +-
.../{chatgpt-plugin.tsx => codex-plugin.tsx} | 91 ++++--
packages/mcp-server/package.json | 2 +-
packages/mcp-server/src/kit-client.ts | 45 +++
packages/mcp-server/src/mcp.ts | 281 +++++++++++++++++-
packages/mcp-server/test/http.test.ts | 138 +++++++++
packages/mcp-server/test/kit-client.test.ts | 48 ++-
21 files changed, 758 insertions(+), 77 deletions(-)
delete mode 100644 packages/kit/public/docs/screenshots/chatgpt-plugin.webp
create mode 100644 packages/kit/public/docs/screenshots/codex-plugin.webp
rename packages/kit/src/pages/docs/sections/{chatgpt-plugin.tsx => codex-plugin.tsx} (54%)
diff --git a/packages/kit/README.md b/packages/kit/README.md
index e7391ed6..5fe7ae3d 100644
--- a/packages/kit/README.md
+++ b/packages/kit/README.md
@@ -41,7 +41,7 @@ One package, one binary, one Fly.io app.
- **Free for everyone** — no paywall, no usage caps. Sustained by sponsors at [openiap.dev/sponsors](https://openiap.dev/sponsors)
- **Email OTP (Resend) + GitHub OAuth** via `@convex-dev/auth`
- **OpenAPI spec** auto-generated by `hono-openapi`
-- **ChatGPT / MCP connector** at `/mcp` for IAPKit project inspection and product-management workflows
+- **Codex / MCP plugin endpoint** at `/mcp` for IAPKit project inspection, revenue questions, product management, and store-sync workflows
## Quick Start
@@ -80,8 +80,9 @@ For API-only work:
bun run dev:server # Hono on http://localhost:3000
```
-The dev server also exposes the ChatGPT / MCP connector at
-`http://localhost:3000/mcp`. Use an IAPKit project key, not an OpenAI API key:
+The dev server also exposes the Codex / MCP plugin endpoint at
+`http://localhost:3000/mcp`. Use an IAPKit project key, not an OpenAI or
+ChatGPT API key:
```bash
IAPKIT_API_KEY="openiap-kit_your-project-key" bun run dev:server
diff --git a/packages/kit/public/docs/screenshots/chatgpt-plugin.webp b/packages/kit/public/docs/screenshots/chatgpt-plugin.webp
deleted file mode 100644
index ad6788c6d9d06c9064a8cb2d180a5ea3236c2572..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 7224
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
diff --git a/packages/kit/public/docs/screenshots/codex-plugin.webp b/packages/kit/public/docs/screenshots/codex-plugin.webp
new file mode 100644
index 0000000000000000000000000000000000000000..7759bb7c4b953d2c55c09298844e3ca6ede64486
GIT binary patch
literal 56342
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