diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..996d81d6 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "openiap", + "interface": { + "displayName": "OpenIAP" + }, + "plugins": [ + { + "name": "openiap", + "source": { + "source": "local", + "path": "./plugins/openiap" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index cb617d4f..44cf69d6 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -9,6 +9,7 @@ Complete workflow: branch → commit → push → PR ``` **Options:** + - `--push` or `-p`: Push to remote after commit - `--pr`: Create PR after push - `--all` or `-a`: Commit all changes at once @@ -48,6 +49,7 @@ git branch --show-current ``` **If on `main`** → Create a feature branch first: + ```bash git checkout -b feat/ ``` @@ -55,6 +57,7 @@ git checkout -b feat/ **If NOT on `main`** → Proceed with commits directly. **Branch naming conventions:** + - **Always include the target library/package name** in the branch name - `feat/-` - New features (e.g., `feat/godot-win-back-offers`) - `fix/-` - Bug fixes (e.g., `fix/expo-double-init`) @@ -62,6 +65,7 @@ git checkout -b feat/ - `chore/-` - Maintenance tasks (e.g., `chore/kmp-bump-deps`) **Library shortnames:** + - `rn` or `react-native` → react-native-iap - `expo` → expo-iap - `flutter` → flutter_inapp_purchase @@ -82,21 +86,25 @@ git diff --name-only ### 3. Stage Changes **GQL schema only (FIRST COMMIT):** + ```bash git add packages/gql/src/*.graphql ``` **Generated types (SECOND COMMIT):** + ```bash git add packages/gql/src/generated/ ``` **Specific path:** + ```bash git add ``` **All changes:** + ```bash git add . ``` @@ -134,6 +142,7 @@ EOF | `test` | Adding/updating tests | **Scope Examples:** + - `gql` - GraphQL schema changes - `apple` - iOS/macOS package - `google` - Android package @@ -174,6 +183,28 @@ EOF )" ``` +### 7a. Upload Preview Recording + +For every PR that adds a new feature, visible behavior change, UI change, +documentation page, example flow, or developer workflow, record a preview before +handoff: + +1. Render the actual changed surface after implementation. Use the Codex Chrome + Extension for web/docs/dashboard previews. +2. Compress the final recording to **under 10 MB**. Prefer H.264 MP4 with lower + resolution / frame rate when needed. +3. Upload the compressed recording to the GitHub PR as a PR body attachment or a + clearly labeled attached `Preview` comment. + Do not commit one-off PR preview recordings. Only commit preview media when + the media itself is product documentation or an example asset that should + ship with the repository. +4. Link/embed the GitHub-hosted recording in the PR body or preview comment. + +If there is no visual or interactive surface, add a short PR note explaining why +recording is not applicable and include the best terminal/API proof instead. +Never include secrets, private customer data, or browser profile details in the +recording. + ### 8. Add Labels to PR After creating the PR, add appropriate labels based on the changes. @@ -184,6 +215,7 @@ gh pr edit --add-label "," ``` **Label selection guide:** + - Changes to `packages/apple/` → `📱 iOS` - Changes to `packages/google/` → `🤖 android` - Changes to `packages/docs/` → `📖 documentation` @@ -207,17 +239,18 @@ gh pr edit --add-label "," When making cross-package changes, commit in this order: -| Order | Path | Description | -|-------|------|-------------| -| 1 | `packages/gql/src/*.graphql` | GraphQL schema ONLY (no generated types) | -| 2 | `packages/gql/src/generated/` | Generated types (after schema review) | -| 3 | `packages/apple/` | iOS implementation | -| 4 | `packages/google/` | Android implementation | -| 5 | `packages/docs/` | Documentation updates | -| 6 | `.claude/commands/` | Skill/workflow updates | -| 7 | `knowledge/` | Knowledge base updates | +| Order | Path | Description | +| ----- | ----------------------------- | ---------------------------------------- | +| 1 | `packages/gql/src/*.graphql` | GraphQL schema ONLY (no generated types) | +| 2 | `packages/gql/src/generated/` | Generated types (after schema review) | +| 3 | `packages/apple/` | iOS implementation | +| 4 | `packages/google/` | Android implementation | +| 5 | `packages/docs/` | Documentation updates | +| 6 | `.claude/commands/` | Skill/workflow updates | +| 7 | `knowledge/` | Knowledge base updates | **IMPORTANT - First Commit Must Be GQL Spec Only:** + ```bash # Stage ONLY .graphql files (not generated/) git add packages/gql/src/*.graphql @@ -233,6 +266,7 @@ git commit -m "feat(gql): add new types..." ``` This order allows: + - API schema to be reviewed first before any implementation - Generated types committed after schema approval - Platform implementations to follow the approved schema @@ -243,6 +277,7 @@ This order allows: ## Example Commit Messages **GQL schema update:** + ``` feat(gql): add win-back offer and product status types @@ -259,6 +294,7 @@ Co-Authored-By: Claude Opus 4.5 ``` **Generated types:** + ``` chore(gql): regenerate types for all platforms @@ -269,6 +305,7 @@ Co-Authored-By: Claude Opus 4.5 ``` **iOS implementation:** + ``` feat(apple): implement win-back offers and JWS promotional offers @@ -281,6 +318,7 @@ Co-Authored-By: Claude Opus 4.5 ``` **Documentation update:** + ``` docs: add release notes and type documentation @@ -306,20 +344,24 @@ Co-Authored-By: Claude Opus 4.5 ## Changes ### GraphQL Schema (packages/gql) + - `WinBackOfferInputIOS` - Win-back offer input type - `ProductStatusAndroid` - Product fetch status enum - `PromotionalOfferJWSInputIOS` - JWS format promo offers ### iOS (packages/apple) + - Implement win-back offer handling in purchase flow - Add JWS promotional offer support (back-deployed to iOS 15) - Add introductory offer eligibility override ### Android (packages/google) + - Map ProductStatusAndroid from BillingResult - Return status in fetchProducts response ### Documentation (packages/docs) + - Release notes for v1.3.13 - Type documentation updates - Example code updates diff --git a/.codex/skills/openiap-workflows/SKILL.md b/.codex/skills/openiap-workflows/SKILL.md index a426eccd..8d661037 100644 --- a/.codex/skills/openiap-workflows/SKILL.md +++ b/.codex/skills/openiap-workflows/SKILL.md @@ -69,6 +69,13 @@ appropriate labels before merging. - For release-note package lists, verify versions from package metadata and GitHub release tags; never infer framework versions from `openiap-versions.json` or from a nearby release block. +- For PRs with new features, visible behavior changes, UI changes, docs pages, + example flows, or developer workflows, record the actual changed surface, + compress the video to under 10 MB, and upload it to the GitHub PR as a + `Preview` comment or PR body attachment. Do not commit one-off PR preview + recordings; only commit media when the asset itself is part of product docs or + examples. Use the Codex Chrome Extension for web/docs/dashboard previews when + applicable. - Keep commits in Angular Conventional Commits format: `(): `. diff --git a/.gitignore b/.gitignore index 8e1fe1e7..41d65584 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,10 @@ yarn-debug.log* yarn-error.log* lerna-debug.log* +# PR preview recordings should be uploaded to GitHub PR attachments, not +# committed to the repository. +.github/pr-previews/ + # Testing coverage/ .nyc_output/ diff --git a/bun.lock b/bun.lock index ff9ac558..bf40d586 100644 --- a/bun.lock +++ b/bun.lock @@ -84,6 +84,7 @@ "@convex-dev/auth": "^0.0.94", "@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/knowledge/internal/06-git-deployment.md b/knowledge/internal/06-git-deployment.md index d10ea6b0..1c450333 100644 --- a/knowledge/internal/06-git-deployment.md +++ b/knowledge/internal/06-git-deployment.md @@ -12,6 +12,33 @@ - No trailing period - Use imperative mood ("add" not "added") +## Pull Request Preview Recordings + +Every PR that introduces a new feature, visible behavior change, UI change, +documentation page, example flow, or developer workflow must include a preview +recording before it is handed off for review. + +Requirements: + +- Record the actual changed surface after the implementation is complete. Use + the Codex Chrome Extension for web/docs/dashboard previews whenever a browser + can render the change. +- Compress the final video to **under 10 MB** so GitHub accepts it reliably. + Prefer H.264 MP4 with a modest resolution / frame rate when the raw capture is + too large. +- Upload the compressed recording to the GitHub PR as a PR body attachment or a + clearly labeled attached `Preview` comment. +- Do not commit one-off PR preview recordings. Only commit preview media when + the media itself is a product documentation or example asset that should ship + with the repository. +- Link or embed the uploaded preview in the PR body or a clearly labeled + `Preview` PR comment. +- If the change has no visual or interactive surface, include a short note in + the PR explaining why a recording was not applicable and show the most useful + terminal/API proof instead. +- Do not upload secrets, private customer data, unreleased credentials, or local + browser profile details in previews. Redact or use test fixtures. + ### With Tag and Scope When a commit targets a specific package or library, include the scope: diff --git a/packages/docs/public/docs/videos/openiap-mcp-expo-test.webm b/packages/docs/public/docs/videos/openiap-mcp-expo-test.webm new file mode 100644 index 00000000..eb15a4c1 Binary files /dev/null and b/packages/docs/public/docs/videos/openiap-mcp-expo-test.webm differ diff --git a/packages/docs/src/generated/version-metadata.json b/packages/docs/src/generated/version-metadata.json index 491c12c4..49e24fb0 100644 --- a/packages/docs/src/generated/version-metadata.json +++ b/packages/docs/src/generated/version-metadata.json @@ -6,7 +6,7 @@ "godotPackageVersion": "2.3.1", "kmpPackageVersion": "2.3.1", "mauiPackageId": "OpenIap.Maui", - "mauiPackageVersion": "1.1.2", + "mauiPackageVersion": "1.1.3", "googleCompileSdk": "35", "googleMinSdk": "23", "googlePlayBillingVersion": "8.3.0", diff --git a/packages/docs/src/pages/docs/getting-started.tsx b/packages/docs/src/pages/docs/getting-started.tsx index 1e15802a..1148f7a3 100644 --- a/packages/docs/src/pages/docs/getting-started.tsx +++ b/packages/docs/src/pages/docs/getting-started.tsx @@ -503,7 +503,7 @@ await iap.request_purchase(props)`}
  • Validation — server-side verification (your own backend or IAPKit — open source under MIT, - hosted free at{' '} + with hosted validation and analytics free at{' '} + +

    MCP Server

    +

    + OpenIAP ships an IAPKit-backed MCP server so Codex and other MCP clients + can inspect in-app purchase configuration, generate setup snippets, + manage IAPKit catalog rows, run safe store sync previews, and review app + purchase code from the same thread. +

    +

    + If you only use the OpenIAP SDKs directly in your app, you do not need + IAPKit or this MCP server. IAPKit is the optional managed + receipt-validation backend for OpenIAP projects: it stores your product + catalog, validates App Store / Google Play purchases, tracks + subscriptions, and exposes project tools that Codex can call through + MCP. Create or open an IAPKit project at{' '} + + kit.openiap.dev + {' '} + before using the hosted MCP endpoint. +

    + + +
      +
    • + IAPKit dashboard:{' '} + + https://kit.openiap.dev + +
    • +
    • + Hosted endpoint: https://kit.openiap.dev/mcp +
    • +
    • + Authentication:{' '} + Authorization: Bearer <IAPKit project key> +
    • +
    • + Local testing: run @hyodotdev/openiap-mcp-server from + the monorepo. +
    • +
    • + Store writes should start with dryRun: true. +
    • +
    +
    + +
    + + Where to open it + +

    + In this OpenIAP docs site, this page lives at{' '} + /docs/guides/mcp-server under{' '} + Setup Guide → AI Assistants → MCP Server. It covers + MCP setup, local PR testing, tool behavior, safety, and the recorded + Example App walkthrough. +

    +

    + The IAPKit dashboard keeps a shorter Codex plugin page at{' '} + /docs/ai-assistants/codex-plugin for Kit-local endpoint + and API-key details. That page links back here instead of duplicating + the full MCP guide. On a local checkout, Vite may assign different + ports to the OpenIAP docs site and the Kit dashboard, so use the page + path rather than the port number when opening the guide. +

    +
    + +
    + + Codex plugin + +

    + Use the OpenIAP plugin when you want Codex to call IAPKit tools while + it also edits and tests your app workspace. Install the plugin from + the OpenIAP marketplace, set IAPKIT_API_KEY in the + environment that launches Codex, then open a new thread. +

    + {`codex plugin marketplace add hyodotdev/openiap --ref main +export IAPKIT_API_KEY="openiap-kit_your-project-key"`} + {`Use the OpenIAP plugin. + +Review my app's in-app purchase flow and list the OpenIAP/IAPKit tools available. +Do not create products, start sync jobs, or modify files until I confirm.`} +
    + +
    + + Manual MCP config + +

    + If you do not install the plugin bundle, configure the hosted MCP + server directly in Codex. Keep the project key in an environment + variable instead of hardcoding it into config files. +

    + {`[mcp_servers.openiap] +url = "https://kit.openiap.dev/mcp" +bearer_token_env_var = "IAPKIT_API_KEY" +default_tools_approval_mode = "prompt"`} +

    + For unreleased PR testing, run the local HTTP transport and point + Codex at it: +

    + {`# From the monorepo root +IAPKIT_API_KEY="openiap-kit_your-project-key" \\ + bun run --filter @hyodotdev/openiap-mcp-server start:http + +# Local MCP URL: +# http://127.0.0.1:3939/mcp`} + {`[mcp_servers.openiap-local] +url = "http://127.0.0.1:3939/mcp" +default_tools_approval_mode = "prompt"`} +
    + +
    + + Recorded Expo app test + +

    + This recording uses the generated CPK Expo app at{' '} + /Users/hyo/Github/others/OpenIapMcpTestApp. The local + OpenIAP MCP server is started on localhost:3939/mcp and a + Codex prompt asks MCP to generate the Expo setup hook. After the hook + is applied, the app loads the store products, connects Buy to{' '} + requestPurchase, validates the receipt against the dev + Kit API, and finishes the transaction on a connected iPhone. +

    +

    The recording covers these concrete checks:

    +
      +
    • + Create the CPK Expo app in /Users/hyo/Github/others and + configure dev.hyo.martie as the sample iOS bundle id + and Android package name. The app UI still shows only Example App + product names, prices, and Buy buttons. +
    • +
    • + Start the local MCP HTTP transport, call initialize, + confirm the iapkit_* tools list, and request the Expo + setup snippet with iapkit_setup. +
    • +
    • + Confirm the generated Expo IAP hook fetches the subscription and + in-app products: Premium, 10 Bulbs, and 30 Bulbs. +
    • +
    • + Verify fetchProducts returns all three products, then + press Buy in the app and confirm requestPurchase opens + the native Apple sandbox purchase sheet on the connected iPhone. +
    • +
    • + Confirm the dev Kit validation endpoint returns{' '} + isValid: true, the purchase state is{' '} + READY_TO_CONSUME, finishTransaction{' '} + completes, and the dev database records the purchase row. +
    • +
    • + Run npm run typecheck,{' '} + npm test -- --runInBand, and a native iOS run. +
    • +
    +
    + +
    + Real-device verification: MCP initialize/tools/list/iapkit_setup + succeeds, the generated Expo hook loads Premium and Bulbs products, + Buy opens the native Apple sandbox purchase sheet, and dev receipt + validation plus finishTransaction completes. +
    +
    +

    + The recording uses local PR code with the dev Kit backend; it does not + require the production MCP endpoint to be deployed. The same MCP + thread can also connect the app to its IAPKit project, check + entitlement status, inspect webhook URLs, and review revenue or + subscriber state without leaving Codex. +

    +
    + +
    + + Example App setup + +

    + For a concrete recording or walkthrough, use Example App with{' '} + dev.hyo.martie as the sample iOS bundle id and Android + package name. Configure that identifier in the IAPKit project settings + first; store sync tools read the package and bundle identifiers from + IAPKit. Keep the app UI focused on product names such as Premium, 10 + Bulbs, and 30 Bulbs. +

    + +

    1. Inspect first

    + {`Use the OpenIAP plugin in this workspace. + +The app is Example App: +- iOS bundle id: dev.hyo.martie +- Android package name: dev.hyo.martie +- framework: Expo + +Inspect the IAPKit project, list existing products, and review the app's purchase code. +Do not create products, start sync jobs, or edit files until I approve.`} + +

    2. Create local catalog rows

    + {`Use the OpenIAP plugin. + +Create or update these Example App products in IAPKit's local catalog: +- Premium: Subscription, monthly +- 10 Bulbs: Consumable +- 30 Bulbs: Consumable + +Create both iOS and Android rows. +For iOS, put Premium in subscriptionGroupName "Example Premium". +After creating them, list products and summarize exactly what changed.`} + +

    3. Preview store sync

    + {`Use the OpenIAP plugin. + +Run a dry-run product sync for Example App: +- platform Android, direction push, dryRun true +- platform IOS, direction push, dryRun true + +Poll each sync job until it finishes. +Show the proposed store changes and wait for my approval before running dryRun false.`} + +

    4. Wire the Expo app

    + {`Use the OpenIAP plugin and update the Expo app. + +Call iapkit_setup for framework expo and the Premium product. +Apply the generated snippet to the app's purchase screen or purchase hook. +Fetch Premium, 10 Bulbs, and 30 Bulbs, and connect Buy to requestPurchase. +Keep IAPKIT_API_KEY out of source code; read it from runtime configuration. +Run the app's typecheck and tests after editing.`} + +

    5. Add receipt validation

    + {`Use the OpenIAP plugin and continue in this app. + +Wire receipt validation after a successful purchase: +- use IAPKit as the verification provider +- on iOS, validate the StoreKit JWS from the purchase +- on Android, validate the Google purchaseToken +- grant the entitlement and finishTransaction only after the validation result is valid +- keep the IAPKit project key out of committed source; use a backend endpoint or runtime secret + +Then inspect IAPKit state from MCP: +- call iapkit_inspect_state to review project status, products, and webhook URLs +- call iapkit_check_status for the app's test user after a sandbox purchase +- use iapkit_troubleshoot if validation, webhooks, or entitlement state does not look right + +Run typecheck and tests after editing, and summarize exactly what changed.`} +
    + +
    + + Tools exposed + +

    + Codex sees the tools with the iapkit_ prefix. The MCP + server currently exposes tools for setup snippets, status checks, + troubleshooting, product catalog reads and writes, subscription lists, + sandbox purchase guidance, synthetic webhook delivery, entitlement + inspection, revenue analytics, and App Store / Google Play product + sync jobs. Receipt validation still runs in your app or backend + through the OpenIAP SDK and IAPKit API; MCP gives Codex the project + context and tool results it needs to wire and verify that flow. +

    +

    + For the lower-level backend architecture and stdio example, see{' '} + Kit backend → MCP server. +

    +
    + +
    + + Safety + +

    + Product management tools call live IAPKit endpoints. Store sync jobs + can write to App Store Connect or Google Play when dryRun{' '} + is false. Ask Codex to inspect first, run store sync as{' '} + dryRun: true, and approve live writes only after + reviewing the proposed product id, platform, type, price, and billing + period. +

    +
    + + ); +} + +export default MCPServer; diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index 8468d987..1185ed3f 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -124,6 +124,7 @@ import Announcements from './updates/announcements'; import Releases from './updates/releases'; import Versions from './updates/versions'; import AIAssistants from './guides/ai-assistants'; +import MCPServer from './guides/mcp-server'; import Testing from './guides/testing'; import FoundationGovernance from './foundation/governance'; import FoundationOnePager from './foundation/one-pager'; @@ -635,15 +636,12 @@ function Docs() { }))} onItemClick={closeSidebar} /> -
  • - (isActive ? 'active' : '')} - onClick={closeSidebar} - > - AI Assistants - -
  • +
  • } /> } /> } /> + } /> } /> } />

    @hyodotdev/openiap-mcp-server is a stdio Model Context - Protocol server with 10 tools covering setup, status checks, + Protocol server with 13 tools covering setup, status checks, troubleshooting, product CRUD, subscription listing, sandbox simulation, and full-state inspection. Plug it into Claude Desktop / Cursor / Codex via:

    {`{ "mcpServers": { - "openiap": { + "iapkit": { "command": "bunx", "args": ["@hyodotdev/openiap-mcp-server"], "env": { - "OPENIAP_API_KEY": "openiap-kit_", - "OPENIAP_BASE_URL": "https://kit.openiap.dev" + "IAPKIT_API_KEY": "openiap-kit_", + "IAPKIT_BASE_URL": "https://kit.openiap.dev" } } } diff --git a/packages/docs/src/pages/docs/webhooks.tsx b/packages/docs/src/pages/docs/webhooks.tsx index 8d178a1d..e628e8f9 100644 --- a/packages/docs/src/pages/docs/webhooks.tsx +++ b/packages/docs/src/pages/docs/webhooks.tsx @@ -223,7 +223,7 @@ import { useWebhookEvents } from 'react-native-iap'; import EventSource from 'react-native-sse'; const { events, lastError, isConnected } = useWebhookEvents({ - apiKey: process.env.OPENIAP_API_KEY!, + apiKey: process.env.IAPKIT_API_KEY!, // baseUrl defaults to https://kit.openiap.dev eventSourceFactory: (url) => new EventSource(url), onEvent: (event) => { 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..5fe7ae3d 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` +- **Codex / MCP plugin endpoint** at `/mcp` for IAPKit project inspection, revenue questions, product management, and store-sync workflows ## Quick Start @@ -79,6 +80,14 @@ For API-only work: bun run dev:server # Hono on http://localhost:3000 ``` +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 +``` + ### 4. Production build ```bash diff --git a/packages/kit/package.json b/packages/kit/package.json index b5f2e418..a1be79f0 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -34,6 +34,7 @@ "@convex-dev/auth": "^0.0.94", "@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/codex-plugin.webp b/packages/kit/public/docs/screenshots/codex-plugin.webp new file mode 100644 index 00000000..56406dc7 Binary files /dev/null and b/packages/kit/public/docs/screenshots/codex-plugin.webp differ diff --git a/packages/kit/public/llms-full.txt b/packages/kit/public/llms-full.txt index 4273c7e1..f99d3a09 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`, the Codex plugin, + MCP-compatible assistant clients, 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 the project key stays in that private process. +This is not an OpenAI or ChatGPT 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 Codex and other MCP clients. Exposes tools +with the `iapkit_` prefix: setup snippets, revenue analytics, status checks, +diagnostics, product catalog reads/writes, store-sync jobs, subscriber views, +sandbox guidance, webhook simulation, and project inspection. Full setup guide: +`/docs/ai-assistants/codex-plugin`. + ## Harmonized purchase states | State | `isValid` | Meaning | diff --git a/packages/kit/public/llms.txt b/packages/kit/public/llms.txt index 2f2c090f..2811a3d7 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/codex-plugin) — MCP Streamable HTTP endpoint for Codex and other MCP clients. Uses an IAPKit project API key, not an OpenAI or ChatGPT 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. @@ -68,5 +69,6 @@ Harmonized `state` values (truthy `isValid`): `ENTITLED`, - [/docs/api](https://kit.openiap.dev/docs/api) — request shapes, responses, errors, headers - [/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](https://kit.openiap.dev/docs/ai-assistants) — how to point Codex / Claude / Cursor / etc. at this file +- [/docs/ai-assistants/codex-plugin](https://kit.openiap.dev/docs/ai-assistants/codex-plugin) — Codex plugin 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..6eec56d1 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/codex-plugin + 2026-06-04 monthly 0.6 diff --git a/packages/kit/server/api/v1/products.test.ts b/packages/kit/server/api/v1/products.test.ts index 98c2dab8..d4aa33da 100644 --- a/packages/kit/server/api/v1/products.test.ts +++ b/packages/kit/server/api/v1/products.test.ts @@ -398,6 +398,33 @@ describe("productsRoutes", () => { expect(mocks.mutation).not.toHaveBeenCalled(); }); + it("enqueues product sync jobs", async () => { + const app = buildApp(); + mocks.mutation.mockResolvedValueOnce({ + jobId: "job_123", + deduped: false, + }); + + const response = await app.request( + "/products/key/sync/android?direction=push&dryRun=true", + { + method: "POST", + }, + ); + + expect(response.status).toBe(202); + await expect(response.json()).resolves.toEqual({ + jobId: "job_123", + deduped: false, + }); + expect(mocks.mutation).toHaveBeenCalledWith("enqueueProductSync", { + apiKey: "key", + platform: "Android", + direction: "push", + dryRun: true, + }); + }); + it("rejects oversized sync job ids before calling Convex", async () => { const app = buildApp(); const jobId = "j".repeat(257); diff --git a/packages/kit/server/api/v1/products.ts b/packages/kit/server/api/v1/products.ts index 3f35af8a..59a0b8ad 100644 --- a/packages/kit/server/api/v1/products.ts +++ b/packages/kit/server/api/v1/products.ts @@ -11,9 +11,9 @@ import { } from "./request-body"; // Catalog read/write surface mirroring onesub's @onesub/providers -// admin path. The actual App Store Connect / Play Console push-sync -// is a Phase 3 follow-up; for now this manages the kit-side cache, -// which the dashboard / MCP server / SDKs all share. +// admin path. Store sync is queued through background jobs so the +// dashboard, MCP server, and API clients do not hold browser/mobile +// HTTP connections open while App Store Connect or Play Console work runs. const products = new Hono(); const MAX_PRODUCT_ID_LENGTH = 256; diff --git a/packages/kit/server/api/v1/subscriptions.test.ts b/packages/kit/server/api/v1/subscriptions.test.ts index b695a4c4..94e3b5d3 100644 --- a/packages/kit/server/api/v1/subscriptions.test.ts +++ b/packages/kit/server/api/v1/subscriptions.test.ts @@ -15,6 +15,7 @@ vi.mock("@/convex", () => ({ entitlements: "entitlements", listSubscriptions: "listSubscriptions", metricsSummary: "metricsSummary", + getRevenueMetrics: "getRevenueMetrics", }, mutation: { bindUser: "bindUser", @@ -211,6 +212,62 @@ describe("subscriptionsRoutes", () => { expect(mocks.mutation).not.toHaveBeenCalled(); }); + it("forwards revenue metrics ranges to Convex", async () => { + const app = buildApp(); + mocks.query.mockResolvedValueOnce({ + days: [], + currencies: [], + productIds: [], + platforms: [], + truncated: false, + }); + + const response = await app.request( + "/subscriptions/revenue/key?fromDay=2026-06-01&toDay=2026-06-04", + ); + + expect(response.status).toBe(200); + expect(mocks.query).toHaveBeenCalledWith("getRevenueMetrics", { + apiKey: "key", + fromDay: "2026-06-01", + toDay: "2026-06-04", + }); + }); + + it("rejects invalid revenue ranges before calling Convex", async () => { + const app = buildApp(); + + const cases = [ + { + path: "/subscriptions/revenue/key?fromDay=bad&toDay=2026-06-04", + message: "fromDay and toDay must be YYYY-MM-DD", + }, + { + path: "/subscriptions/revenue/key?fromDay=2026-02-31&toDay=2026-06-04", + message: "fromDay and toDay must be valid calendar days", + }, + { + path: "/subscriptions/revenue/key?fromDay=2026-06-05&toDay=2026-06-04", + message: "fromDay must be on or before toDay", + }, + { + path: "/subscriptions/revenue/key?fromDay=2026-01-01&toDay=2026-06-04", + message: "revenue range must be 92 days or less", + }, + ]; + + for (const { path, message } of cases) { + const response = await app.request(path); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [{ code: "INVALID_INPUT", message }], + }); + } + + expect(mocks.query).not.toHaveBeenCalled(); + }); + it("rejects oversized non-Apple bind-user purchaseToken before calling Convex", async () => { const app = buildApp(); const response = await app.request("/subscriptions/bind-user/key", { diff --git a/packages/kit/server/api/v1/subscriptions.ts b/packages/kit/server/api/v1/subscriptions.ts index 32c38b9a..aad73a03 100644 --- a/packages/kit/server/api/v1/subscriptions.ts +++ b/packages/kit/server/api/v1/subscriptions.ts @@ -51,6 +51,7 @@ const API_KEY_ROUTES = [ "/entitlements/:apiKey", "/list/:apiKey", "/metrics/:apiKey", + "/revenue/:apiKey", "/bind-user/:apiKey", ]; @@ -178,6 +179,45 @@ subscriptions.get("/metrics/:apiKey", async (c) => { } }); +subscriptions.get("/revenue/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const fromDay = c.req.query("fromDay"); + const toDay = c.req.query("toDay"); + + if (!isIsoDay(fromDay) || !isIsoDay(toDay)) { + return invalidInput(c, "fromDay and toDay must be YYYY-MM-DD"); + } + if (fromDay > toDay) { + return invalidInput(c, "fromDay must be on or before toDay"); + } + const spanDays = isoDaySpan(fromDay, toDay); + if (spanDays === null) { + return invalidInput(c, "fromDay and toDay must be valid calendar days"); + } + if (spanDays > 92) { + return invalidInput(c, "revenue range must be 92 days or less"); + } + + try { + const result = await client.query( + api.subscriptions.query.getRevenueMetrics, + { + apiKey, + fromDay, + toDay, + }, + ); + return c.json(result); + } catch (error) { + return subscriptionRouteError( + c, + error, + "SUBSCRIPTION_REVENUE_FAILED", + "Subscription revenue lookup failed", + ); + } +}); + subscriptions.post("/bind-user/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); let body: unknown; @@ -365,6 +405,36 @@ function isNonBlankString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +function isIsoDay(value: unknown): value is string { + return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value); +} + +function isoDaySpan(fromDay: string, toDay: string): number | null { + const fromMs = isoDayStartMs(fromDay); + const toMs = isoDayStartMs(toDay); + if (fromMs === null || toMs === null) return null; + return Math.round((toMs - fromMs) / 86_400_000) + 1; +} + +function isoDayStartMs(day: string): number | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); + if (!match) return null; + + const year = Number(match[1]); + const month = Number(match[2]); + const date = Number(match[3]); + const ms = Date.UTC(year, month - 1, date); + const parsed = new Date(ms); + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month - 1 || + parsed.getUTCDate() !== date + ) { + return null; + } + return ms; +} + function invalidInput(c: Context, message: string) { return c.json({ errors: [{ code: "INVALID_INPUT", message }] }, 400); } diff --git a/packages/kit/server/mcp.test.ts b/packages/kit/server/mcp.test.ts new file mode 100644 index 00000000..4bc3f973 --- /dev/null +++ b/packages/kit/server/mcp.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { handleIapKitMcpRequest } from "./mcp"; + +describe("IAPKit MCP route handler", () => { + it("initializes a Codex-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).toContain("iapkit_revenue_analytics"); + expect(toolNames).toContain("iapkit_sync_products"); + expect(toolNames).toContain("iapkit_sync_status"); + 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 { + 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 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) { + 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..b4e433ed --- /dev/null +++ b/packages/kit/server/mcp.ts @@ -0,0 +1,4 @@ +import { createIapKitWebMcpHandler } from "@hyodotdev/openiap-mcp-server/web"; + +/** Handles MCP HTTP requests for the Kit-hosted IAPKit MCP endpoint. */ +export const handleIapKitMcpRequest = createIapKitWebMcpHandler(); diff --git a/packages/kit/server/server.ts b/packages/kit/server/server.ts index 8ac87b2f..302596d5 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); +// Codex / MCP plugin 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/components/FreeTransitionNotice.test.tsx b/packages/kit/src/components/FreeTransitionNotice.test.tsx index dd0608b3..979590ed 100644 --- a/packages/kit/src/components/FreeTransitionNotice.test.tsx +++ b/packages/kit/src/components/FreeTransitionNotice.test.tsx @@ -7,7 +7,7 @@ import { MemoryRouter } from "react-router-dom"; import { FreeTransitionNotice } from "./FreeTransitionNotice"; const DISMISS_KEY = "iapkit.freeTransitionNoticeDismissed.v2"; -const TITLE = "IAPKit is now free for everyone."; +const TITLE = "IAPKit validation APIs are now free."; const MESSAGE_PREFIX = "Thank you for supporting IAPKit."; function renderNotice(hadBillingRelationship: boolean) { diff --git a/packages/kit/src/components/FreeTransitionNotice.tsx b/packages/kit/src/components/FreeTransitionNotice.tsx index 260d4318..e6990e7d 100644 --- a/packages/kit/src/components/FreeTransitionNotice.tsx +++ b/packages/kit/src/components/FreeTransitionNotice.tsx @@ -65,12 +65,12 @@ export function FreeTransitionNotice({

    - {"IAPKit is now free for everyone."} + {"IAPKit validation APIs are now free."}

    { - "Thank you for supporting IAPKit. Your subscription has been cancelled and any unused portion refunded in full — there's nothing you need to do. The validation APIs you were using keep working, without limits." + "Thank you for supporting IAPKit. Your subscription has been cancelled and any unused portion refunded in full — there's nothing you need to do. The validation APIs and analytics you were using keep working." }

    diff --git a/packages/kit/src/pages/auth/index.tsx b/packages/kit/src/pages/auth/index.tsx index 93812c49..9c4ede3c 100644 --- a/packages/kit/src/pages/auth/index.tsx +++ b/packages/kit/src/pages/auth/index.tsx @@ -112,14 +112,7 @@ export default function AuthenticatedPages() { return ( - - - - } - > + }> {docsChildRoutes} }> diff --git a/packages/kit/src/pages/auth/organization/usage.tsx b/packages/kit/src/pages/auth/organization/usage.tsx index a4adf079..b81094b6 100644 --- a/packages/kit/src/pages/auth/organization/usage.tsx +++ b/packages/kit/src/pages/auth/organization/usage.tsx @@ -49,7 +49,9 @@ export default function OrganizationUsagePage() {

    {"Usage"}

    - {"Track your monthly API usage. All validations are free."} + { + "Track validation usage and stored receipt analytics. Validation and analytics are free." + }

    @@ -86,7 +88,12 @@ export default function OrganizationUsagePage() {

    { - "IAPKit is free for everyone. If your team or company depends on it, consider supporting the project so we can keep it running for thousands of indie developers." + "Validation and analytics stay free for every developer. If your team or company depends on them, consider supporting the project so we can keep the core service running for thousands of indie developers." + } +

    +

    + { + "AI-assisted workflows may later use separate usage-based pricing because model token costs are real infrastructure costs." }

    = [ { - q: "Is IAPKit really free?", - a: "Yes. Every receipt-validation API is free for all developers. No credit card, no paywall, no monthly plan, no usage limits. Indie projects and large commercial apps have the same access — everyone pays the same: nothing.", + q: "Which parts of IAPKit are free?", + a: "Receipt validation and analytics are free for all developers. No credit card, no monthly plan, and no validation paywall. AI-assisted workflows may later use separate usage-based pricing because model token costs are real infrastructure costs.", }, { q: "Why is IAPKit joining OpenIAP?", - a: "Receipt validation is the ground floor of in-app purchases — not a premium feature. Keeping it behind a paywall was out of step with what OpenIAP stands for. Moving IAPKit under OpenIAP and making the API free removes one more wall from an already fragmented IAP ecosystem.", + a: "Receipt validation is the ground floor of in-app purchases — not a premium feature. Keeping validation behind a paywall was out of step with what OpenIAP stands for. Moving IAPKit under OpenIAP and making validation and analytics free removes one more wall from an already fragmented IAP ecosystem.", }, { q: "What happens to existing paying customers?", @@ -92,7 +92,7 @@ export default function IapkitJoinsOpenIap() { title: post?.title ?? "IAPKit joins OpenIAP", description: post?.description ?? - "IAPKit is joining OpenIAP and going completely free. No paywall, no plans, no usage limits.", + "IAPKit is joining OpenIAP. Receipt validation and analytics are free.", canonicalPath: `/blog/${SLUG}`, ogType: "article", keywords: post?.keywords, @@ -109,7 +109,7 @@ export default function IapkitJoinsOpenIap() { {post?.readingTime}

    - IAPKit joins OpenIAP. The API is now free for everyone. + IAPKit joins OpenIAP. Validation is now free.

    @@ -164,8 +164,8 @@ export default function IapkitJoinsOpenIap() {

    - And with this transition, the IAPKit API is becoming{" "} - completely free. + And with this transition, IAPKit receipt validation and analytics are + becoming free for every developer.

    Why we're doing this

    @@ -176,18 +176,19 @@ export default function IapkitJoinsOpenIap() {

    - Keeping it behind a paywall made sense as an early-stage business - decision — but over time, it became increasingly out of step with what - OpenIAP stands for. Charging for the most foundational building block - of IAP was adding one more wall to the very fragmentation we set out - to fix. + Keeping validation behind a paywall made sense as an early-stage + business decision — but over time, it became increasingly out of step + with what OpenIAP stands for. Charging for the most foundational + building block of IAP was adding one more wall to the very + fragmentation we set out to fix.

    So we changed the model:

    diff --git a/packages/kit/src/pages/blog/posts.ts b/packages/kit/src/pages/blog/posts.ts index 825bb2fd..0013c5eb 100644 --- a/packages/kit/src/pages/blog/posts.ts +++ b/packages/kit/src/pages/blog/posts.ts @@ -20,13 +20,13 @@ export const BLOG_AUTHOR = { export const POSTS: BlogPost[] = [ { slug: "iapkit-joins-openiap", - title: "IAPKit joins OpenIAP. The API is now free for everyone.", + title: "IAPKit joins OpenIAP. Validation is now free.", date: "2026-04-22", readingTime: "4 min read", excerpt: - "We're moving IAPKit under OpenIAP and dropping every paywall. Receipt validation is the ground floor of IAP — it shouldn't be a tier you pay for.", + "We're moving IAPKit under OpenIAP and making receipt validation and analytics free. Receipt validation is the ground floor of IAP — it shouldn't be a tier you pay for.", description: - "IAPKit is joining OpenIAP and going completely free. No paywall, no plans, no usage limits on App Store, Google Play, and Meta Horizon receipt validation.", + "IAPKit is joining OpenIAP. App Store, Google Play, and Meta Horizon receipt validation and analytics are free, while future AI-assisted workflows may use separate usage-based pricing.", keywords: [ "IAPKit", "OpenIAP", diff --git a/packages/kit/src/pages/docs/DocsLayout.tsx b/packages/kit/src/pages/docs/DocsLayout.tsx index 7f404061..2c09858a 100644 --- a/packages/kit/src/pages/docs/DocsLayout.tsx +++ b/packages/kit/src/pages/docs/DocsLayout.tsx @@ -2,9 +2,12 @@ import { useEffect, useState } from "react"; import { Link, Outlet, useLocation } from "react-router-dom"; import { ChevronLeft, ChevronRight, Menu, X } from "lucide-react"; -import { DOCS_NAV, type DocsNavEntry } from "./nav"; +import { DOCS_NAV, flattenDocsNav, type DocsNavEntry } from "./nav"; +import { usePageTitle } from "@/hooks/usePageTitle"; import { MixpanelEvent, trackEvent } from "@/lib/mixpanel"; +const FLATTENED_DOCS_NAV = flattenDocsNav(DOCS_NAV); + /** * Docs shell. The container claims the full viewport and splits into * two independent scroll columns: the sidebar on the left owns its @@ -19,6 +22,7 @@ import { MixpanelEvent, trackEvent } from "@/lib/mixpanel"; * no longer hosts the docs. */ export default function DocsLayout() { + const location = useLocation(); const [mobileOpen, setMobileOpen] = useState(false); const [collapsed, setCollapsed] = useState(() => { if (typeof window === "undefined") return false; @@ -53,6 +57,8 @@ export default function DocsLayout() { trackEvent(MixpanelEvent.ViewedDocs); }, []); + usePageTitle(titleForDocsPath(location.pathname)); + return (
    {/* Desktop sidebar — own scroll column, fixed viewport height, @@ -173,6 +179,20 @@ function DocsNavTree({ onNavigate }: { onNavigate?: () => void }) { ); } +function titleForDocsPath(pathname: string): string { + const marker = "/docs"; + const docsIndex = pathname.indexOf(marker); + if (docsIndex === -1) return "Documentation"; + + const slug = pathname + .slice(docsIndex + marker.length) + .replace(/^\/+|\/+$/g, ""); + return ( + FLATTENED_DOCS_NAV.find((entry) => entry.slug === slug)?.title ?? + "Documentation" + ); +} + function DocsNavRow({ entry, pathname, diff --git a/packages/kit/src/pages/docs/nav.ts b/packages/kit/src/pages/docs/nav.ts index 4bdf8310..d417a590 100644 --- a/packages/kit/src/pages/docs/nav.ts +++ b/packages/kit/src/pages/docs/nav.ts @@ -70,7 +70,15 @@ 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 Codex plugin setup.", + children: [ + { + slug: "ai-assistants/codex-plugin", + title: "Codex plugin", + summary: + "Kit endpoint and key reference; full MCP guide lives in OpenIAP docs.", + }, + ], }, { slug: "release-notes", diff --git a/packages/kit/src/pages/docs/routes.tsx b/packages/kit/src/pages/docs/routes.tsx index bd0fed17..dfb92db4 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 CodexPluginPage from "./sections/codex-plugin"; import ReleaseNotesPage from "./sections/release-notes"; /** @@ -45,6 +46,7 @@ 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..5d683309 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() {

    +

    Codex plugin

    +

    + Codex can use IAPKit as an MCP-backed plugin through{" "} + https://kit.openiap.dev/mcp. The plugin uses your IAPKit + project API key, not an OpenAI or ChatGPT API key. See the{" "} + + Codex plugin guide + {" "} + for the setup flow, self-hosted option, and tool list. +

    +

    Using the files

    From a prompt

    diff --git a/packages/kit/src/pages/docs/sections/codex-plugin.tsx b/packages/kit/src/pages/docs/sections/codex-plugin.tsx new file mode 100644 index 00000000..7bc1a34e --- /dev/null +++ b/packages/kit/src/pages/docs/sections/codex-plugin.tsx @@ -0,0 +1,95 @@ +import { DOCS_URL } from "../../../config/env"; +import { Callout } from "../components/Callout"; +import { DocsPage } from "../components/DocsPage"; + +const MCP_SERVER_GUIDE_URL = `${DOCS_URL}/docs/guides/mcp-server`; + +export default function CodexPluginPage() { + return ( + +

    + The OpenIAP Codex plugin connects Codex to this IAPKit project through + the hosted /mcp endpoint. Use this page for the Kit-local + endpoint and key details; use the OpenIAP MCP Server guide for the full + installation flow, local PR testing, tool list, safety rules, and + Example App walkthrough. +

    + + +

    + For the full setup guide, local PR testing steps, tool list, safety + notes, and Example App walkthrough, open{" "} + + /docs/guides/mcp-server + + . +

    +
    + + +

    + This OpenIAP plugin is experimental. The MCP endpoint, tool names, and + setup flow are available for early testing and may continue to evolve. +

    +
    + + +

    + Do not use an OpenAI or ChatGPT API key for this plugin. + Authentication is an IAPKit project API key sent as{" "} + Authorization: Bearer <IAPKit project key> or + provided to a private MCP server as IAPKIT_API_KEY. +

    +
    + +
    +

    Plugin settings

    +
    +
    +
    + Remote MCP URL +
    + + https://kit.openiap.dev/mcp + +
    +
    +
    + Authentication +
    + + Bearer token = IAPKit project API key + +
    +
    +
    + Tool prefix +
    + + iapkit_* tools through the OpenIAP plugin + +
    +
    +
    + +

    + Start with a read-only Codex prompt and keep product writes behind + review. Store sync jobs should begin with dryRun: true; + approve live writes only after checking the proposed platform, product + id, price, and billing period in the OpenIAP MCP Server guide. +

    +
    + ); +} diff --git a/packages/kit/src/pages/docs/sections/release-notes.tsx b/packages/kit/src/pages/docs/sections/release-notes.tsx index c083e459..80d21471 100644 --- a/packages/kit/src/pages/docs/sections/release-notes.tsx +++ b/packages/kit/src/pages/docs/sections/release-notes.tsx @@ -28,15 +28,19 @@ const RELEASES: ReleaseEntry[] = [ { version: "1.2.0", date: "2026-04-22", - tagline: "IAPKit joins OpenIAP. API is now free for everyone, no paywall.", + tagline: "IAPKit joins OpenIAP. Validation and analytics are now free.", items: [ { kind: "feature", - text: "IAPKit is now an OpenIAP project. Receipt validation is free for every developer, no usage caps, no credit card required.", + text: "IAPKit is now an OpenIAP project. Receipt validation and analytics are free for every developer, no credit card required.", }, { kind: "feature", - text: "Community sponsorship replaces paid plans. If your team or company depends on IAPKit, support the project at openiap.dev/sponsors.", + text: "Community sponsorship replaces paid validation plans. If your team or company depends on IAPKit, support the project at openiap.dev/sponsors.", + }, + { + kind: "ops", + text: "AI-assisted workflows may later use separate usage-based pricing because model token costs are real infrastructure costs.", }, { kind: "feature", @@ -44,11 +48,11 @@ const RELEASES: ReleaseEntry[] = [ }, { kind: "feature", - text: "Previous paying customers were migrated to the free tier automatically. Any remaining balance was refunded in full.", + text: "Previous paying customers were migrated to free validation access automatically. Any remaining balance was refunded in full.", }, { kind: "ops", - text: "Dropped Stripe + paid plans entirely. Dashboard now has a single 'Usage' tab that shows monthly verifications and a sponsor nudge.", + text: "Dropped Stripe + paid validation plans. Dashboard now has a single 'Usage' tab that shows monthly verifications and a sponsor nudge.", }, { kind: "fix", diff --git a/packages/kit/src/pages/index.tsx b/packages/kit/src/pages/index.tsx index 0239ad60..a392f1bc 100644 --- a/packages/kit/src/pages/index.tsx +++ b/packages/kit/src/pages/index.tsx @@ -52,14 +52,7 @@ export default function PublicPages() { The public navigation header wrapping everything else was both stealing sidebar height and double-scrolling the content column. */} - - - - } - > + }> {docsChildRoutes} }> diff --git a/packages/kit/src/pages/landing.tsx b/packages/kit/src/pages/landing.tsx index 935d3464..3c761e6d 100644 --- a/packages/kit/src/pages/landing.tsx +++ b/packages/kit/src/pages/landing.tsx @@ -79,13 +79,9 @@ export default function LandingPage() { , verifies your App Store and Google Play receipts so you don't have to.

    - {/* Free-forever banner — PR #5 dropped every paid tier, and - the primary CTA used to read "Try IAPKit for free" which - reads like a trial that escalates. This standalone line - leaves no room to assume an upsell later. */}

    - {"IAPKit is 100% free for everyone."} + {"Validation and analytics are free for every developer."}

    - {"Free forever · No credit card, ever"} + {"No credit card for validation or analytics."}

    @@ -163,7 +159,7 @@ export default function LandingPage() {

    { - "Search any receipt, inspect payloads, and re-run validations without digging through logs." + "Search receipts, inspect payloads, review analytics, and re-run validations without digging through logs." }

    @@ -255,7 +251,12 @@ export default function LandingPage() {

    { - "IAPKit is free for everyone. If your team depends on it, help sustain the project — every contribution keeps it free for thousands of indie developers." + "Core IAPKit validation and analytics are free for every developer. If your team depends on them, help sustain the project — every contribution keeps the foundation available for thousands of indie developers." + } +

    +

    + { + "Advanced AI-assisted workflows may be handled separately later, because model token costs are real infrastructure costs." }

    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..9d3da036 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 Codex and other MCP clients into IAPKit's product, subscription, revenue, 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..8ac02086 --- /dev/null +++ b/packages/mcp-server/src/http.ts @@ -0,0 +1,407 @@ +#!/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 MCP_BODY_TOO_LARGE_ERROR = "MCP request body is too large"; +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 { + /** Interface address to bind. Defaults to HOST, then 0.0.0.0. */ + host?: string; + /** TCP port to bind. Defaults to PORT, IAPKIT_MCP_PORT, then 3939. */ + port?: number; + /** MCP endpoint path. Defaults to /mcp. */ + mcpPath?: string; + /** CORS allow-list. Defaults to IAPKIT_MCP_ALLOWED_ORIGINS or local/Codex origins. */ + allowedOrigins?: string[]; + /** Logger for lifecycle and request failures. Defaults to console. */ + logger?: Pick; +} + +/** Runtime handle for an IAPKit remote MCP HTTP server. */ +export interface RemoteMcpHttpServer { + /** Node HTTP server instance, unbound until listen/start is called. */ + server: NodeHttpServer; + /** Closes MCP transports first, then the HTTP listener. */ + close: () => Promise; +} + +/** + * Creates an IAPKit Streamable HTTP MCP server without binding a socket. + * + * @param options Server configuration. + * @returns A server handle whose `server` can be bound by the caller. + */ +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", + ], + }); + 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, + ); + return; + } + + if (req.method === "GET" || req.method === "DELETE") { + await handleExistingMcpSession(req, res, transports); + return; + } + + 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"); + } + } + }); + + 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 }; +} + +/** + * Creates and starts the IAPKit Streamable HTTP MCP server. + * + * @param options Server configuration. + * @returns A bound server handle. + * @throws When the configured port/host is invalid or the listener emits an error. + */ +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, reject) => { + const onError = (error: Error) => { + remote.server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + remote.server.off("error", onError); + resolve(); + }; + + remote.server.once("error", onError); + remote.server.listen(port, host, onListening); + }); + + (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, +): 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(); + 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_BODY_TOO_LARGE_ERROR); + } + chunks.push(buffer); + } + + const raw = Buffer.concat(chunks).toString("utf8"); + 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 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..0eb420da 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,588 +1,9 @@ #!/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"; - -// 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 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); -} +import { createIapKitMcpServer } from "./mcp.js"; +const server = createIapKitMcpServer(); const transport = new StdioServerTransport(); + await server.connect(transport); diff --git a/packages/mcp-server/src/kit-client.ts b/packages/mcp-server/src/kit-client.ts index a0fcd3e0..40987792 100644 --- a/packages/mcp-server/src/kit-client.ts +++ b/packages/mcp-server/src/kit-client.ts @@ -123,6 +123,32 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { mrrMicros: number; currency?: string; }>(`/v1/subscriptions/metrics/${encodeURIComponent(apiKey)}`), + revenueMetrics: (params: { fromDay: string; toDay: string }) => { + const usp = new URLSearchParams({ + fromDay: params.fromDay, + toDay: params.toDay, + }); + return call<{ + days: Array<{ + day: string; + currency: string; + productId: string; + platform: "IOS" | "Android"; + activeSubs: number; + newSubs: number; + renewals: number; + cancellations: number; + refunds: number; + revenueMicros: number; + }>; + currencies: string[]; + productIds: string[]; + platforms: Array<"IOS" | "Android">; + truncated: boolean; + }>( + `/v1/subscriptions/revenue/${encodeURIComponent(apiKey)}?${usp.toString()}`, + ); + }, listProducts: (params: { platform?: "IOS" | "Android" } = {}) => { const usp = new URLSearchParams(); if (params.platform) usp.set("platform", params.platform); @@ -156,6 +182,25 @@ export function kitClient({ baseUrl, apiKey }: KitClientOptions) { `/v1/products/${encodeURIComponent(apiKey)}/state`, { method: "POST", body: JSON.stringify(params) }, ), + syncProducts: (params: { + platform: "IOS" | "Android"; + direction: "pull" | "push" | "both" | "purge-local"; + dryRun: boolean; + }) => { + const platformPath = params.platform === "IOS" ? "ios" : "android"; + const usp = new URLSearchParams({ + direction: params.direction, + dryRun: String(params.dryRun), + }); + return call<{ jobId: string; deduped?: boolean }>( + `/v1/products/${encodeURIComponent(apiKey)}/sync/${platformPath}?${usp.toString()}`, + { method: "POST" }, + ); + }, + syncJob: (jobId: string) => + call( + `/v1/products/${encodeURIComponent(apiKey)}/sync/jobs/${encodeURIComponent(jobId)}`, + ), health: () => call<{ ok: boolean }>("/health"), }; } diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts new file mode 100644 index 00000000..681e8002 --- /dev/null +++ b/packages/mcp-server/src/mcp.ts @@ -0,0 +1,1147 @@ +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"; + +// MCP server for IAPKit. Every tool funnels through `withClient` +// so Authorization bearer / IAPKIT_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"; +const IAPKIT_TOOL_PREFIX = "iapkit"; +const SETUP_FRAMEWORKS = [ + "expo", + "react-native", + "flutter", + "kmp", + "godot", + "ios", + "android", +] as const; + +type ToolExtra = RequestHandlerExtra; +type SetupFramework = (typeof SETUP_FRAMEWORKS)[number]; + +const OPTIONAL_BASE_URL = z + .string() + .url() + .optional() + .describe( + "Override IAPKit base URL. Defaults to IAPKIT_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: true, + 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 ISO_DAY_PARAM = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD"); +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.", +); + +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 resolveApiKey( + opts: { apiKey?: string; baseUrl?: string }, + extra?: ToolExtra, +): string | undefined { + return opts.apiKey ?? extra?.authInfo?.token ?? process.env.IAPKIT_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, 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, + }); +} + +function ok(payload: unknown) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(payload, null, 2), + }, + ], + }; +} + +function err(error: unknown, apiKey?: string) { + const detail = + error instanceof KitHttpError + ? { + status: error.status, + body: redactSecrets(error.body, apiKey), + message: redactSecrets(error.message, apiKey), + } + : { + message: redactSecrets( + error instanceof Error ? error.message : String(error), + apiKey, + ), + }; + return { + isError: true, + content: [ + { + type: "text" as const, + text: JSON.stringify({ ok: false, error: detail }, null, 2), + }, + ], + }; +} + +function redactSecrets(value: unknown, apiKey?: string): unknown { + if (typeof value === "string") { + return redactSecretString(value, apiKey); + } + if (Array.isArray(value)) { + return value.map((item) => redactSecrets(item, apiKey)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + redactSecrets(item, apiKey), + ]), + ); + } + return value; +} + +function redactSecretString(value: string, apiKey?: string): string { + const knownSecrets = [apiKey, process.env.IAPKIT_API_KEY].filter( + (secret): secret is string => Boolean(secret?.trim()), + ); + 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|revenue)|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, + localName: string, + description: string, + schema: Record, + annotations: ToolAnnotations, + handler: (args: any, extra: ToolExtra) => unknown, +) { + server.tool( + `${IAPKIT_TOOL_PREFIX}_${localName}`, + description, + schema, + annotations, + handler as any, + ); +} + +/** + * Creates the IAPKit MCP server and registers the `iapkit_*` tool surface. + * + * The returned server is configured with the package name/version metadata and + * `https://kit.openiap.dev` as its website URL. Tool registration is performed + * before returning so callers can connect the server directly to stdio, HTTP, + * or web-standard MCP transports. + * + * @returns An `McpServer` instance with all IAPKit tools registered. + */ +export function createIapKitMcpServer(): McpServer { + const server = new McpServer({ + name: IAPKIT_MCP_SERVER_NAME, + version: IAPKIT_MCP_SERVER_VERSION, + websiteUrl: "https://kit.openiap.dev", + }); + registerIapKitTools(server); + return server; +} + +function registerIapKitTools(server: McpServer) { + // --------------------------------------------------------------------------- + // 1. setup — generate per-framework integration snippet. + // --------------------------------------------------------------------------- + registerTool( + server, + "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(SETUP_FRAMEWORKS) + .describe("Which framework SDK or native platform 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, + "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, resolveApiKey(args, extra)); + } + }, + ); + + // --------------------------------------------------------------------------- + // 3. troubleshoot — quick diagnostics. + // --------------------------------------------------------------------------- + registerTool( + server, + "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.apiKey) })), + client + .metrics() + .catch((e) => ({ error: stringifyError(e, client.apiKey) })), + ]); + // 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.apiKey) })), + client + .entitlements(args.sampleUserId) + .catch((e) => ({ error: stringifyError(e, client.apiKey) })), + ]).then(([status, entitlements]) => ({ status, entitlements })) + : null; + return ok({ health, metrics, userProbe }); + } catch (error) { + return err(error, resolveApiKey(args, extra)); + } + }, + ); + + // --------------------------------------------------------------------------- + // 4. create_product — upsert a product in kit's catalog. + // --------------------------------------------------------------------------- + registerTool( + server, + "create_product", + "Add or update a product in IAPKit's local catalog. Note: this creates the IAPKit-side row only — use `iapkit_sync_products` with direction=push or both after store credentials are configured to enqueue App Store Connect / Play Console sync.", + { + 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", + ), + resolveApiKey(args, extra), + ); + } + 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, resolveApiKey(args, extra)); + } + }, + ); + + // --------------------------------------------------------------------------- + // 5. list_products — read kit's product catalog. + // --------------------------------------------------------------------------- + registerTool( + server, + "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, resolveApiKey(args, extra)); + } + }, + ); + + // --------------------------------------------------------------------------- + // 6. view_subscribers — paginated subscription list for the dashboard. + // --------------------------------------------------------------------------- + registerTool( + server, + "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, resolveApiKey(args, extra)); + } + }, + ); + + // --------------------------------------------------------------------------- + // 7. simulate_purchase — print sandbox-purchase guidance per platform. + // --------------------------------------------------------------------------- + registerTool( + server, + "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, + "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) => { + if (args.platform === "Android") { + const apiKey = + args.apiKey ?? extra?.authInfo?.token ?? process.env.IAPKIT_API_KEY; + if (!apiKey) return err(new Error("apiKey required")); + const validationError = validateApiKey(apiKey); + if (validationError) return err(new Error(validationError), apiKey); + let baseUrl: string; + try { + baseUrl = normalizeKitBaseUrl( + args.baseUrl ?? process.env.IAPKIT_BASE_URL, + ); + } catch (error) { + return err(error, apiKey); + } + const message = { + version: "1.0", + packageName: "com.example.app", + eventTimeMillis: Date.now(), + testNotification: { version: "1.0" }, + }; + const data = base64EncodeUtf8(JSON.stringify(message)); + 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}`, + ), + apiKey, + ); + } + return ok({ status: response.status, body: responseBody }); + } catch (error) { + return err(error, apiKey); + } + } + 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, + "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.apiKey) })), + client + .listProducts() + .catch((e) => ({ error: stringifyError(e, client.apiKey) })), + ]); + return ok({ + metrics, + products, + webhookUrls: { + lifecycle: `${client.baseUrl}/v1/webhooks/${API_KEY_PLACEHOLDER}`, + stream: `${client.baseUrl}/v1/webhooks/stream/${API_KEY_PLACEHOLDER}`, + }, + note: "Use webhookUrls.lifecycle for both Apple ASN v2 and Google Pub/Sub RTDN. URLs use an IAPKIT_API_KEY placeholder so tool output does not leak project credentials.", + }); + } catch (error) { + return err(error, resolveApiKey(args, extra)); + } + }, + ); + + // --------------------------------------------------------------------------- + // 10. revenue_analytics — answer purchase / revenue questions. + // --------------------------------------------------------------------------- + registerTool( + server, + "revenue_analytics", + "Summarize IAPKit subscription purchase and revenue analytics for a date range. Defaults to the current UTC month so Codex can answer questions like 'how many purchases happened this month?'.", + { + period: z + .enum(["this_month", "last_30_days", "last_90_days", "custom"]) + .optional() + .describe( + "Date window to summarize. Use custom with fromDay and toDay for an explicit range.", + ), + fromDay: ISO_DAY_PARAM.optional().describe( + "Inclusive UTC day for custom ranges, YYYY-MM-DD.", + ), + toDay: ISO_DAY_PARAM.optional().describe( + "Inclusive UTC day for custom ranges, YYYY-MM-DD.", + ), + productId: PRODUCT_ID_PARAM.optional(), + platform: z.enum(["IOS", "Android"]).optional(), + currency: z.string().optional(), + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + READ_ONLY_TOOL, + async (args, extra) => { + try { + const range = resolveRevenueRange(args); + const metrics = await withClient(args, extra).revenueMetrics(range); + return ok( + summarizeRevenueMetrics(metrics, { + ...range, + productId: args.productId, + platform: args.platform, + currency: args.currency, + }), + ); + } catch (error) { + return err(error, resolveApiKey(args, extra)); + } + }, + ); + + // --------------------------------------------------------------------------- + // 11. manage_product — disable / refresh a product entry. + // --------------------------------------------------------------------------- + registerTool( + server, + "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, resolveApiKey(args, extra)); + } + }, + ); + + // --------------------------------------------------------------------------- + // 12. sync_products — enqueue App Store / Play Console sync. + // --------------------------------------------------------------------------- + registerTool( + server, + "sync_products", + "Enqueue an IAPKit product sync job for App Store Connect or Google Play. Use dryRun=true first to inspect what Codex would change; set dryRun=false only when the user explicitly asks to apply the store sync.", + { + platform: z.enum(["IOS", "Android"]), + direction: z + .enum(["pull", "push", "both", "purge-local"]) + .optional() + .describe( + "pull imports from the store, push writes IAPKit catalog rows to the store, both does both, purge-local removes local rows missing from the store.", + ), + dryRun: z + .boolean() + .optional() + .describe("Defaults to true so Codex previews store changes first."), + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + WRITE_TOOL, + async (args, extra) => { + try { + return ok( + await withClient(args, extra).syncProducts({ + platform: args.platform, + direction: args.direction ?? "both", + dryRun: args.dryRun ?? true, + }), + ); + } catch (error) { + return err(error, resolveApiKey(args, extra)); + } + }, + ); + + // --------------------------------------------------------------------------- + // 13. sync_status — poll a product sync job. + // --------------------------------------------------------------------------- + registerTool( + server, + "sync_status", + "Return the current state and log summary for a previously enqueued IAPKit product sync job.", + { + jobId: kitTextParam("jobId", MAX_KIT_ID_LENGTH), + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + READ_ONLY_TOOL, + async (args, extra) => { + try { + return ok(await withClient(args, extra).syncJob(args.jobId)); + } catch (error) { + return err(error, resolveApiKey(args, extra)); + } + }, + ); +} + +function renderSetupSnippet( + framework: SetupFramework, + apiKey: string, + productId: string, +): string { + const apiKeyLiteral = codeStringLiteral(apiKey); + const productIdLiteral = codeStringLiteral(productId); + + switch (framework) { + case "expo": + case "react-native": + return `import { useEffect } from 'react'; +import { useIAP, useWebhookEvents, type WebhookEventStream } from '${framework === "expo" ? "expo-iap" : "react-native-iap"}'; +import EventSource from 'react-native-sse'; + +const productId = ${productIdLiteral}; + +function createWebhookEventSource( + url: string, + headers: Record, +): WebhookEventStream { + const source = new EventSource(url, { headers }); + const stream: WebhookEventStream = { + onmessage: null, + onerror: null, + close: () => source.close(), + addEventListener: (type, listener) => { + source.addEventListener(type, (event) => { + listener({ + data: event.data ?? '', + lastEventId: event.lastEventId ?? undefined, + }); + }); + }, + }; + source.addEventListener('error', (event) => stream.onerror?.(event)); + return stream; +} + +export function useOpenIapPremium() { + useWebhookEvents({ + apiKey: ${apiKeyLiteral}, + eventSourceFactory: createWebhookEventSource, + onEvent: (event) => { + if (event.type === 'SubscriptionRenewed') grantEntitlement(event.purchaseToken); + }, + }); + + const { fetchProducts, requestPurchase } = useIAP(); + + useEffect(() => { + void fetchProducts({ skus: [productId], type: 'in-app' }); + }, [fetchProducts]); + + const buyPremium = () => + requestPurchase({ + request: { + ios: { sku: productId }, + android: { skus: [productId] }, + }, + type: 'in-app', + }); + + return { buyPremium }; +}`; + 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})`; + case "ios": + return `import OpenIAP + +let iapkitApiKey = ${apiKeyLiteral} +let productId = ${productIdLiteral} +let iapStore = OpenIapStore() + +try await iapStore.initConnection() +try await iapStore.fetchProducts(skus: [productId], type: .inApp) + +if let purchase = try await iapStore.requestPurchase(sku: productId, type: .inApp), + let jws = purchase.purchaseToken { + let verification = try await iapStore.verifyPurchaseWithProvider( + VerifyPurchaseWithProviderProps( + iapkit: RequestVerifyPurchaseWithIapkitProps( + apiKey: iapkitApiKey, + apple: RequestVerifyPurchaseWithIapkitAppleProps(jws: jws), + google: nil + ), + provider: .iapkit + ) + ) + if verification?.isValid == true { + grantEntitlement(purchase.productId) + } +}`; + case "android": + return `import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.ProductQueryType +import dev.hyo.openiap.ProductRequest +import dev.hyo.openiap.PurchaseVerificationProvider +import dev.hyo.openiap.RequestPurchaseAndroidProps +import dev.hyo.openiap.RequestPurchaseProps +import dev.hyo.openiap.RequestPurchasePropsByPlatforms +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitGoogleProps +import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps +import dev.hyo.openiap.VerifyPurchaseWithProviderProps +import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import kotlinx.coroutines.launch + +class MainActivity : AppCompatActivity() { + private val openIap by lazy { OpenIapModule(this) } + private val iapkitApiKey = ${apiKeyLiteral} + private val productId = ${productIdLiteral} + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + openIap.setActivity(this) + openIap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { purchase -> + lifecycleScope.launch { + val token = purchase.purchaseToken ?: return@launch + val verification = openIap.verifyPurchaseWithProvider( + VerifyPurchaseWithProviderProps( + iapkit = RequestVerifyPurchaseWithIapkitProps( + apiKey = iapkitApiKey, + google = RequestVerifyPurchaseWithIapkitGoogleProps( + purchaseToken = token + ) + ), + provider = PurchaseVerificationProvider.Iapkit + ) + ).iapkit + if (verification?.isValid == true) { + grantEntitlement(purchase.productId) + } + } + }) + + lifecycleScope.launch { + openIap.initConnection(null) + openIap.fetchProducts( + ProductRequest(skus = listOf(productId), type = ProductQueryType.InApp) + ) + } + } + + fun buyPremium() { + lifecycleScope.launch { + openIap.setActivity(this@MainActivity) + openIap.requestPurchase( + RequestPurchaseProps( + request = RequestPurchaseProps.Request.Purchase( + RequestPurchasePropsByPlatforms( + google = RequestPurchaseAndroidProps(skus = listOf(productId)) + ) + ), + type = ProductQueryType.InApp + ) + ) + } + } +}`; + } +} + +function codeStringLiteral(value: string): string { + return JSON.stringify(value); +} + +function base64EncodeUtf8(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +type RevenueMetricsResponse = { + days: Array<{ + day: string; + currency: string; + productId: string; + platform: "IOS" | "Android"; + activeSubs: number; + newSubs: number; + renewals: number; + cancellations: number; + refunds: number; + revenueMicros: number; + }>; + currencies: string[]; + productIds: string[]; + platforms: Array<"IOS" | "Android">; + truncated: boolean; +}; + +function resolveRevenueRange(args: { + period?: "this_month" | "last_30_days" | "last_90_days" | "custom"; + fromDay?: string; + toDay?: string; +}): { fromDay: string; toDay: string; period: string } { + const period = args.period ?? "this_month"; + if (period === "custom") { + if (!args.fromDay || !args.toDay) { + throw new Error("fromDay and toDay are required when period is custom"); + } + if (args.fromDay > args.toDay) { + throw new Error("fromDay must be on or before toDay"); + } + return { fromDay: args.fromDay, toDay: args.toDay, period }; + } + + const now = new Date(); + const today = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); + + if (period === "this_month") { + return { + fromDay: formatUtcDay( + new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1)), + ), + toDay: formatUtcDay(today), + period, + }; + } + + return { + fromDay: formatUtcDay( + addUtcDays(today, period === "last_30_days" ? -29 : -89), + ), + toDay: formatUtcDay(today), + period, + }; +} + +function formatUtcDay(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function addUtcDays(date: Date, days: number): Date { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + days); + return next; +} + +function summarizeRevenueMetrics( + metrics: RevenueMetricsResponse, + filters: { + fromDay: string; + toDay: string; + period: string; + productId?: string; + platform?: "IOS" | "Android"; + currency?: string; + }, +) { + const rows = metrics.days.filter((row) => { + if (filters.productId && row.productId !== filters.productId) return false; + if (filters.platform && row.platform !== filters.platform) return false; + if (filters.currency && row.currency !== filters.currency) return false; + return true; + }); + + const totalsByCurrency = new Map< + string, + { + revenueMicros: number; + purchaseEvents: number; + newSubs: number; + renewals: number; + cancellations: number; + refunds: number; + } + >(); + const totalsByProduct = new Map< + string, + { purchaseEvents: number; revenueMicrosByCurrency: Record } + >(); + const totalsByPlatform = new Map< + "IOS" | "Android", + { purchaseEvents: number; revenueMicrosByCurrency: Record } + >(); + + for (const row of rows) { + const purchaseEvents = row.newSubs + row.renewals; + const currencyTotal = totalsByCurrency.get(row.currency) ?? { + revenueMicros: 0, + purchaseEvents: 0, + newSubs: 0, + renewals: 0, + cancellations: 0, + refunds: 0, + }; + currencyTotal.revenueMicros += row.revenueMicros; + currencyTotal.purchaseEvents += purchaseEvents; + currencyTotal.newSubs += row.newSubs; + currencyTotal.renewals += row.renewals; + currencyTotal.cancellations += row.cancellations; + currencyTotal.refunds += row.refunds; + totalsByCurrency.set(row.currency, currencyTotal); + + const productTotal = totalsByProduct.get(row.productId) ?? { + purchaseEvents: 0, + revenueMicrosByCurrency: {}, + }; + productTotal.purchaseEvents += purchaseEvents; + productTotal.revenueMicrosByCurrency[row.currency] = + (productTotal.revenueMicrosByCurrency[row.currency] ?? 0) + + row.revenueMicros; + totalsByProduct.set(row.productId, productTotal); + + const platformTotal = totalsByPlatform.get(row.platform) ?? { + purchaseEvents: 0, + revenueMicrosByCurrency: {}, + }; + platformTotal.purchaseEvents += purchaseEvents; + platformTotal.revenueMicrosByCurrency[row.currency] = + (platformTotal.revenueMicrosByCurrency[row.currency] ?? 0) + + row.revenueMicros; + totalsByPlatform.set(row.platform, platformTotal); + } + + return { + range: filters, + rowCount: rows.length, + totalsByCurrency: Object.fromEntries(totalsByCurrency), + totalsByProduct: Object.fromEntries(totalsByProduct), + totalsByPlatform: Object.fromEntries(totalsByPlatform), + availableFilters: { + currencies: metrics.currencies, + productIds: metrics.productIds, + platforms: metrics.platforms, + }, + truncated: metrics.truncated, + note: "purchaseEvents counts subscription starts plus renewals in IAPKit revenue rollups. Refunds and cancellations are reported separately.", + }; +} + +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, apiKey?: string): string { + const message = e instanceof Error ? e.message : String(e); + return String(redactSecrets(message, apiKey)); +} diff --git a/packages/mcp-server/src/web.ts b/packages/mcp-server/src/web.ts new file mode 100644 index 00000000..55c992c5 --- /dev/null +++ b/packages/mcp-server/src/web.ts @@ -0,0 +1,254 @@ +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 MCP_BODY_TOO_LARGE_ERROR = "MCP request body is too large"; +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[]; + 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, + 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) { + 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, + jsonRpcError(500, -32603, "Internal server error"), + allowedOrigins, + ); + } + }; +} + +async function handlePost( + request: Request, + transports: Map, + logger: Pick, + 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(); + 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_BODY_TOO_LARGE_ERROR); + } + + const raw = await request.clone().text(); + 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, + 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..29244055 --- /dev/null +++ b/packages/mcp-server/test/http.test.ts @@ -0,0 +1,655 @@ +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + createRemoteMcpHttpServer, + startRemoteMcpHttpServer, + type RemoteMcpHttpServer, +} from "../src/http"; + +let remote: RemoteMcpHttpServer | null = null; +let kitApi: Server | null = null; + +interface SetupToolPayload { + framework: string; + snippet: string; + note: string; +} + +afterEach(async () => { + if (remote) { + await remote.close(); + remote = null; + } + if (kitApi) { + await closeServer(kitApi); + kitApi = 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", + }, + authentication: [ + "Authorization: Bearer ", + "IAPKIT_API_KEY environment variable", + ], + }); + }); + + it("rejects when the configured listener cannot bind", async () => { + const occupied = createServer(); + + try { + await new Promise((resolve) => { + occupied.listen(0, "127.0.0.1", resolve); + }); + const port = (occupied.address() as AddressInfo).port; + + await expect( + startRemoteMcpHttpServer({ + host: "127.0.0.1", + port, + logger: { + error: () => undefined, + info: () => undefined, + }, + }), + ).rejects.toMatchObject({ code: "EADDRINUSE" }); + } finally { + await closeServer(occupied); + } + }); + + 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, + ); + const toolsByName = new Map( + listEvent.result.tools.map( + (tool: { + name: string; + annotations?: { + readOnlyHint?: boolean; + destructiveHint?: boolean; + }; + }) => [tool.name, tool], + ), + ); + + expect(toolNames).toContain("iapkit_inspect_state"); + expect(toolNames).toContain("iapkit_create_product"); + expect(toolNames).toContain("iapkit_revenue_analytics"); + expect(toolNames).toContain("iapkit_sync_products"); + expect(toolNames).toContain("iapkit_sync_status"); + expect(toolNames).not.toContain("openiap_inspect_state"); + expect( + toolsByName.get("iapkit_revenue_analytics")?.annotations, + ).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + }); + expect(toolsByName.get("iapkit_create_product")?.annotations).toMatchObject( + { + readOnlyHint: false, + destructiveHint: true, + }, + ); + }); + + it("summarizes revenue analytics through the bearer-authenticated Kit API", async () => { + const apiKey = "openiap-kit_secret_revenue"; + const previousBaseUrl = process.env.IAPKIT_BASE_URL; + process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => { + expect(req.method).toBe("GET"); + expect(req.url).toBe( + `/v1/subscriptions/revenue/${apiKey}?fromDay=2026-06-01&toDay=2026-06-04`, + ); + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + days: [ + { + day: "2026-06-01", + currency: "USD", + productId: "premium_monthly", + platform: "IOS", + activeSubs: 10, + newSubs: 2, + renewals: 3, + cancellations: 1, + refunds: 0, + revenueMicros: 4990000, + }, + ], + currencies: ["USD"], + productIds: ["premium_monthly"], + platforms: ["IOS"], + truncated: false, + }), + ); + }); + + try { + const { baseUrl, sessionId } = await initializeMcpSession(apiKey); + const response = await postMcp( + baseUrl, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "iapkit_revenue_analytics", + arguments: { + period: "custom", + fromDay: "2026-06-01", + toDay: "2026-06-04", + }, + }, + }, + sessionId, + { authorization: `Bearer ${apiKey}` }, + ); + const event = parseSseJson(await response.text()); + const payload = JSON.parse(event.result.content[0].text); + + expect(payload.totalsByCurrency.USD).toMatchObject({ + purchaseEvents: 5, + newSubs: 2, + renewals: 3, + revenueMicros: 4990000, + }); + } finally { + if (previousBaseUrl === undefined) delete process.env.IAPKIT_BASE_URL; + else process.env.IAPKIT_BASE_URL = previousBaseUrl; + } + }); + + it("generates Expo setup snippets compatible with current SDK types", async () => { + const apiKey = "openiap-kit_secret_setup"; + const { baseUrl, sessionId } = await initializeMcpSession(apiKey); + + const expoPayload = await callTool( + baseUrl, + sessionId, + "iapkit_setup", + { + framework: "expo", + productId: "premium_monthly", + }, + ); + expect(expoPayload).toMatchObject({ + framework: "expo", + note: expect.stringContaining("IAPKIT_API_KEY"), + }); + expect(expoPayload.snippet).toContain( + "export function useOpenIapPremium()", + ); + expect(expoPayload.snippet).toContain("useIAP()"); + expect(expoPayload.snippet).toContain("fetchProducts({"); + expect(expoPayload.snippet).toContain("new EventSource"); + expect(expoPayload.snippet).toContain("type WebhookEventStream"); + expect(expoPayload.snippet).toContain(""); + expect(expoPayload.snippet).not.toContain("useIAP({ skus:"); + expect(expoPayload.snippet).not.toContain(apiKey); + }); + + it("generates native iOS and Android setup snippets", async () => { + const apiKey = "openiap-kit_secret_setup"; + const { baseUrl, sessionId } = await initializeMcpSession(apiKey); + + const iosPayload = await callTool( + baseUrl, + sessionId, + "iapkit_setup", + { + framework: "ios", + productId: "premium_monthly", + }, + ); + expect(iosPayload).toMatchObject({ + framework: "ios", + note: expect.stringContaining("IAPKIT_API_KEY"), + }); + expect(iosPayload.snippet).toContain("OpenIapStore"); + expect(iosPayload.snippet).toContain( + "RequestVerifyPurchaseWithIapkitAppleProps", + ); + expect(iosPayload.snippet).toContain(""); + expect(iosPayload.snippet).not.toContain(apiKey); + + const androidPayload = await callTool( + baseUrl, + sessionId, + "iapkit_setup", + { + framework: "android", + productId: "premium_monthly", + }, + ); + expect(androidPayload).toMatchObject({ + framework: "android", + note: expect.stringContaining("IAPKIT_API_KEY"), + }); + expect(androidPayload.snippet).toContain("OpenIapModule"); + expect(androidPayload.snippet).toContain("OpenIapPurchaseUpdateListener"); + expect(androidPayload.snippet).toContain( + "RequestVerifyPurchaseWithIapkitGoogleProps", + ); + expect(androidPayload.snippet).toContain(""); + expect(androidPayload.snippet).not.toContain(apiKey); + }); + + it("enqueues store sync jobs through the bearer-authenticated Kit API", async () => { + const apiKey = "openiap-kit_secret_sync"; + const previousBaseUrl = process.env.IAPKIT_BASE_URL; + process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => { + expect(req.method).toBe("POST"); + expect(req.url).toBe( + `/v1/products/${apiKey}/sync/ios?direction=push&dryRun=false`, + ); + res.writeHead(202, { "content-type": "application/json" }); + res.end(JSON.stringify({ jobId: "job_123", deduped: false })); + }); + + try { + const { baseUrl, sessionId } = await initializeMcpSession(apiKey); + const response = await postMcp( + baseUrl, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "iapkit_sync_products", + arguments: { + platform: "IOS", + direction: "push", + dryRun: false, + }, + }, + }, + sessionId, + { authorization: `Bearer ${apiKey}` }, + ); + const event = parseSseJson(await response.text()); + const payload = JSON.parse(event.result.content[0].text); + + expect(payload).toEqual({ jobId: "job_123", deduped: false }); + } finally { + if (previousBaseUrl === undefined) delete process.env.IAPKIT_BASE_URL; + else process.env.IAPKIT_BASE_URL = previousBaseUrl; + } + }); + + it("posts UTF-8-safe synthetic Android webhook payloads", async () => { + const apiKey = "openiap-kit_secret_webhook"; + const { baseUrl, sessionId } = await initializeMcpSession(apiKey); + const kitBaseUrl = await startKitApi((req, res) => { + expect(req.method).toBe("POST"); + expect(req.url).toBe(`/v1/webhooks/${apiKey}`); + + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + const parsed = JSON.parse(raw) as { + message: { data: string; messageId: string }; + }; + const decoded = JSON.parse( + Buffer.from(parsed.message.data, "base64").toString("utf8"), + ); + + expect(decoded).toMatchObject({ + version: "1.0", + packageName: "com.example.app", + testNotification: { version: "1.0" }, + }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + }); + + const response = await postMcp( + baseUrl, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "iapkit_simulate_webhook", + arguments: { + platform: "Android", + baseUrl: kitBaseUrl, + }, + }, + }, + sessionId, + { authorization: `Bearer ${apiKey}` }, + ); + const event = parseSseJson(await response.text()); + const payload = JSON.parse(event.result.content[0].text); + + expect(payload).toMatchObject({ status: 200 }); + }); + + it("returns iOS webhook simulation guidance without credentials", async () => { + const { baseUrl, sessionId } = await initializeMcpSession(); + + const payload = await callTool<{ info: string }>( + baseUrl, + sessionId, + "iapkit_simulate_webhook", + { platform: "IOS" }, + ); + + expect(payload.info).toContain("Apple ASN v2 simulation"); + expect(payload.info).toContain("/v1/webhooks/{apiKey}"); + }); + + 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; + } + }); + + it("redacts API keys from partial diagnostic errors", async () => { + const apiKey = "openiap-kit_secret_diagnostic"; + const previousBaseUrl = process.env.IAPKIT_BASE_URL; + process.env.IAPKIT_BASE_URL = await startKitApi((req, res) => { + if (req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + res.writeHead(500, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: `upstream saw bearer key ${apiKey}`, + path: req.url, + }), + ); + }); + + try { + const { baseUrl, sessionId } = await initializeMcpSession(apiKey); + const response = await postMcp( + baseUrl, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "iapkit_troubleshoot", + arguments: { sampleUserId: "user_1" }, + }, + }, + sessionId, + { authorization: `Bearer ${apiKey}` }, + ); + const callBody = await response.text(); + + expect(callBody).not.toContain(apiKey); + expect(callBody).toContain(""); + expect(callBody).toContain("/v1/subscriptions/metrics/"); + expect(callBody).toContain("/v1/subscriptions/status/"); + expect(callBody).toContain("/v1/subscriptions/entitlements/"); + } finally { + if (previousBaseUrl === undefined) delete process.env.IAPKIT_BASE_URL; + else process.env.IAPKIT_BASE_URL = previousBaseUrl; + } + }); +}); + +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 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 initializeMcpSession( + apiKey?: string, +): Promise<{ baseUrl: string; sessionId: string }> { + const baseUrl = await startServer(); + const headers = apiKey ? { 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, + headers, + ); + const sessionId = initResponse.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + await initResponse.text(); + return { baseUrl, sessionId: sessionId ?? "" }; +} + +async function callTool( + baseUrl: string, + sessionId: string, + name: string, + args: Record, +): Promise { + const response = await postMcp( + baseUrl, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name, + arguments: args, + }, + }, + sessionId, + ); + expect(response.status).toBe(200); + const event = parseSseJson(await response.text()); + return JSON.parse(event.result.content[0].text) as T; +} + +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); + return response.json(); +} + +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) { + throw new Error(`No SSE data line found: ${raw}`); + } + return JSON.parse(dataLine.slice("data: ".length)); +} diff --git a/packages/mcp-server/test/kit-client.test.ts b/packages/mcp-server/test/kit-client.test.ts index df5bb075..f994536d 100644 --- a/packages/mcp-server/test/kit-client.test.ts +++ b/packages/mcp-server/test/kit-client.test.ts @@ -23,9 +23,9 @@ describe("normalizeKitBaseUrl", () => { expect(() => normalizeKitBaseUrl("ftp://kit.example")).toThrow( "kit baseUrl must use http or https", ); - expect(() => normalizeKitBaseUrl("https://kit.example?token=secret")).toThrow( - "kit baseUrl must not include query or fragment", - ); + expect(() => + normalizeKitBaseUrl("https://kit.example?token=secret"), + ).toThrow("kit baseUrl must not include query or fragment"); }); }); @@ -92,6 +92,48 @@ describe("kitClient", () => { await expect(client.listProducts()).resolves.toEqual({ products: [] }); }); + it("calls revenue and sync endpoints with encoded query params", async () => { + const fetchMock = vi.fn(async () => { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const client = kitClient({ + apiKey: "custom-secret", + baseUrl: "https://kit.example", + }); + + await client.revenueMetrics({ + fromDay: "2026-06-01", + toDay: "2026-06-04", + }); + await client.syncProducts({ + platform: "Android", + direction: "purge-local", + dryRun: true, + }); + await client.syncJob("job/with slash"); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://kit.example/v1/subscriptions/revenue/custom-secret?fromDay=2026-06-01&toDay=2026-06-04", + expect.any(Object), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://kit.example/v1/products/custom-secret/sync/android?direction=purge-local&dryRun=true", + expect.objectContaining({ method: "POST" }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "https://kit.example/v1/products/custom-secret/sync/jobs/job%2Fwith%20slash", + expect.any(Object), + ); + }); + it("includes the full kit path in HTTP error messages", async () => { const fetchMock = vi.fn(async () => { return new Response(JSON.stringify({ errors: [] }), { diff --git a/plugins/openiap/.codex-plugin/plugin.json b/plugins/openiap/.codex-plugin/plugin.json new file mode 100644 index 00000000..ca54209d --- /dev/null +++ b/plugins/openiap/.codex-plugin/plugin.json @@ -0,0 +1,32 @@ +{ + "name": "openiap", + "version": "0.1.0", + "description": "Help Codex inspect and implement in-app purchase flows with OpenIAP.", + "author": { + "name": "OpenIAP", + "url": "https://openiap.dev" + }, + "homepage": "https://kit.openiap.dev/docs/ai-assistants/codex-plugin", + "repository": "https://github.com/hyodotdev/openiap", + "license": "MIT", + "keywords": ["openiap", "iapkit", "in-app-purchases", "mcp", "codex"], + "skills": "./skills/", + "interface": { + "displayName": "OpenIAP", + "shortDescription": "Inspect and implement in-app purchase flows.", + "longDescription": "Connects Codex to OpenIAP workflows for app in-app purchase implementation, purchase-flow review, SDK setup snippets, product catalog checks, subscription analytics, webhook simulation, and IAPKit-backed receipt-validation operations.", + "developerName": "OpenIAP", + "category": "Developer Tools", + "capabilities": ["Read", "Write"], + "websiteURL": "https://openiap.dev", + "privacyPolicyURL": "https://openiap.dev/privacy", + "termsOfServiceURL": "https://openiap.dev/terms", + "defaultPrompt": [ + "Use OpenIAP to review my in-app purchase flow.", + "Use OpenIAP to generate an Expo setup snippet.", + "Use OpenIAP to summarize IAPKit purchases." + ], + "brandColor": "#2563EB" + }, + "mcpServers": "./.mcp.json" +} diff --git a/plugins/openiap/.mcp.json b/plugins/openiap/.mcp.json new file mode 100644 index 00000000..7f5ad1b6 --- /dev/null +++ b/plugins/openiap/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "openiap": { + "url": "https://kit.openiap.dev/mcp", + "bearer_token_env_var": "IAPKIT_API_KEY", + "default_tools_approval_mode": "prompt" + } + } +} diff --git a/plugins/openiap/skills/openiap/SKILL.md b/plugins/openiap/skills/openiap/SKILL.md new file mode 100644 index 00000000..acd1275a --- /dev/null +++ b/plugins/openiap/skills/openiap/SKILL.md @@ -0,0 +1,30 @@ +--- +name: openiap +description: Use when the user wants Codex to inspect, implement, or troubleshoot app in-app purchase flows with OpenIAP, including SDK setup, product catalog checks, subscription analytics, IAPKit receipt validation, store sync jobs, and webhook simulation. +--- + +# OpenIAP + +Use the bundled `openiap` MCP server for OpenIAP and IAPKit-backed in-app +purchase workflows. The current hosted MCP endpoint is IAPKit-backed by default +and exposes `iapkit_*` tools for live project operations. + +## Authentication + +The server expects an IAPKit project API key, not an OpenAI or ChatGPT API key. +Users should set `IAPKIT_API_KEY` in the environment that launches Codex before +using the plugin. + +## Operating Rules + +- Start by reviewing the app's current purchase flow and SDK usage before + proposing code changes. +- Use read-only tools first, including `iapkit_inspect_state`, + `iapkit_list_products`, `iapkit_revenue_analytics`, `iapkit_check_status`, and + `iapkit_setup`. +- Treat product management and store sync tools as real writes. +- Use `dryRun: true` for store sync previews first. +- Do not create products, start non-dry-run sync jobs, simulate webhooks, or + edit app code unless the user explicitly asks for that action in the current + thread. +- Keep IAPKit project API keys out of code snippets and final responses.