diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8486fbd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +build +dist-mcpb +test +.git +.github +*.md +*.mcpb +.env* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbdb49c..8dfea97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,20 @@ jobs: cache: npm - run: npm ci - run: npm run build - - name: Mock integration tests + - name: Mock integration tests (stdio) run: node test/e2e.mjs + - name: Mock integration tests (streamable HTTP) + run: node test/http-test.mjs + - name: Mock integration tests (OAuth / lazy auth) + run: node test/oauth-test.mjs - name: Live spec-discovery test run: node test/discovery-test.mjs + - name: Validate desktop-extension manifest + run: npx mcpb validate manifest.json + - name: Build desktop extension (.mcpb) + run: npm run build:mcpb + - uses: actions/upload-artifact@v4 + with: + name: kudosity-mcp-mcpb + path: dist-mcpb/*.mcpb + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..32cfd29 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +name: Release assets + +# When a GitHub Release is published, build the Claude Desktop extension and +# attach it to the release so users can download kudosity-mcp-.mcpb. +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + mcpb: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run build:mcpb + - name: Attach .mcpb to the release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" dist-mcpb/*.mcpb --clobber diff --git a/.gitignore b/.gitignore index e81ef8c..b9100d0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ test/get.mjs test/infra-test.mjs test/lists-test.mjs test/tier1-discovery-test.mjs + +# MCPB staging + bundles +build/ +dist-mcpb/ +*.mcpb diff --git a/.mcpbignore b/.mcpbignore new file mode 100644 index 0000000..23bb3ce --- /dev/null +++ b/.mcpbignore @@ -0,0 +1,16 @@ +# Only dist/, node_modules/, manifest.json, package.json, icon.png, LICENSE and README.md belong in the bundle. +# scripts/build-mcpb.sh stages exactly those; this file covers a direct `mcpb pack .`. +src +test +scripts +.github +build +dist-mcpb +*.mcpb +tsconfig.json +server.json +smithery.yaml +glama.json +Dockerfile +.dockerignore +CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9920e5a..38c3738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.6.0 + +Remote transport + Claude Desktop extension, built for the Claude Connectors Directory. + +- **Streamable HTTP transport** (`kudosity-mcp-http` / `node dist/http.js`): a stateless remote MCP server. Credentials are read per request from `Authorization: Basic key:secret`, `Authorization: Bearer key` or `x-api-key` (+ `x-api-secret`) and never stored. Without credentials the server initialises, lists tools and runs the discovery tools; API tools return instructions for adding the header. +- **Claude Desktop extension** (`.mcpb`): `manifest.json` + `npm run build:mcpb`. API key and secret are entered in the extension's settings (stored by Claude Desktop, masked), not pasted into chat. +- **Dockerfile** for hosting the HTTP server; `GET /healthz`. +- Tool factory refactor (`src/server.ts`) shared by both transports; no tool changes except annotations and wording: + - `send_sms`, `send_mms`, `send_whatsapp` now carry `destructiveHint: true` — sending is billable and irreversible, so clients ask before calling. + - Discovery tool descriptions name the API reference they query and say no credentials are needed. + - Secret-requiring tools say "Requires your API secret" instead of naming an environment variable. +- New test `test/http-test.mjs` (mock APIs, no network); CI validates the MCPB manifest and builds the bundle. + ## 0.5.0 Initial public release. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5edf413 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Remote (Streamable HTTP) Kudosity MCP server. +# docker build -t kudosity-mcp . && docker run -p 8080:8080 kudosity-mcp +# Credentials are supplied per request via headers — see README "Remote server". +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json tsconfig.json ./ +COPY src ./src +RUN npm ci --ignore-scripts --no-audit --no-fund && npm run build && npm prune --omit=dev + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production PORT=8080 HOST=0.0.0.0 +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY package.json ./ +USER node +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1 +CMD ["node", "dist/http.js"] diff --git a/README.md b/README.md index 11fcb11..677e3f0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # Kudosity MCP Server -An installable [Model Context Protocol](https://modelcontextprotocol.io) server that gives AI agents native tools to send **SMS, MMS and WhatsApp** messages, manage contacts and webhooks, and research the Kudosity API — powered by [Kudosity](https://kudosity.com). +A [Model Context Protocol](https://modelcontextprotocol.io) server that gives AI agents native tools to send **SMS, MMS and WhatsApp** messages, manage contacts and webhooks, and research the Kudosity API — powered by [Kudosity](https://kudosity.com). -Runs locally (or in your own infrastructure), authenticates with your Kudosity API key, and exposes one clean tool per action — so an agent calls `send_sms` instead of hand-building HTTP requests. +One tool per action — an agent calls `send_sms` instead of hand-building HTTP requests. Three ways to run it: + +| | Best for | Credentials | +|---|---|---| +| **Local (`npx kudosity-mcp`)** | Claude Code, Cursor, VS Code, Gemini CLI, any stdio client | environment variables | +| **Claude Desktop extension (`.mcpb`)** | One-click install in Claude Desktop | entered once in the extension's settings | +| **Remote (Streamable HTTP)** | claude.ai / Claude mobile, team-wide connectors, hosted agents | OAuth sign-in, or a request header | ## Tools @@ -16,6 +22,8 @@ Runs locally (or in your own infrastructure), authenticates with your Kudosity A | `get_message` | read | Get a sent message and its status by channel + ID | | `list_messages` | read | List sent/received messages with delivery status; filter by status, direction (sent/replies), recipient, dates | +Sending is billable and irreversible, so the three `send_*` tools are annotated `destructiveHint: true` — Claude and other clients ask before calling them. + **Webhooks** | Tool | Type | Description | @@ -24,7 +32,7 @@ Runs locally (or in your own infrastructure), authenticates with your Kudosity A | `list_webhooks` | read | List configured webhooks | | `delete_webhook` | write | Delete a webhook by ID | -**Contacts & lists** *(require `KUDOSITY_API_SECRET`)* +**Contacts & lists** *(need the API secret as well as the key)* | Tool | Type | Description | |---|---|---| @@ -35,13 +43,13 @@ Runs locally (or in your own infrastructure), authenticates with your Kudosity A | `remove_contact_from_list` | write | Remove a contact from a list | | `delete_list` | write | Delete a contact list | -**Account** *(requires `KUDOSITY_API_SECRET`)* +**Account** *(needs the API secret)* | Tool | Type | Description | |---|---|---| | `get_balance` | read | Get your account balance | -**API discovery** — research the whole Kudosity API, always current +**API discovery** — research the whole Kudosity API, always current, **no credentials needed** | Tool | Type | Description | |---|---|---| @@ -54,15 +62,21 @@ The discovery tools read the live specs from `developers.kudosity.com`, so they ## Requirements -- Node.js 18+ - A Kudosity account and API key — **Settings → API Settings** in the Kudosity platform - For contact and balance tools, also your API **secret** (same settings page) +- Node.js 18+ for the local and self-hosted options (Claude Desktop bundles its own) ## Install & configure -Runs via `npx` — no clone required. +### Claude Desktop — extension (one click) -### Claude Desktop / Claude Code +1. Download `kudosity-mcp-.mcpb` from the [latest release](https://github.com/kudosity/mcp/releases/latest). +2. Open it with Claude Desktop (double-click, or drag it onto **Settings → Extensions**). +3. Enter your Kudosity API key, and optionally the API secret. Claude Desktop stores them for you; they are never pasted into a chat. + +To build the extension yourself: `npm run build:mcpb` → `dist-mcpb/kudosity-mcp-.mcpb`. + +### Claude Desktop / Claude Code — `npx` ```json { @@ -79,6 +93,8 @@ Runs via `npx` — no clone required. } ``` +Or from the terminal: `claude mcp add kudosity -e KUDOSITY_API_KEY=your_api_key -- npx -y kudosity-mcp` + `KUDOSITY_API_SECRET` is optional — omit it if you only need messaging, webhooks, and discovery. It's required for the contact-list and balance tools. ### Cursor — `.cursor/mcp.json` @@ -109,6 +125,73 @@ Runs via `npx` — no clone required. } ``` +## Remote server (Streamable HTTP) + +The same 19 tools as a stateless remote MCP server — for claude.ai, Claude mobile, org-wide connectors, and hosted agents. Start it with `kudosity-mcp-http` (or `node dist/http.js`, or the [Dockerfile](./Dockerfile)); it serves MCP at `/mcp` and a health check at `/healthz`. + +Credentials are supplied **per request** and never stored on the server. There are two ways to supply them. + +### A. OAuth sign-in (Claude "Connect" button) + +When `KUDOSITY_MCP_PUBLIC_URL` and `KUDOSITY_MCP_TOKEN_KEY` are set, the server also acts as a small OAuth 2.1 authorization server: + +- Anyone can connect and use the discovery tools without signing in. +- The first time a tool needs your account, Claude shows a **Connect** card. You enter your Kudosity API key (and optional secret) on a consent page served from this server's domain — never in the chat. +- The key is verified with Kudosity, then sealed into an encrypted access token that Claude sends on later requests. The server holds no user data: rotating `KUDOSITY_MCP_TOKEN_KEY` invalidates every token. + +Supports Dynamic Client Registration and Client ID Metadata Documents, PKCE (S256), refresh tokens, and the Claude Code loopback redirect. Discovery documents live at `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server`. + +Add it in Claude as a custom connector with just the server URL (**Settings → Connectors → Add custom connector**), or in Claude Code: + +```bash +claude mcp add --transport http kudosity https:///mcp +``` + +### B. Request header (no sign-in) + +For a connector your whole team shares, or for scripts and tools that can set headers, send the credentials on every request: + +| Header | Value | Covers | +|---|---|---| +| `Authorization` | `Basic ` — leave the secret empty (`API_KEY:`) if you don't need lists/balance | everything | +| `x-api-key` (+ `x-api-secret`) | the raw key (and secret) | everything | +| `Authorization` | `Bearer API_KEY` | messaging, webhooks, reporting (header-only mode, i.e. when OAuth is not enabled) | + +In Claude: **Add custom connector → No sign-in → Request headers**, header `authorization`, value `Basic …`. In Claude Code: + +```bash +claude mcp add --transport http kudosity https:///mcp \ + --header "Authorization: Basic $(printf '%s:%s' "$KUDOSITY_API_KEY" "$KUDOSITY_API_SECRET" | base64)" +``` + +Without credentials the server still initialises and lists tools; discovery works, and tools that need your account return an error explaining how to add the header (or, in OAuth mode, start the sign-in). + +### Self-hosting + +```bash +npm install -g kudosity-mcp +PORT=8080 kudosity-mcp-http +# or +docker build -t kudosity-mcp . && docker run -p 8080:8080 -e KUDOSITY_MCP_PUBLIC_URL=https://mcp.example.com -e KUDOSITY_MCP_TOKEN_KEY="$(openssl rand -base64 32)" kudosity-mcp +``` + +Put it behind HTTPS (Claude only connects to `https://`). Then verify: + +```bash +curl -s https:///healthz +curl -s https:///mcp -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +| Env var | Default | Description | +|---|---|---| +| `PORT` / `HOST` | `8080` / `0.0.0.0` | Listen address | +| `KUDOSITY_MCP_PATH` | `/mcp` | MCP endpoint path | +| `KUDOSITY_MCP_PUBLIC_URL` | — | Public origin, e.g. `https://mcp.example.com`. Enables OAuth together with the token key. | +| `KUDOSITY_MCP_TOKEN_KEY` | — | Secret used to encrypt tokens (`openssl rand -base64 32`). Rotate to revoke all tokens. | +| `KUDOSITY_MCP_CONSENT_RATE_LIMIT` | `10` | Consent-page submissions per IP per minute | +| `KUDOSITY_HTTP_ALLOW_ENV_CREDENTIALS` | unset | `true` lets requests without credentials fall back to `KUDOSITY_API_KEY`/`_SECRET` from the server's environment. **Single-tenant only** — never on a shared or public host. | + ## Usage examples - *"Send an SMS to 61438333061 from KudosityDemo saying the order shipped."* @@ -123,14 +206,15 @@ Runs via `npx` — no clone required. Kudosity has two API surfaces, and this server uses each where it's authoritative: -| Tools | Auth | Credentials | +| Tools | Kudosity auth | Credentials | |---|---|---| -| Messaging, webhooks, discovery | `x-api-key` header | `KUDOSITY_API_KEY` | -| Contact lists, balance | HTTP Basic | `KUDOSITY_API_KEY` + `KUDOSITY_API_SECRET` | +| Messaging, webhooks, reporting | `x-api-key` header | API key | +| Contact lists, balance | HTTP Basic | API key + API secret | +| API discovery | none | — | -Credentials are read from environment variables and never written to disk. Treat them as secrets. +Locally, credentials are read from environment variables (or the extension's settings) and never written to disk. Remotely, they arrive per request in a header or an encrypted OAuth token, are used for that request, and are discarded. Treat them as secrets. -## Configuration +## Configuration (local) | Env var | Required | Default | Description | |---|---|---|---| @@ -139,6 +223,22 @@ Credentials are read from environment variables and never written to disk. Treat | `KUDOSITY_API_BASE_URL` | no | `https://api.transmitmessage.com` | Override the v2 API base URL | | `KUDOSITY_V1_BASE_URL` | no | `https://api.transmitsms.com` | Override the v1 API base URL | +## Privacy Policy + +This software is a thin client for the Kudosity API. What it does with your data: + +- **What it collects.** Only the inputs you give a tool — recipient numbers, message text, media URLs, list and webhook details — and the API credentials you supply. It reads nothing else from your device, your files, or your conversation. +- **How it is used and stored.** Inputs are sent to the Kudosity API over HTTPS to perform the requested action, and the API's response is returned to your AI client. The server stores nothing: no database, no logs of message content or credentials. Locally, credentials live in environment variables or your client's settings store. Remotely, they are carried in the request (a header, or an encrypted OAuth token that only the server's key can open) and discarded when the request completes. +- **Third parties.** Data goes to Kudosity's API (`api.transmitmessage.com`, `api.transmitsms.com`) and, for the discovery tools, public API specifications are fetched from `developers.kudosity.com`. Nothing is sent anywhere else. No analytics or telemetry. +- **Retention.** None by this software. Messages, contacts and account data are retained by Kudosity under the [Kudosity Privacy Policy](https://kudosity.com/legal/privacy-policy). +- **Contact.** [help.kudosity.com](https://help.kudosity.com) or [GitHub issues](https://github.com/kudosity/mcp/issues). + +## Support + +- Help centre and tickets: [help.kudosity.com](https://help.kudosity.com) +- Bugs and feature requests: [github.com/kudosity/mcp/issues](https://github.com/kudosity/mcp/issues) +- API documentation: [developers.kudosity.com](https://developers.kudosity.com/docs/mcp) + ## Local development ```bash @@ -146,14 +246,14 @@ git clone https://github.com/kudosity/mcp cd mcp npm install npm run build -KUDOSITY_API_KEY=your_api_key npm start +KUDOSITY_API_KEY=your_api_key npm start # stdio +npm run start:http # streamable HTTP on :8080 ``` -Test tool listing with the MCP Inspector, or run the checks: - ```bash -npm test # mock integration tests (no credentials) -node test/discovery-test.mjs # live spec-discovery test (no credentials) +npm test # build + mock tests: stdio, HTTP, OAuth (no credentials, no network) +node test/discovery-test.mjs # live spec-discovery test (no credentials) +npm run build:mcpb # Claude Desktop extension → dist-mcpb/ ``` ## License diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..8e093fd Binary files /dev/null and b/icon.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..48b67d7 --- /dev/null +++ b/manifest.json @@ -0,0 +1,79 @@ +{ + "manifest_version": "0.3", + "name": "kudosity-mcp", + "display_name": "Kudosity", + "version": "0.6.0", + "description": "Send SMS, MMS and WhatsApp, manage contacts and webhooks, and explore the Kudosity API.", + "long_description": "Gives Claude native tools for the Kudosity messaging platform: send SMS, MMS and WhatsApp messages, check delivery status and inbound replies, manage contact lists and webhooks, check your balance, and search the live Kudosity API reference.\n\nYou need a Kudosity account and API key (Settings → API Settings in the Kudosity platform). The API secret is optional and only needed for the contact-list and balance tools.\n\nSending a message is billable and irreversible, so Claude always asks before sending.", + "author": { + "name": "Kudosity", + "url": "https://kudosity.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/kudosity/mcp" + }, + "homepage": "https://developers.kudosity.com/docs/mcp", + "documentation": "https://developers.kudosity.com/docs/mcp", + "support": "https://help.kudosity.com", + "icon": "icon.png", + "license": "MIT", + "keywords": ["sms", "mms", "whatsapp", "messaging", "notifications", "kudosity", "contacts", "webhooks"], + "privacy_policies": ["https://kudosity.com/legal/privacy-policy"], + "server": { + "type": "node", + "entry_point": "dist/index.js", + "mcp_config": { + "command": "node", + "args": ["${__dirname}/dist/index.js"], + "env": { + "KUDOSITY_API_KEY": "${user_config.api_key}", + "KUDOSITY_API_SECRET": "${user_config.api_secret}" + } + } + }, + "tools": [ + { "name": "send_sms", "description": "Send an SMS to a recipient" }, + { "name": "send_mms", "description": "Send an MMS with a media attachment" }, + { "name": "send_whatsapp", "description": "Send a WhatsApp message (free-form text or approved template)" }, + { "name": "get_message", "description": "Get a sent message and its status by channel and ID" }, + { "name": "list_messages", "description": "List sent or received messages with delivery status" }, + { "name": "create_webhook", "description": "Register an HTTPS webhook for delivery status, inbound messages, link hits, opt-outs" }, + { "name": "list_webhooks", "description": "List configured webhooks" }, + { "name": "delete_webhook", "description": "Delete a webhook by ID" }, + { "name": "get_balance", "description": "Get your account balance" }, + { "name": "create_list", "description": "Create a contact list" }, + { "name": "get_lists", "description": "List all contact lists" }, + { "name": "get_list", "description": "Get a contact list and optionally its members" }, + { "name": "add_contact_to_list", "description": "Add a contact to a list" }, + { "name": "remove_contact_from_list", "description": "Remove a contact from a list" }, + { "name": "delete_list", "description": "Delete a contact list" }, + { "name": "list_specs", "description": "List the Kudosity API specifications" }, + { "name": "list_endpoints", "description": "List all endpoints for an API spec" }, + { "name": "search_endpoints", "description": "Search the Kudosity API reference by keyword" }, + { "name": "get_endpoint", "description": "Get full detail for one API endpoint" } + ], + "user_config": { + "api_key": { + "type": "string", + "title": "Kudosity API key", + "description": "From the Kudosity platform: Settings → API Settings. Used for messaging, webhooks and reporting.", + "sensitive": true, + "required": true + }, + "api_secret": { + "type": "string", + "title": "Kudosity API secret (optional)", + "description": "Same page as the API key. Only needed for the contact-list and balance tools; leave blank otherwise.", + "sensitive": true, + "required": false + } + }, + "compatibility": { + "claude_desktop": ">=0.10.0", + "platforms": ["darwin", "win32", "linux"], + "runtimes": { + "node": ">=18.0.0" + } + } +} diff --git a/package-lock.json b/package-lock.json index c86592f..4432e14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,23 @@ { - "name": "@kudosity/mcp", - "version": "0.1.0", + "name": "kudosity-mcp", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@kudosity/mcp", - "version": "0.1.0", + "name": "kudosity-mcp", + "version": "0.6.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.13.0", "zod": "^3.23.8" }, "bin": { - "kudosity-mcp": "dist/index.js" + "kudosity-mcp": "dist/index.js", + "kudosity-mcp-http": "dist/http.js" }, "devDependencies": { + "@anthropic-ai/mcpb": "^2.1.2", "@types/node": "^22.0.0", "typescript": "^5.6.0" }, @@ -23,6 +25,27 @@ "node": ">=18" } }, + "node_modules/@anthropic-ai/mcpb": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@anthropic-ai/mcpb/-/mcpb-2.1.2.tgz", + "integrity": "sha512-goRbBC8ySo7SWb7tRzr+tL6FxDc4JPTRCdgfD2omba7freofvjq5rom1lBnYHZHo6Mizs1jAHJeN53aZbDoy8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^6.0.1", + "commander": "^13.1.0", + "fflate": "^0.8.2", + "galactus": "^1.0.0", + "ignore": "^7.0.5", + "node-forge": "^1.3.2", + "pretty-bytes": "^5.6.0", + "zod": "^3.25.67", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "mcpb": "dist/cli/cli.js" + } + }, "node_modules/@hono/node-server": { "version": "1.19.15", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz", @@ -35,6 +58,227 @@ "hono": "^4" } }, + "node_modules/@inquirer/checkbox": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-3.0.1.tgz", + "integrity": "sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.2.1", + "@inquirer/figures": "^1.0.6", + "@inquirer/type": "^2.0.0", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-4.0.1.tgz", + "integrity": "sha512-46yL28o2NJ9doViqOy0VDcoTzng7rAb6yPQKU7VDLqkmbCaH4JqK4yk4XqlzNWy9PVC5pG1ZUXPBQv+VqnYs2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.2.1", + "@inquirer/type": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.2.1.tgz", + "integrity": "sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.6", + "@inquirer/type": "^2.0.0", + "@types/mute-stream": "^0.0.4", + "@types/node": "^22.5.5", + "@types/wrap-ansi": "^3.0.0", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^1.0.0", + "signal-exit": "^4.1.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/editor": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-3.0.1.tgz", + "integrity": "sha512-VA96GPFaSOVudjKFraokEEmUQg/Lub6OXvbIEZU1SDCmBzRkHGhxoFAVaF30nyiB4m5cEbDgiI2QRacXZ2hw9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.2.1", + "@inquirer/type": "^2.0.0", + "external-editor": "^3.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/expand": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-3.0.1.tgz", + "integrity": "sha512-ToG8d6RIbnVpbdPdiN7BCxZGiHOTomOX94C2FaT5KOHupV40tKEDozp12res6cMIfRKrXLJyexAZhWVHgbALSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.2.1", + "@inquirer/type": "^2.0.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-3.0.1.tgz", + "integrity": "sha512-BDuPBmpvi8eMCxqC5iacloWqv+5tQSJlUafYWUe31ow1BVXjW2a5qe3dh4X/Z25Wp22RwvcaLCc2siHobEOfzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.2.1", + "@inquirer/type": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/number": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-2.0.1.tgz", + "integrity": "sha512-QpR8jPhRjSmlr/mD2cw3IR8HRO7lSVOnqUvQa8scv1Lsr3xoAMMworcYW3J13z3ppjBFBD2ef1Ci6AE5Qn8goQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.2.1", + "@inquirer/type": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/password": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-3.0.1.tgz", + "integrity": "sha512-haoeEPUisD1NeE2IanLOiFr4wcTXGWrBOyAyPZi1FfLJuXOzNmxCJPgUrGYKVh+Y8hfGJenIfz5Wb/DkE9KkMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.2.1", + "@inquirer/type": "^2.0.0", + "ansi-escapes": "^4.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/prompts": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-6.0.1.tgz", + "integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^3.0.1", + "@inquirer/confirm": "^4.0.1", + "@inquirer/editor": "^3.0.1", + "@inquirer/expand": "^3.0.1", + "@inquirer/input": "^3.0.1", + "@inquirer/number": "^2.0.1", + "@inquirer/password": "^3.0.1", + "@inquirer/rawlist": "^3.0.1", + "@inquirer/search": "^2.0.1", + "@inquirer/select": "^3.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/rawlist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-3.0.1.tgz", + "integrity": "sha512-VgRtFIwZInUzTiPLSfDXK5jLrnpkuSOh1ctfaoygKAdPqjcjKYmGh6sCY1pb0aGnCGsmhUxoqLDUAU0ud+lGXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.2.1", + "@inquirer/type": "^2.0.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/search": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-2.0.1.tgz", + "integrity": "sha512-r5hBKZk3g5MkIzLVoSgE4evypGqtOannnB3PKTG9NRZxyFRKcfzrdxXXPcoJQsxJPzvdSU2Rn7pB7lw0GCmGAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.2.1", + "@inquirer/figures": "^1.0.6", + "@inquirer/type": "^2.0.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/select": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-3.0.1.tgz", + "integrity": "sha512-lUDGUxPhdWMkN/fHy1Lk7pF3nK1fh/gqeyWXmctefhxLYxlDsc7vsPBEpxrfVGDsVdyYJsiJoD4bJ1b623cV1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^9.2.1", + "@inquirer/figures": "^1.0.6", + "@inquirer/type": "^2.0.0", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-2.0.0.tgz", + "integrity": "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==", + "dev": true, + "license": "MIT", + "dependencies": { + "mute-stream": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -75,6 +319,16 @@ } } }, + "node_modules/@types/mute-stream": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", + "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -85,6 +339,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/wrap-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", + "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", + "dev": true, + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -131,6 +392,48 @@ } } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -206,6 +509,53 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -323,6 +673,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -460,6 +817,34 @@ "express": ">= 4.11" } }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/external-editor/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -482,6 +867,13 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -503,6 +895,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/flora-colossus": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/flora-colossus/-/flora-colossus-2.0.0.tgz", + "integrity": "sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "fs-extra": "^10.1.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -521,6 +927,21 @@ "node": ">= 0.8" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -530,6 +951,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/galactus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/galactus/-/galactus-1.0.0.tgz", + "integrity": "sha512-R1fam6D4CyKQGNlvJne4dkNF+PvUUl7TAJInvTGa9fti9qAv95quQz29GXapA4d8Ec266mJJxFVh82M4GIIGDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "flora-colossus": "^2.0.0", + "fs-extra": "^10.1.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -579,6 +1015,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -648,6 +1091,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ignore": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -672,6 +1125,16 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -705,6 +1168,19 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -770,6 +1246,16 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -779,6 +1265,16 @@ "node": ">= 0.6" } }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -821,6 +1317,16 @@ "wrappy": "1" } }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -858,6 +1364,19 @@ "node": ">=16.20.0" } }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1090,6 +1609,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1099,6 +1631,47 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1108,6 +1681,19 @@ "node": ">=0.6" } }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -1160,6 +1746,16 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1193,12 +1789,40 @@ "node": ">= 8" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 7a16373..3631e5b 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "kudosity-mcp", - "version": "0.5.0", + "version": "0.6.0", "description": "Kudosity MCP server — send SMS, MMS and WhatsApp, manage contacts and webhooks, and research the Kudosity API from AI agents", "type": "module", "bin": { - "kudosity-mcp": "dist/index.js" + "kudosity-mcp": "dist/index.js", + "kudosity-mcp-http": "dist/http.js" }, "files": [ "dist" @@ -13,9 +14,13 @@ "scripts": { "build": "tsc", "start": "node dist/index.js", + "start:http": "node dist/http.js", "dev": "tsc && node dist/index.js", - "test": "npm run build && node test/e2e.mjs", - "prepare": "npm run build" + "test": "npm run build && node test/e2e.mjs && node test/http-test.mjs && node test/oauth-test.mjs", + "test:http": "node test/http-test.mjs", + "build:mcpb": "bash scripts/build-mcpb.sh", + "prepare": "npm run build", + "test:oauth": "node test/oauth-test.mjs" }, "keywords": [ "mcp", @@ -48,6 +53,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "@anthropic-ai/mcpb": "^2.1.2" } } diff --git a/scripts/build-mcpb.sh b/scripts/build-mcpb.sh new file mode 100755 index 0000000..5321d04 --- /dev/null +++ b/scripts/build-mcpb.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Build the Claude Desktop extension: dist-mcpb/kudosity-mcp-.mcpb +# Stages a clean copy (compiled server + production node_modules + manifest), +# validates the manifest, packs it, and prints the bundle summary. +set -euo pipefail +cd "$(dirname "$0")/.." + +VERSION=$(node -p "require('./package.json').version") +STAGE=build/mcpb +OUT=dist-mcpb/kudosity-mcp-$VERSION.mcpb + +npm run build +# Keep manifest.json's version in lock-step with package.json. +node -e ' +const fs = require("fs"); +const m = JSON.parse(fs.readFileSync("manifest.json", "utf8")); +if (m.version !== process.argv[1]) { m.version = process.argv[1]; fs.writeFileSync("manifest.json", JSON.stringify(m, null, 2) + "\n"); } +' "$VERSION" + +rm -rf "$STAGE" && mkdir -p "$STAGE" dist-mcpb +cp -R dist manifest.json package.json package-lock.json icon.png LICENSE README.md "$STAGE"/ +(cd "$STAGE" && npm ci --omit=dev --ignore-scripts --no-audit --no-fund) + +npx mcpb validate "$STAGE/manifest.json" +rm -f "$OUT" +npx mcpb pack "$STAGE" "$OUT" +npx mcpb info "$OUT" +echo "Built $OUT" diff --git a/server.json b/server.json index 82fe57f..33b1741 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "com.kudosity/mcp", "title": "Kudosity", "description": "Send SMS, MMS and WhatsApp, manage contacts and webhooks, from AI agents.", - "version": "0.5.0", + "version": "0.6.0", "websiteUrl": "https://developers.kudosity.com/", "repository": { "url": "https://github.com/kudosity/mcp", @@ -14,8 +14,10 @@ "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "kudosity-mcp", - "version": "0.5.0", - "transport": { "type": "stdio" }, + "version": "0.6.0", + "transport": { + "type": "stdio" + }, "environmentVariables": [ { "name": "KUDOSITY_API_KEY", diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..df2ab5f --- /dev/null +++ b/src/http.ts @@ -0,0 +1,231 @@ +#!/usr/bin/env node +/** + * Streamable HTTP entry point — a stateless remote MCP server for claude.ai, + * Claude Code, Cursor and any HTTP MCP client. + * + * node dist/http.js (or: kudosity-mcp-http) + * PORT=8080 HOST=0.0.0.0 KUDOSITY_MCP_PATH=/mcp + * + * Credentials are per request and never stored. Two ways in: + * + * A) Request headers — for custom connectors, Claude Code, Cursor, curl: + * Authorization: Basic base64(API_KEY:API_SECRET) ← secret may be empty + * Authorization: Bearer API_KEY + * x-api-key: API_KEY [+ x-api-secret: API_SECRET] + * + * B) OAuth (lazy auth) — for the Claude Connectors Directory. Enabled when + * KUDOSITY_MCP_PUBLIC_URL and KUDOSITY_MCP_TOKEN_KEY are set. Users click + * Connect in Claude, enter their API key on our consent page, and Claude + * receives an encrypted bearer token that carries the credentials. Public + * tools (discovery) work without signing in; a protected tool call without + * a token returns 401 + WWW-Authenticate so Claude starts the OAuth flow. + * See src/oauth.ts. + * + * KUDOSITY_HTTP_ALLOW_ENV_CREDENTIALS=true makes the server fall back to + * KUDOSITY_API_KEY / KUDOSITY_API_SECRET from its own environment when a + * request carries none. Only for single-tenant self-hosting — never set it + * on a shared or public deployment. + */ +import http from "node:http"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createServer, SERVER_NAME, SERVER_VERSION, PUBLIC_TOOLS, type Credentials } from "./server.js"; +import { KudosityClient } from "./kudosity.js"; +import { OAuthProvider, readBody, sendHtml, sendJson, type AuthorizeParams } from "./oauth.js"; + +const PORT = Number(process.env.PORT || 8080); +const HOST = process.env.HOST || "0.0.0.0"; +const MCP_PATH = process.env.KUDOSITY_MCP_PATH || "/mcp"; +const ALLOW_ENV_CREDENTIALS = process.env.KUDOSITY_HTTP_ALLOW_ENV_CREDENTIALS === "true"; +const PUBLIC_URL = (process.env.KUDOSITY_MCP_PUBLIC_URL || "").replace(/\/$/, ""); +const TOKEN_KEY = process.env.KUDOSITY_MCP_TOKEN_KEY || ""; + +/** Check an API key by making a cheap read-only call. Returns a user-facing message on failure. */ +async function verifyCredentials(creds: Credentials): Promise<{ ok: boolean; message?: string }> { + try { + const client = new KudosityClient(creds.apiKey!, creds.apiSecret); + await client.v2("GET", "/v2/webhook"); + if (creds.apiSecret) await client.v1("GET", "/get-balance.json"); + return { ok: true }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (/\b40[13]\b/.test(msg)) return { ok: false, message: creds.apiSecret ? "Kudosity rejected the API key or secret. Check both under Settings → API Settings." : "Kudosity rejected that API key. Check it under Settings → API Settings." }; + return { ok: false, message: "Could not reach the Kudosity API to verify the key. Please try again." }; + } +} + +const oauth = PUBLIC_URL && TOKEN_KEY ? new OAuthProvider({ publicUrl: PUBLIC_URL, mcpPath: MCP_PATH, tokenKey: TOKEN_KEY, verifyCredentials }) : null; +if ((PUBLIC_URL || TOKEN_KEY) && !oauth) { + console.error("OAuth is disabled: set BOTH KUDOSITY_MCP_PUBLIC_URL and KUDOSITY_MCP_TOKEN_KEY to enable it."); +} + +export const HTTP_CREDENTIAL_HELP = + "This tool needs your Kudosity API credentials and the request carried none. " + + "Send them as a request header on the connector — recommended: " + + "`Authorization: Basic ` (leave the secret empty, i.e. base64 of `API_KEY:`, " + + "unless you need the contact-list and balance tools); or `x-api-key: `. " + + "In Claude, add the header under Request headers when adding the connector (Settings → Connectors → Add custom connector → No sign-in). " + + "In Claude Code: `claude mcp add --transport http kudosity --header \"Authorization: Basic \"`. " + + "Find your API key and secret in the Kudosity platform under Settings → API Settings. " + + "The API-discovery tools (list_specs, list_endpoints, search_endpoints, get_endpoint) work without credentials."; + +const first = (v: string | string[] | undefined): string | undefined => (Array.isArray(v) ? v[0] : v)?.trim() || undefined; + +/** Extract Kudosity credentials from request headers (raw API key forms, or an OAuth bearer we issued). */ +export function credentialsFromHeaders(headers: http.IncomingHttpHeaders, provider: OAuthProvider | null = oauth): Credentials { + const auth = first(headers.authorization); + if (auth) { + const sp = auth.indexOf(" "); + const scheme = sp === -1 ? auth : auth.slice(0, sp); + const value = sp === -1 ? "" : auth.slice(sp + 1).trim(); + if (/^basic$/i.test(scheme) && value) { + const decoded = Buffer.from(value, "base64").toString("utf8"); + const colon = decoded.indexOf(":"); + if (colon === -1) return { apiKey: decoded || undefined }; + return { apiKey: decoded.slice(0, colon) || undefined, apiSecret: decoded.slice(colon + 1) || undefined }; + } + if (/^bearer$/i.test(scheme) && value) { + // OAuth mode: a bearer must be a token we issued. Anything else (expired, garbage) + // yields no credentials, so a protected call gets a 401 and Claude refreshes or re-authorises. + if (provider) return provider.credentialsFromAccessToken(value) ?? {}; + // Header mode: treat the bearer value as a raw API key (custom connectors, curl). + return { apiKey: value }; + } + } + const apiKey = first(headers["x-api-key"]); + if (apiKey) return { apiKey, apiSecret: first(headers["x-api-secret"]) }; + if (ALLOW_ENV_CREDENTIALS && process.env.KUDOSITY_API_KEY) { + return { apiKey: process.env.KUDOSITY_API_KEY, apiSecret: process.env.KUDOSITY_API_SECRET || undefined }; + } + return {}; +} + +/** True if the JSON-RPC body (single or batch) calls a tool that needs credentials. */ +export function callsProtectedTool(body: unknown): boolean { + for (const msg of Array.isArray(body) ? body : [body]) { + if (msg && typeof msg === "object" && (msg as { method?: unknown }).method === "tools/call") { + const name = (msg as { params?: { name?: unknown } }).params?.name; + if (typeof name === "string" && !PUBLIC_TOOLS.has(name)) return true; + } + } + return false; +} + +const rpcError = (code: number, message: string) => ({ jsonrpc: "2.0", error: { code, message }, id: null }); + +/** Per-IP limit on consent submissions (each one verifies a key against Kudosity). */ +const CONSENT_LIMIT = Number(process.env.KUDOSITY_MCP_CONSENT_RATE_LIMIT || 10); // per minute +const consentHits = new Map(); +function consentAllowed(req: http.IncomingMessage): boolean { + const ip = (first(req.headers["x-forwarded-for"]) ?? "").split(",")[0].trim() || req.socket.remoteAddress || "unknown"; + const t = Date.now(); + const hit = consentHits.get(ip); + if (!hit || hit.reset < t) { consentHits.set(ip, { n: 1, reset: t + 60_000 }); return true; } + hit.n++; + if (consentHits.size > 10_000) for (const [k, v] of consentHits) if (v.reset < t) consentHits.delete(k); + return hit.n <= CONSENT_LIMIT; +} + +async function handleMcp(req: http.IncomingMessage, res: http.ServerResponse): Promise { + if (req.method !== "POST") { + // Stateless: no server-initiated SSE stream (GET) and no sessions to end (DELETE). + res.setHeader("allow", "POST"); + return sendJson(res, 405, rpcError(-32000, "Method not allowed. This server is stateless: send JSON-RPC over POST.")); + } + let body: unknown; + try { + body = JSON.parse(await readBody(req, 4 * 1024 * 1024)); + } catch { + return sendJson(res, 400, rpcError(-32700, "Parse error: body must be JSON.")); + } + + const creds = credentialsFromHeaders(req.headers); + + // Lazy-auth gate (OAuth mode only): a protected tool call without credentials + // must fail at the HTTP layer with 401 so Claude shows its Connect card. + if (oauth && !creds.apiKey && callsProtectedTool(body)) { + return sendJson(res, 401, { error: "invalid_token", error_description: "Authentication required for this tool" }, { "www-authenticate": oauth.wwwAuthenticate() }); + } + + // One server + transport per request: credentials never outlive the request. + const server = createServer(creds, { credentialHelp: HTTP_CREDENTIAL_HELP }); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + res.on("close", () => { void transport.close(); void server.close(); }); + await server.connect(transport); + await transport.handleRequest(req, res, body); +} + +async function handleOAuth(o: OAuthProvider, url: URL, req: http.IncomingMessage, res: http.ServerResponse): Promise { + const path = url.pathname; + if (req.method === "GET" && (path === "/.well-known/oauth-protected-resource" || path === `/.well-known/oauth-protected-resource${MCP_PATH}`)) { + sendJson(res, 200, o.protectedResourceMetadata(), { "access-control-allow-origin": "*" }); return true; + } + if (req.method === "GET" && (path === "/.well-known/oauth-authorization-server" || path === "/.well-known/openid-configuration")) { + sendJson(res, 200, o.authorizationServerMetadata(), { "access-control-allow-origin": "*" }); return true; + } + if (path === "/register") { + if (req.method !== "POST") { sendJson(res, 405, { error: "invalid_request" }); return true; } + let body: unknown; + try { body = JSON.parse(await readBody(req, 64 * 1024)); } catch { sendJson(res, 400, { error: "invalid_client_metadata", error_description: "Body must be JSON." }); return true; } + const r = o.register(body); sendJson(res, r.status, r.json); return true; + } + if (path === "/authorize") { + if (req.method === "GET") { + const a = await o.authorizeRequest(url.searchParams); + if (!a.ok) { + if (a.redirect) { res.statusCode = 302; res.setHeader("location", a.redirect); res.setHeader("cache-control", "no-store"); res.end(); } + else sendHtml(res, a.status, errorPage(a.error, a.description)); + return true; + } + sendHtml(res, 200, o.consentPage(a)); return true; + } + if (req.method === "POST") { + if (!consentAllowed(req)) { sendHtml(res, 429, errorPage("too_many_requests", "Too many attempts. Please wait a minute and try again.")); return true; } + const form = new URLSearchParams(await readBody(req, 64 * 1024)); + const a = await o.authorizeRequest(form); + if (!a.ok) { + if (a.redirect) { res.statusCode = 302; res.setHeader("location", a.redirect); res.end(); } else sendHtml(res, a.status, errorPage(a.error, a.description)); + return true; + } + const issued = await o.issueCode(a.params as AuthorizeParams, { apiKey: form.get("api_key")?.trim() || undefined, apiSecret: form.get("api_secret")?.trim() || undefined }); + if (!issued.ok) { sendHtml(res, 200, o.consentPage({ ...a, error: issued.message })); return true; } + res.statusCode = 302; res.setHeader("location", issued.redirect); res.setHeader("cache-control", "no-store"); res.end(); return true; + } + sendJson(res, 405, { error: "invalid_request" }); return true; + } + if (path === "/token") { + if (req.method !== "POST") { sendJson(res, 405, { error: "invalid_request" }); return true; } + const ct = first(req.headers["content-type"]) ?? ""; + const raw = await readBody(req, 64 * 1024); + let form: URLSearchParams; + if (ct.includes("application/json")) { try { form = new URLSearchParams(Object.entries(JSON.parse(raw) as Record)); } catch { sendJson(res, 400, { error: "invalid_request", error_description: "Malformed JSON." }); return true; } } + else form = new URLSearchParams(raw); + const r = o.token(form); sendJson(res, r.status, r.json); return true; + } + return false; +} + +const errorPage = (error: string, description: string) => + `Kudosity — ${error}

Can't continue

${error}

${description.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c] as string)}

`; + +const httpServer = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + (async () => { + if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/")) { + return sendJson(res, 200, { ok: true, name: SERVER_NAME, version: SERVER_VERSION, mcp: MCP_PATH, transport: "streamable-http", oauth: Boolean(oauth) }); + } + if (oauth && (await handleOAuth(oauth, url, req, res))) return; + if (url.pathname !== MCP_PATH) return sendJson(res, 404, rpcError(-32000, `Not found. The MCP endpoint is ${MCP_PATH}.`)); + await handleMcp(req, res); + })().catch((err) => { + console.error("request failed:", err instanceof Error ? err.message : err); + if (!res.headersSent) sendJson(res, 500, rpcError(-32603, "Internal server error")); + }); +}); + +httpServer.listen(PORT, HOST, () => { + console.error(`Kudosity MCP server (streamable HTTP) listening on http://${HOST}:${PORT}${MCP_PATH}` + (oauth ? ` — OAuth enabled, issuer ${oauth.issuer}` : " — header credentials only")); +}); + +const shutdown = () => httpServer.close(() => process.exit(0)); +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); diff --git a/src/index.ts b/src/index.ts index dd6f51b..e6bcca6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ #!/usr/bin/env node -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +/** + * stdio entry point — used by `npx kudosity-mcp`, the Claude Desktop extension + * (.mcpb), Cursor, VS Code, Claude Code, and any other local MCP client. + * Credentials come from the environment. For the HTTP transport see http.ts. + */ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; -import { KudosityClient } from "./kudosity.js"; -import { loadSpecs, forEachOperation } from "./specs.js"; +import { createServer } from "./server.js"; const apiKey = process.env.KUDOSITY_API_KEY; if (!apiKey) { @@ -14,412 +16,9 @@ if (!apiKey) { process.exit(1); } // Optional — only needed for v1 tools (contact lists, balance). -const apiSecret = process.env.KUDOSITY_API_SECRET; - -const kudosity = new KudosityClient(apiKey, apiSecret); -const server = new McpServer({ name: "kudosity", version: "0.5.0" }); - -const asText = (data: unknown) => ({ - content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], -}); - -const EVENT_TYPES = [ - "LINK_HIT", "OPT_OUT", "SMS_STATUS", "SMS_INBOUND", "MMS_STATUS", "MMS_INBOUND", - "WHATSAPP_STATUS", "WHATSAPP_INBOUND", "RCS_STATUS", "RCS_INBOUND", -] as const; - -// ── send_sms ──────────────────────────────────────────────────────────────── -server.registerTool( - "send_sms", - { - title: "Send SMS", - description: "Send an SMS text message to a single recipient via Kudosity.", - inputSchema: { - recipient: z.string().describe("Destination number in E.164 international format, e.g. 61438333061."), - sender: z.string().describe("Sender number, or an alphanumeric ID (max 11 chars) registered to your account."), - message: z.string().describe("Message body. Long messages are automatically split into multiple parts."), - message_ref: z.string().max(500).optional().describe("Your own reference; echoed back in webhook events."), - track_links: z.boolean().optional().describe("Replace links with shortened, tracked links."), - }, - annotations: { title: "Send SMS", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, - }, - async (args) => asText(await kudosity.v2("POST", "/v2/sms", args)), -); - -// ── send_mms ──────────────────────────────────────────────────────────────── -server.registerTool( - "send_mms", - { - title: "Send MMS", - description: "Send an MMS (media) message to a recipient via Kudosity.", - inputSchema: { - recipient: z.string().describe("Destination number in E.164 format. Australia only at present."), - sender: z.string().describe("Sender number registered to your account."), - content_urls: z.array(z.string().url()).min(1).describe("Absolute URL(s) to media. One image up to 400KB. jpg, gif, png, mp3, mp4."), - subject: z.string().max(20).optional().describe("Subject line, max 20 ASCII characters."), - message: z.string().optional().describe("Message body, max 1000 characters."), - message_ref: z.string().max(500).optional().describe("Your reference; echoed in webhooks."), - track_links: z.boolean().optional().describe("Shorten and track links in the message."), - }, - annotations: { title: "Send MMS", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, - }, - async (args) => asText(await kudosity.v2("POST", "/v2/mms", args)), -); - -// ── send_whatsapp ───────────────────────────────────────────────────────────── -server.registerTool( - "send_whatsapp", - { - title: "Send WhatsApp message", - description: - "Send a WhatsApp message via Kudosity — either free-form text (only within an open 24h " + - "customer-service window) or a pre-approved template (sendable anytime).", - inputSchema: { - recipient: z.string().describe("Recipient WhatsApp number in E.164 format, e.g. 61411122211."), - content_type: z.enum(["text", "template"]).describe("'text' for free-form; 'template' for a pre-approved WhatsApp template."), - text: z.string().optional().describe("Message body — required when content_type is 'text'."), - template_name: z.string().optional().describe("Approved template name — required when content_type is 'template'."), - template_parameters: z.array(z.string()).optional().describe("Ordered values that fill the template's placeholders."), - template_locale: z.string().optional().describe("Template locale, e.g. en_US. Defaults to 'en'."), - sender: z.string().optional().describe("Registered WhatsApp sender number. Optional if the account has a single sender."), - message_ref: z.string().max(500).optional().describe("Your reference; echoed in webhooks."), - }, - annotations: { title: "Send WhatsApp message", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, - }, - async (a) => { - let content: Record; - if (a.content_type === "text") { - if (!a.text) throw new Error("`text` is required when content_type is 'text'."); - content = { text: { message: a.text } }; - } else { - if (!a.template_name) throw new Error("`template_name` is required when content_type is 'template'."); - const template: Record = { name: a.template_name, locale: a.template_locale ?? "en" }; - if (a.template_parameters && a.template_parameters.length) template.parameters = a.template_parameters; - content = { template }; - } - const body: Record = { recipient: a.recipient, content_type: a.content_type, content }; - if (a.sender) body.sender = a.sender; - if (a.message_ref) body.message_ref = a.message_ref; - return asText(await kudosity.v2("POST", "/v2/whatsapp/messages", body)); - }, -); - -// ── get_message ─────────────────────────────────────────────────────────────── -server.registerTool( - "get_message", - { - title: "Get message", - description: "Retrieve a previously sent message and its current status by channel and ID.", - inputSchema: { - channel: z.enum(["sms", "mms", "whatsapp"]).describe("The channel the message was sent on."), - id: z.string().describe("The message ID returned when the message was sent."), - }, - annotations: { title: "Get message", readOnlyHint: true, openWorldHint: true }, - }, - async ({ channel, id }) => asText(await kudosity.v2("GET", `/v2/${channel}/${id}`)), -); - -// ── create_webhook ───────────────────────────────────────────────────────────── -server.registerTool( - "create_webhook", - { - title: "Create webhook", - description: "Register a webhook (HTTPS URL) to receive event callbacks — delivery status, inbound messages, link hits, opt-outs.", - inputSchema: { - name: z.string().min(2).max(100).describe("A name for the webhook."), - url: z.string().url().describe("HTTPS URL that will receive JSON POST callbacks."), - event_types: z.array(z.enum(EVENT_TYPES)).optional().describe("Event types to subscribe to. Omit to receive all."), - senders: z.array(z.string()).optional().describe("Only fire for these sender numbers."), - statuses: z.array(z.string()).optional().describe("Only fire for these statuses (status events only), e.g. DELIVERED, FAILED."), - rate_limit: z.number().int().min(0).max(10000).optional().describe("Max callbacks per second (0 = system default)."), - }, - annotations: { title: "Create webhook", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, - }, - async (a) => { - const filter: Record = {}; - if (a.event_types?.length) filter.event_type = a.event_types; - if (a.senders?.length) filter.sender = a.senders; - if (a.statuses?.length) filter.status = a.statuses; - const body: Record = { name: a.name, url: a.url }; - if (Object.keys(filter).length) body.filter = filter; - if (a.rate_limit !== undefined) body.rate_limit = a.rate_limit; - return asText(await kudosity.v2("POST", "/v2/webhook", body)); - }, -); - -// ── list_webhooks ────────────────────────────────────────────────────────────── -server.registerTool( - "list_webhooks", - { - title: "List webhooks", - description: "List all webhooks configured on your account.", - inputSchema: {}, - annotations: { title: "List webhooks", readOnlyHint: true, openWorldHint: true }, - }, - async () => asText(await kudosity.v2("GET", "/v2/webhook")), -); - -// ── delete_webhook ───────────────────────────────────────────────────────────── -server.registerTool( - "delete_webhook", - { - title: "Delete webhook", - description: "Delete a webhook by its ID.", - inputSchema: { id: z.string().describe("The webhook ID to delete.") }, - annotations: { title: "Delete webhook", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, - }, - async ({ id }) => asText(await kudosity.v2("DELETE", `/v2/webhook/${id}`)), -); - -// ── get_balance (v1 — requires KUDOSITY_API_SECRET) ──────────────────────────── -server.registerTool( - "get_balance", - { - title: "Get account balance", - description: "Get your Kudosity account balance. Requires KUDOSITY_API_SECRET (v1 Basic auth).", - inputSchema: {}, - annotations: { title: "Get account balance", readOnlyHint: true, openWorldHint: true }, - }, - async () => asText(await kudosity.v1("GET", "/get-balance.json")), -); - -// ── Contact lists (v1 — require KUDOSITY_API_SECRET) ─────────────────────────── -server.registerTool( - "create_list", - { - title: "Create contact list", - description: "Create a new contact list. Requires KUDOSITY_API_SECRET (v1 auth).", - inputSchema: { name: z.string().describe("A unique name for the list.") }, - annotations: { title: "Create contact list", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, - }, - async ({ name }) => asText(await kudosity.v1("POST", "/add-list.json", { name })), -); - -server.registerTool( - "get_lists", - { - title: "Get contact lists", - description: "List all contact lists on your account. Requires KUDOSITY_API_SECRET (v1 auth).", - inputSchema: {}, - annotations: { title: "Get contact lists", readOnlyHint: true, openWorldHint: true }, - }, - async () => asText(await kudosity.v1("GET", "/get-lists.json")), -); - -server.registerTool( - "get_list", - { - title: "Get contact list", - description: "Get a contact list, optionally including its members. Requires KUDOSITY_API_SECRET (v1 auth).", - inputSchema: { - list_id: z.number().int().describe("Numeric list ID."), - members: z.boolean().optional().describe("Include member contacts in the response."), - }, - annotations: { title: "Get contact list", readOnlyHint: true, openWorldHint: true }, - }, - async ({ list_id, members }) => { - const form: Record = { list_id: String(list_id) }; - if (members) form.members = "true"; - return asText(await kudosity.v1("POST", "/get-list.json", form)); - }, -); - -server.registerTool( - "add_contact_to_list", - { - title: "Add contact to list", - description: "Add a contact (mobile number) to a list. Requires KUDOSITY_API_SECRET (v1 auth).", - inputSchema: { - list_id: z.number().int().describe("Numeric ID of the list to add to."), - number: z.string().describe("Mobile number in E.164 format (or local with country_code)."), - first_name: z.string().optional().describe("Contact first name."), - last_name: z.string().optional().describe("Contact last name."), - country_code: z.string().optional().describe("2-letter ISO country code to format a local number, e.g. AU."), - }, - annotations: { title: "Add contact to list", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, - }, - async (a) => { - const form: Record = { list_id: String(a.list_id), msisdn: a.number }; - if (a.first_name) form.first_name = a.first_name; - if (a.last_name) form.last_name = a.last_name; - if (a.country_code) form.countrycode = a.country_code; - return asText(await kudosity.v1("POST", "/add-to-list.json", form)); - }, -); - -server.registerTool( - "remove_contact_from_list", - { - title: "Remove contact from list", - description: "Remove a contact from a list (list_id 0 = remove from all lists). Requires KUDOSITY_API_SECRET (v1 auth).", - inputSchema: { - number: z.string().describe("Mobile number in E.164 format."), - list_id: z.number().int().optional().describe("List ID to remove from. 0 removes from all lists."), - country_code: z.string().optional().describe("2-letter ISO country code for a local number."), - }, - annotations: { title: "Remove contact from list", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, - }, - async (a) => { - const form: Record = { msisdn: a.number }; - if (a.list_id !== undefined) form.list_id = String(a.list_id); - if (a.country_code) form.countrycode = a.country_code; - return asText(await kudosity.v1("POST", "/delete-from-list.json", form)); - }, -); - -server.registerTool( - "delete_list", - { - title: "Delete contact list", - description: "Delete a contact list by ID. Requires KUDOSITY_API_SECRET (v1 auth).", - inputSchema: { list_id: z.number().int().describe("Numeric list ID to delete.") }, - annotations: { title: "Delete contact list", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, - }, - async ({ list_id }) => asText(await kudosity.v1("POST", "/remove-list.json", { list_id: String(list_id) })), -); - -// ── Tier-1: reporting & replies ──────────────────────────────────────────────── -const MESSAGE_STATUSES = [ - "PENDING", "SENT", "DELIVERED", "FAILED", "ACCEPTED", "SOFT_BOUNCE", "HARD_BOUNCE", - "REJECTED", "UNDELIVERABLE", "READ", "OTHER", -] as const; -const LIST_PATHS: Record = { sms: "/v2/sms", whatsapp: "/v2/whatsapp/messages" }; - -server.registerTool( - "list_messages", - { - title: "List messages", - description: - "List sent or received messages with their delivery status. Filter by status, recipient, direction " + - "(OUT = sent, IN = inbound replies), and date range. This is the delivery-report and reply view.", - inputSchema: { - channel: z.enum(["sms", "whatsapp"]).describe("Channel to list (MMS has no list endpoint; use get_message by id)."), - status: z.enum(MESSAGE_STATUSES).optional().describe("Only messages with this status, e.g. DELIVERED, FAILED."), - direction: z.enum(["OUT", "IN"]).optional().describe("OUT = messages you sent; IN = inbound replies."), - recipient: z.string().optional().describe("Filter by recipient number."), - sender: z.string().optional().describe("Filter by sender number."), - start_date: z.string().optional().describe("Only messages created on/after this RFC3339 datetime."), - end_date: z.string().optional().describe("Only messages created on/before this RFC3339 datetime."), - limit: z.number().int().min(1).max(1000).optional().describe("Max results (default 100)."), - }, - annotations: { title: "List messages", readOnlyHint: true, openWorldHint: true }, - }, - async (a) => { - const p = new URLSearchParams(); - for (const k of ["status", "direction", "recipient", "sender", "start_date", "end_date"] as const) { - if (a[k]) p.set(k, a[k] as string); - } - if (a.limit !== undefined) p.set("limit", String(a.limit)); - const qs = p.toString(); - return asText(await kudosity.v2("GET", `${LIST_PATHS[a.channel]}${qs ? `?${qs}` : ""}`)); - }, -); - -// ── API discovery (reads the live specs from the Kudosity developer portal) ──── -// These let a user research the full Kudosity API — every endpoint, not just the -// tools above. Specs are fetched live so they always reflect the latest docs. - -server.registerTool( - "list_specs", - { - title: "List API specs", - description: "List the available Kudosity API specifications and how many endpoints each has. Use this to see what the Kudosity platform can do beyond the built-in tools.", - inputSchema: {}, - annotations: { title: "List API specs", readOnlyHint: true, openWorldHint: true }, - }, - async () => { - const specs = await loadSpecs(); - const out = Object.entries(specs).map(([title, s]) => ({ - title, - version: s.info?.version, - description: (s.info?.description || "").replace(/\s+/g, " ").slice(0, 400), - server: s.servers?.[0]?.url, - endpoints: Object.keys(s.paths || {}).length, - })); - return asText(out); - }, -); - -server.registerTool( - "list_endpoints", - { - title: "List API endpoints", - description: "List all endpoints (path + method + summary) for a given Kudosity API spec. Use list_specs first to get spec titles.", - inputSchema: { - spec: z.string().describe("Spec title, e.g. 'Transmit Message API' or 'Transmit SMS API'."), - }, - annotations: { title: "List API endpoints", readOnlyHint: true, openWorldHint: true }, - }, - async ({ spec }) => { - const specs = await loadSpecs(); - if (!specs[spec]) return asText({ error: `Unknown spec '${spec}'. Use list_specs to see available specs.` }); - const out: unknown[] = []; - forEachOperation(specs, (m) => out.push({ path: m.path, method: m.method, summary: m.summary }), spec); - return asText(out); - }, -); - -server.registerTool( - "search_endpoints", - { - title: "Search API endpoints", - description: "Search the Kudosity API for endpoints matching a keyword — across paths, summaries, descriptions, tags and parameters. Use this to discover what the API can do.", - inputSchema: { - query: z.string().describe("Keyword to search for, e.g. 'delivery', 'opt out', 'balance'."), - spec: z.string().optional().describe("Optional spec title to limit the search."), - }, - annotations: { title: "Search API endpoints", readOnlyHint: true, openWorldHint: true }, - }, - async ({ query, spec }) => { - const specs = await loadSpecs(); - const q = query.toLowerCase(); - const out: unknown[] = []; - forEachOperation( - specs, - (m) => { - const hay = [m.path, m.op.summary, m.op.description, (m.op.tags || []).join(" "), JSON.stringify(m.op.parameters || m.op.requestBody || "")] - .join(" ") - .toLowerCase(); - if (hay.includes(q)) out.push({ spec: m.spec, path: m.path, method: m.method, summary: m.op.summary }); - }, - spec, - ); - return asText(out.slice(0, 50)); - }, -); - -server.registerTool( - "get_endpoint", - { - title: "Get endpoint details", - description: "Get the full details of one Kudosity API endpoint — description, parameters, request body schema, and auth. Use search_endpoints or list_endpoints first.", - inputSchema: { - spec: z.string().describe("Spec title, e.g. 'Transmit Message API'."), - path: z.string().describe("The endpoint path, e.g. '/v2/sms'."), - method: z.string().describe("HTTP method, e.g. 'POST'."), - }, - annotations: { title: "Get endpoint details", readOnlyHint: true, openWorldHint: true }, - }, - async ({ spec, path, method }) => { - const specs = await loadSpecs(); - const s = specs[spec]; - if (!s) return asText({ error: `Unknown spec '${spec}'. Use list_specs.` }); - const op = s.paths?.[path]?.[method.toLowerCase()]; - if (!op) return asText({ error: `No ${method.toUpperCase()} ${path} in '${spec}'. Use list_endpoints.` }); - return asText({ - spec, - path, - method: method.toUpperCase(), - server: s.servers?.[0]?.url, - summary: op.summary, - description: op.description, - parameters: op.parameters, - requestBody: op.requestBody, - security: op.security, - }); - }, -); +const apiSecret = process.env.KUDOSITY_API_SECRET || undefined; +const server = createServer({ apiKey, apiSecret }); const transport = new StdioServerTransport(); await server.connect(transport); console.error("Kudosity MCP server running on stdio."); diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..4d3f46d --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,372 @@ +/** + * Minimal OAuth 2.1 authorization server that wraps Kudosity API credentials. + * + * Kudosity's API authenticates with an API key (+ secret). Claude's connector + * directory requires OAuth for authenticated services, so this module puts a + * standards-compliant OAuth front door on the MCP server itself: + * + * 1. Claude discovers us via RFC 9728 (protected resource metadata) and + * RFC 8414 (authorization server metadata). + * 2. Claude identifies itself with a Client ID Metadata Document (CIMD) or + * Dynamic Client Registration (RFC 7591) — both stateless here. + * 3. /authorize shows a consent page on OUR domain where the user pastes + * their Kudosity API key (and optional secret). We verify the key against + * the Kudosity API, then issue an authorization code. + * 4. /token exchanges the code (PKCE S256 required) for an access token. + * + * Codes and tokens are AES-256-GCM envelopes containing the user's + * credentials, encrypted with a server-held key. Nothing is stored server-side: + * no database, no session table. Rotating KUDOSITY_MCP_TOKEN_KEY invalidates + * every token at once. The Kudosity API itself is untouched. + */ +import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Credentials } from "./server.js"; + +export interface OAuthConfig { + /** Public origin of this deployment, e.g. https://mcp.kudosity.com (no trailing slash). */ + publicUrl: string; + /** Path of the MCP endpoint, e.g. /mcp. */ + mcpPath: string; + /** Secret used to derive the token-encryption key. */ + tokenKey: string; + /** Verify an API key/secret pair against Kudosity before issuing a code. */ + verifyCredentials: (creds: Credentials) => Promise<{ ok: boolean; message?: string }>; + /** Lifetimes in seconds. */ + codeTtl?: number; + accessTtl?: number; + refreshTtl?: number; + /** Fetch implementation (injectable for tests). */ + fetch?: typeof fetch; +} + +type Purpose = "code" | "access" | "refresh" | "client"; + +interface CodePayload { + k: string; s?: string; exp: number; + client_id: string; redirect_uri: string; code_challenge: string; resource?: string; nonce: string; +} +interface TokenPayload { k: string; s?: string; exp: number; client_id: string } +interface RegisteredClient { redirect_uris: string[]; client_name?: string; iat: number } + +export const SCOPES = ["kudosity"] as const; + +const b64u = (b: Buffer) => b.toString("base64url"); +const unb64u = (s: string) => Buffer.from(s, "base64url"); + +export class OAuthProvider { + private readonly key: Buffer; + private readonly cfg: Required> & OAuthConfig; + private readonly cimdCache = new Map(); + + constructor(cfg: OAuthConfig) { + if (!cfg.tokenKey || cfg.tokenKey.length < 16) throw new Error("KUDOSITY_MCP_TOKEN_KEY must be a strong secret (≥16 chars; use `openssl rand -base64 32`)."); + if (!/^https?:\/\/[^/]+$/.test(cfg.publicUrl)) throw new Error("KUDOSITY_MCP_PUBLIC_URL must be an origin like https://mcp.example.com (no path, no trailing slash)."); + this.key = createHash("sha256").update(cfg.tokenKey).digest(); + this.cfg = { codeTtl: 300, accessTtl: 3600, refreshTtl: 30 * 24 * 3600, ...cfg }; + } + + get issuer(): string { return this.cfg.publicUrl; } + get resource(): string { return `${this.cfg.publicUrl}${this.cfg.mcpPath}`; } + get prmUrl(): string { return `${this.cfg.publicUrl}/.well-known/oauth-protected-resource${this.cfg.mcpPath}`; } + + // ── Sealed envelopes ───────────────────────────────────────────────────── + private seal(purpose: Purpose, payload: object): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", this.key, iv); + cipher.setAAD(Buffer.from(purpose)); + const ct = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]); + return b64u(Buffer.concat([iv, ct, cipher.getAuthTag()])); + } + private open(purpose: Purpose, token: string): T | null { + try { + const raw = unb64u(token); + if (raw.length < 12 + 16 + 1) return null; + const iv = raw.subarray(0, 12), tag = raw.subarray(raw.length - 16), ct = raw.subarray(12, raw.length - 16); + const d = createDecipheriv("aes-256-gcm", this.key, iv); + d.setAAD(Buffer.from(purpose)); + d.setAuthTag(tag); + const obj = JSON.parse(Buffer.concat([d.update(ct), d.final()]).toString("utf8")) as T & { exp?: number }; + if (typeof obj.exp === "number" && obj.exp < now()) return null; + return obj; + } catch { return null; } + } + + /** Credentials from a bearer token issued by this provider, or null. */ + credentialsFromAccessToken(token: string): Credentials | null { + const p = this.open("access", token); + return p ? { apiKey: p.k, apiSecret: p.s } : null; + } + + // ── Discovery documents ────────────────────────────────────────────────── + protectedResourceMetadata() { + return { resource: this.resource, authorization_servers: [this.issuer], bearer_methods_supported: ["header"], scopes_supported: [...SCOPES] }; + } + authorizationServerMetadata() { + return { + issuer: this.issuer, + authorization_endpoint: `${this.issuer}/authorize`, + token_endpoint: `${this.issuer}/token`, + registration_endpoint: `${this.issuer}/register`, + scopes_supported: [...SCOPES], + response_types_supported: ["code"], + response_modes_supported: ["query"], + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: ["none"], + code_challenge_methods_supported: ["S256"], + client_id_metadata_document_supported: true, + service_documentation: "https://developers.kudosity.com/docs/mcp", + }; + } + wwwAuthenticate(error = "invalid_token", description = "Authentication required for this tool"): string { + return `Bearer error="${error}", error_description="${description}", resource_metadata="${this.prmUrl}", scope="${SCOPES.join(" ")}"`; + } + + // ── Clients: DCR (stateless) and CIMD ──────────────────────────────────── + /** RFC 7591: register a public client. The client_id is a sealed envelope of its redirect URIs. */ + register(body: unknown): { status: number; json: object } { + const b = (body ?? {}) as Record; + const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u): u is string => typeof u === "string") : []; + if (!uris.length || !uris.every(isAcceptableRedirect)) { + return { status: 400, json: { error: "invalid_redirect_uri", error_description: "redirect_uris must be one or more https URLs or loopback http URLs." } }; + } + const iat = now(); + const client: RegisteredClient = { redirect_uris: uris, client_name: typeof b.client_name === "string" ? b.client_name.slice(0, 100) : undefined, iat }; + const client_id = "dcr_" + this.seal("client", client); + return { + status: 201, + json: { + client_id, client_id_issued_at: iat, redirect_uris: uris, client_name: client.client_name, + token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], + }, + }; + } + + private async resolveClient(clientId: string): Promise { + if (clientId.startsWith("dcr_")) return this.open("client", clientId.slice(4)); + if (isCimdUrl(clientId)) { + const hit = this.cimdCache.get(clientId); + if (hit && now() - hit.at < 3600) return hit.doc; + try { + const f = this.cfg.fetch ?? fetch; + const res = await f(clientId, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(5000) }); + if (!res.ok) return null; + const doc = (await res.json()) as { client_id?: string; redirect_uris?: unknown; client_name?: string }; + if (doc.client_id !== clientId || !Array.isArray(doc.redirect_uris)) return null; + const uris = doc.redirect_uris.filter((u): u is string => typeof u === "string" && isAcceptableRedirect(u)); + if (!uris.length) return null; + const client: RegisteredClient = { redirect_uris: uris, client_name: doc.client_name, iat: now() }; + this.cimdCache.set(clientId, { at: now(), doc: client }); + return client; + } catch { return null; } + } + return null; + } + + // ── /authorize ─────────────────────────────────────────────────────────── + /** Validate an authorization request. Returns the client + display info, or an error to show/redirect. */ + async authorizeRequest(q: URLSearchParams): Promise< + | { ok: true; client: RegisteredClient; params: AuthorizeParams; relyingParty: string; redirectHost: string } + | { ok: false; status: number; error: string; description: string; redirect?: string } + > { + const p: AuthorizeParams = { + client_id: q.get("client_id") ?? "", redirect_uri: q.get("redirect_uri") ?? "", response_type: q.get("response_type") ?? "", + state: q.get("state") ?? undefined, code_challenge: q.get("code_challenge") ?? "", code_challenge_method: q.get("code_challenge_method") ?? "", + scope: q.get("scope") ?? undefined, resource: q.get("resource") ?? undefined, + }; + if (!p.client_id) return { ok: false, status: 400, error: "invalid_request", description: "client_id is required." }; + const client = await this.resolveClient(p.client_id); + if (!client) return { ok: false, status: 400, error: "invalid_client", description: "Unknown client_id. Register with /register or use a Client ID Metadata Document URL." }; + if (!p.redirect_uri || !redirectUriAllowed(p.redirect_uri, client.redirect_uris)) { + return { ok: false, status: 400, error: "invalid_request", description: "redirect_uri is not registered for this client." }; + } + // From here on, errors go back to the client via redirect (RFC 6749 §4.1.2.1). + const fail = (error: string, description: string) => ({ ok: false as const, status: 302, error, description, redirect: withParams(p.redirect_uri, { error, error_description: description, state: p.state }) }); + if (p.response_type !== "code") return fail("unsupported_response_type", "Only response_type=code is supported."); + if (!p.code_challenge || p.code_challenge_method !== "S256") return fail("invalid_request", "PKCE with code_challenge_method=S256 is required."); + if (p.resource && normaliseUrl(p.resource) !== normaliseUrl(this.resource)) return fail("invalid_target", `resource must be ${this.resource}.`); + const relyingParty = isCimdUrl(p.client_id) ? new URL(p.client_id).host : (client.client_name ?? "an MCP client"); + return { ok: true, client, params: p, relyingParty, redirectHost: new URL(p.redirect_uri).host }; + } + + /** Verify credentials and mint the redirect carrying the authorization code. */ + async issueCode(p: AuthorizeParams, creds: Credentials): Promise<{ ok: true; redirect: string } | { ok: false; message: string }> { + if (!creds.apiKey) return { ok: false, message: "Enter your Kudosity API key." }; + const v = await this.cfg.verifyCredentials(creds); + if (!v.ok) return { ok: false, message: v.message ?? "Kudosity rejected that API key." }; + const payload: CodePayload = { + k: creds.apiKey, s: creds.apiSecret || undefined, exp: now() + this.cfg.codeTtl, + client_id: p.client_id, redirect_uri: p.redirect_uri, code_challenge: p.code_challenge, resource: p.resource, nonce: b64u(randomBytes(8)), + }; + const code = this.seal("code", payload); + return { ok: true, redirect: withParams(p.redirect_uri, { code, state: p.state }) }; + } + + // ── /token ─────────────────────────────────────────────────────────────── + token(form: URLSearchParams): { status: number; json: object } { + const grant = form.get("grant_type"); + const err = (status: number, error: string, error_description: string) => ({ status, json: { error, error_description } }); + + if (grant === "authorization_code") { + const code = form.get("code") ?? "", verifier = form.get("code_verifier") ?? "", clientId = form.get("client_id") ?? ""; + const c = this.open("code", code); + if (!c) return err(400, "invalid_grant", "Authorization code is invalid or expired."); + if (clientId && clientId !== c.client_id) return err(400, "invalid_grant", "client_id does not match the authorization code."); + const ru = form.get("redirect_uri"); + if (ru && ru !== c.redirect_uri) return err(400, "invalid_grant", "redirect_uri does not match the authorization request."); + if (!verifier) return err(400, "invalid_request", "code_verifier is required (PKCE)."); + const expected = b64u(createHash("sha256").update(verifier).digest()); + if (!safeEqual(expected, c.code_challenge)) return err(400, "invalid_grant", "PKCE verification failed."); + return { status: 200, json: this.mintTokens({ k: c.k, s: c.s, client_id: c.client_id }) }; + } + if (grant === "refresh_token") { + const r = this.open("refresh", form.get("refresh_token") ?? ""); + if (!r) return err(400, "invalid_grant", "Refresh token is invalid or expired."); + const clientId = form.get("client_id"); + if (clientId && clientId !== r.client_id) return err(400, "invalid_grant", "client_id does not match the refresh token."); + return { status: 200, json: this.mintTokens({ k: r.k, s: r.s, client_id: r.client_id }) }; + } + return err(400, "unsupported_grant_type", "Use authorization_code or refresh_token."); + } + + private mintTokens(base: { k: string; s?: string; client_id: string }) { + return { + access_token: this.seal("access", { ...base, exp: now() + this.cfg.accessTtl } satisfies TokenPayload), + token_type: "Bearer", + expires_in: this.cfg.accessTtl, + refresh_token: this.seal("refresh", { ...base, exp: now() + this.cfg.refreshTtl } satisfies TokenPayload), + scope: SCOPES.join(" "), + }; + } + + // ── Consent page ───────────────────────────────────────────────────────── + consentPage(a: { params: AuthorizeParams; relyingParty: string; redirectHost: string; error?: string }): string { + const h = escapeHtml; + const hidden = Object.entries(a.params) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => ``) + .join("\n "); + const loopback = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(a.redirectHost); + return ` + +Connect Kudosity +
+

Connect your Kudosity account

+

${h(a.relyingParty)} is asking to use Kudosity on your behalf. After you continue you will be sent back to ${h(a.redirectHost)}.

+ ${loopback ? `

The return address is a local application on your computer. Only continue if you started this from an app you trust.

` : ""} + ${a.error ? `

${h(a.error)}

` : ""} +
+ ${hidden} + + + + +

Both are in the Kudosity platform under Settings → API Settings. Your credentials are verified with Kudosity, sealed into an encrypted token for this connection, and never stored on this server.

+ +
+

Privacy policy · Documentation

+
`; + } +} + +export interface AuthorizeParams { + client_id: string; redirect_uri: string; response_type: string; state?: string; + code_challenge: string; code_challenge_method: string; scope?: string; resource?: string; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── +const now = () => Math.floor(Date.now() / 1000); + +function safeEqual(a: string, b: string): boolean { + const ba = Buffer.from(a), bb = Buffer.from(b); + return ba.length === bb.length && timingSafeEqual(ba, bb); +} + +/** A Client ID Metadata Document URL: https anywhere, or plain http only on loopback (local testing). */ +export function isCimdUrl(clientId: string): boolean { + try { + const u = new URL(clientId); + if (u.protocol === "https:") return true; + return u.protocol === "http:" && isLoopbackHost(u.hostname === "::1" ? "[::1]" : u.hostname); + } catch { return false; } +} + +export function isLoopbackHost(host: string): boolean { + return host === "localhost" || host === "127.0.0.1" || host === "[::1]"; +} + +/** https anywhere, or http only on loopback (native apps, RFC 8252). */ +export function isAcceptableRedirect(uri: string): boolean { + try { + const u = new URL(uri); + if (u.protocol === "https:") return true; + return u.protocol === "http:" && isLoopbackHost(u.hostname === "::1" ? "[::1]" : u.hostname); + } catch { return false; } +} + +/** Exact match, except loopback redirects compare with the port ignored (RFC 8252 §7.3). */ +export function redirectUriAllowed(requested: string, registered: string[]): boolean { + let r: URL; + try { r = new URL(requested); } catch { return false; } + if (!isAcceptableRedirect(requested)) return false; + for (const reg of registered) { + if (reg === requested) return true; + try { + const g = new URL(reg); + const loop = isLoopbackHost(g.hostname === "::1" ? "[::1]" : g.hostname) && isLoopbackHost(r.hostname === "::1" ? "[::1]" : r.hostname); + if (loop && g.protocol === r.protocol && g.hostname === r.hostname && g.pathname === r.pathname) return true; + } catch { /* skip malformed registered uri */ } + } + return false; +} + +/** Lower-case origin + path without a trailing slash, for RFC 8707 resource comparison. */ +export function normaliseUrl(u: string): string { + try { const x = new URL(u); return `${x.origin.toLowerCase()}${x.pathname.replace(/\/$/, "")}`; } catch { return u; } +} + +export function withParams(url: string, params: Record): string { + const u = new URL(url); + for (const [k, v] of Object.entries(params)) if (v !== undefined) u.searchParams.set(k, v); + return u.toString(); +} + +export function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string); +} + +/** Read a request body (form or JSON) with a size cap. */ +export async function readBody(req: IncomingMessage, limit = 1024 * 1024): Promise { + return new Promise((resolve, reject) => { + let size = 0; const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => { size += c.length; if (size > limit) { reject(new Error("Body too large")); req.destroy(); } else chunks.push(c); }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +export function sendJson(res: ServerResponse, status: number, body: unknown, headers: Record = {}): void { + res.statusCode = status; + res.setHeader("content-type", "application/json"); + res.setHeader("cache-control", "no-store"); + for (const [k, v] of Object.entries(headers)) res.setHeader(k, v); + res.end(JSON.stringify(body)); +} + +export function sendHtml(res: ServerResponse, status: number, html: string): void { + res.statusCode = status; + res.setHeader("content-type", "text/html; charset=utf-8"); + res.setHeader("cache-control", "no-store"); + res.setHeader("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"); + res.setHeader("referrer-policy", "no-referrer"); + res.setHeader("x-frame-options", "DENY"); + res.end(html); +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..0184d62 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,446 @@ +/** + * Tool definitions for the Kudosity MCP server. + * + * `createServer()` builds a fresh McpServer with all tools registered against + * the supplied credentials. Both transports use it: + * - stdio (src/index.ts) — one server per process, credentials from env + * - HTTP (src/http.ts) — one server per request, credentials from headers + * + * Tools that call the Kudosity API throw a clear, actionable error when no + * credentials were supplied. The API-discovery tools need no credentials. + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { KudosityClient } from "./kudosity.js"; +import { loadSpecs, forEachOperation } from "./specs.js"; + +export const SERVER_NAME = "kudosity"; +export const SERVER_VERSION = "0.6.0"; + +export interface Credentials { + apiKey?: string; + apiSecret?: string; +} + +export interface ServerOptions { + /** Message returned by credential-requiring tools when no credentials were supplied. */ + credentialHelp?: string; +} + +/** Tools that never call the Kudosity API with user credentials — usable before sign-in. */ +export const PUBLIC_TOOLS: ReadonlySet = new Set(["list_specs", "list_endpoints", "search_endpoints", "get_endpoint"]); + +export const DEFAULT_CREDENTIAL_HELP = + "This tool needs your Kudosity API key and none was supplied. " + + "Set KUDOSITY_API_KEY (and KUDOSITY_API_SECRET for contact-list and balance tools). " + + "Find both in the Kudosity platform under Settings → API Settings."; + +const asText = (data: unknown) => ({ + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], +}); + +const EVENT_TYPES = [ + "LINK_HIT", "OPT_OUT", "SMS_STATUS", "SMS_INBOUND", "MMS_STATUS", "MMS_INBOUND", + "WHATSAPP_STATUS", "WHATSAPP_INBOUND", "RCS_STATUS", "RCS_INBOUND", +] as const; + +const MESSAGE_STATUSES = [ + "PENDING", "SENT", "DELIVERED", "FAILED", "ACCEPTED", "SOFT_BOUNCE", "HARD_BOUNCE", + "REJECTED", "UNDELIVERABLE", "READ", "OTHER", +] as const; +const LIST_PATHS: Record = { sms: "/v2/sms", whatsapp: "/v2/whatsapp/messages" }; + +const SECRET_NOTE = " Requires your API secret as well as your API key."; + +export function createServer(creds: Credentials, opts: ServerOptions = {}): McpServer { + const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); + const client = creds.apiKey ? new KudosityClient(creds.apiKey, creds.apiSecret) : null; + const help = opts.credentialHelp ?? DEFAULT_CREDENTIAL_HELP; + const kudosity = (): KudosityClient => { + if (!client) throw new Error(help); + return client; + }; + + // ── Messaging ────────────────────────────────────────────────────────────── + // Sending a message is irreversible and billable, so it is marked destructive: + // Claude always asks before calling these. + server.registerTool( + "send_sms", + { + title: "Send SMS", + description: "Send an SMS text message to a single recipient via Kudosity.", + inputSchema: { + recipient: z.string().describe("Destination number in E.164 international format, e.g. 61438333061."), + sender: z.string().describe("Sender number, or an alphanumeric ID (max 11 chars) registered to your account."), + message: z.string().describe("Message body. Long messages are automatically split into multiple parts."), + message_ref: z.string().max(500).optional().describe("Your own reference; echoed back in webhook events."), + track_links: z.boolean().optional().describe("Replace links with shortened, tracked links."), + }, + annotations: { title: "Send SMS", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }, + async (args) => asText(await kudosity().v2("POST", "/v2/sms", args)), + ); + + server.registerTool( + "send_mms", + { + title: "Send MMS", + description: "Send an MMS (media) message to a recipient via Kudosity.", + inputSchema: { + recipient: z.string().describe("Destination number in E.164 format. Australia only at present."), + sender: z.string().describe("Sender number registered to your account."), + content_urls: z.array(z.string().url()).min(1).describe("Absolute URL(s) to media. One image up to 400KB. jpg, gif, png, mp3, mp4."), + subject: z.string().max(20).optional().describe("Subject line, max 20 ASCII characters."), + message: z.string().optional().describe("Message body, max 1000 characters."), + message_ref: z.string().max(500).optional().describe("Your reference; echoed in webhooks."), + track_links: z.boolean().optional().describe("Shorten and track links in the message."), + }, + annotations: { title: "Send MMS", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }, + async (args) => asText(await kudosity().v2("POST", "/v2/mms", args)), + ); + + server.registerTool( + "send_whatsapp", + { + title: "Send WhatsApp message", + description: + "Send a WhatsApp message via Kudosity — either free-form text (only within an open 24h " + + "customer-service window) or a pre-approved template (sendable anytime).", + inputSchema: { + recipient: z.string().describe("Recipient WhatsApp number in E.164 format, e.g. 61411122211."), + content_type: z.enum(["text", "template"]).describe("'text' for free-form; 'template' for a pre-approved WhatsApp template."), + text: z.string().optional().describe("Message body — required when content_type is 'text'."), + template_name: z.string().optional().describe("Approved template name — required when content_type is 'template'."), + template_parameters: z.array(z.string()).optional().describe("Ordered values that fill the template's placeholders."), + template_locale: z.string().optional().describe("Template locale, e.g. en_US. Defaults to 'en'."), + sender: z.string().optional().describe("Registered WhatsApp sender number. Optional if the account has a single sender."), + message_ref: z.string().max(500).optional().describe("Your reference; echoed in webhooks."), + }, + annotations: { title: "Send WhatsApp message", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }, + async (a) => { + let content: Record; + if (a.content_type === "text") { + if (!a.text) throw new Error("`text` is required when content_type is 'text'."); + content = { text: { message: a.text } }; + } else { + if (!a.template_name) throw new Error("`template_name` is required when content_type is 'template'."); + const template: Record = { name: a.template_name, locale: a.template_locale ?? "en" }; + if (a.template_parameters && a.template_parameters.length) template.parameters = a.template_parameters; + content = { template }; + } + const body: Record = { recipient: a.recipient, content_type: a.content_type, content }; + if (a.sender) body.sender = a.sender; + if (a.message_ref) body.message_ref = a.message_ref; + return asText(await kudosity().v2("POST", "/v2/whatsapp/messages", body)); + }, + ); + + server.registerTool( + "get_message", + { + title: "Get message", + description: "Retrieve a previously sent message and its current status by channel and ID.", + inputSchema: { + channel: z.enum(["sms", "mms", "whatsapp"]).describe("The channel the message was sent on."), + id: z.string().describe("The message ID returned when the message was sent."), + }, + annotations: { title: "Get message", readOnlyHint: true, openWorldHint: true }, + }, + async ({ channel, id }) => asText(await kudosity().v2("GET", `/v2/${channel}/${id}`)), + ); + + server.registerTool( + "list_messages", + { + title: "List messages", + description: + "List sent or received messages with their delivery status. Filter by status, recipient, direction " + + "(OUT = sent, IN = inbound replies), and date range. This is the delivery-report and reply view.", + inputSchema: { + channel: z.enum(["sms", "whatsapp"]).describe("Channel to list (MMS has no list endpoint; use get_message by id)."), + status: z.enum(MESSAGE_STATUSES).optional().describe("Only messages with this status, e.g. DELIVERED, FAILED."), + direction: z.enum(["OUT", "IN"]).optional().describe("OUT = messages you sent; IN = inbound replies."), + recipient: z.string().optional().describe("Filter by recipient number."), + sender: z.string().optional().describe("Filter by sender number."), + start_date: z.string().optional().describe("Only messages created on/after this RFC3339 datetime."), + end_date: z.string().optional().describe("Only messages created on/before this RFC3339 datetime."), + limit: z.number().int().min(1).max(1000).optional().describe("Max results (default 100)."), + }, + annotations: { title: "List messages", readOnlyHint: true, openWorldHint: true }, + }, + async (a) => { + const p = new URLSearchParams(); + for (const k of ["status", "direction", "recipient", "sender", "start_date", "end_date"] as const) { + if (a[k]) p.set(k, a[k] as string); + } + if (a.limit !== undefined) p.set("limit", String(a.limit)); + const qs = p.toString(); + return asText(await kudosity().v2("GET", `${LIST_PATHS[a.channel]}${qs ? `?${qs}` : ""}`)); + }, + ); + + // ── Webhooks ─────────────────────────────────────────────────────────────── + server.registerTool( + "create_webhook", + { + title: "Create webhook", + description: "Register a webhook (HTTPS URL) to receive event callbacks — delivery status, inbound messages, link hits, opt-outs.", + inputSchema: { + name: z.string().min(2).max(100).describe("A name for the webhook."), + url: z.string().url().describe("HTTPS URL that will receive JSON POST callbacks."), + event_types: z.array(z.enum(EVENT_TYPES)).optional().describe("Event types to subscribe to. Omit to receive all."), + senders: z.array(z.string()).optional().describe("Only fire for these sender numbers."), + statuses: z.array(z.string()).optional().describe("Only fire for these statuses (status events only), e.g. DELIVERED, FAILED."), + rate_limit: z.number().int().min(0).max(10000).optional().describe("Max callbacks per second (0 = system default)."), + }, + annotations: { title: "Create webhook", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, + }, + async (a) => { + const filter: Record = {}; + if (a.event_types?.length) filter.event_type = a.event_types; + if (a.senders?.length) filter.sender = a.senders; + if (a.statuses?.length) filter.status = a.statuses; + const body: Record = { name: a.name, url: a.url }; + if (Object.keys(filter).length) body.filter = filter; + if (a.rate_limit !== undefined) body.rate_limit = a.rate_limit; + return asText(await kudosity().v2("POST", "/v2/webhook", body)); + }, + ); + + server.registerTool( + "list_webhooks", + { + title: "List webhooks", + description: "List all webhooks configured on your account.", + inputSchema: {}, + annotations: { title: "List webhooks", readOnlyHint: true, openWorldHint: true }, + }, + async () => asText(await kudosity().v2("GET", "/v2/webhook")), + ); + + server.registerTool( + "delete_webhook", + { + title: "Delete webhook", + description: "Delete a webhook by its ID.", + inputSchema: { id: z.string().describe("The webhook ID to delete.") }, + annotations: { title: "Delete webhook", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }, + async ({ id }) => asText(await kudosity().v2("DELETE", `/v2/webhook/${id}`)), + ); + + // ── Account (v1 — needs API secret) ──────────────────────────────────────── + server.registerTool( + "get_balance", + { + title: "Get account balance", + description: "Get your Kudosity account balance." + SECRET_NOTE, + inputSchema: {}, + annotations: { title: "Get account balance", readOnlyHint: true, openWorldHint: true }, + }, + async () => asText(await kudosity().v1("GET", "/get-balance.json")), + ); + + // ── Contact lists (v1 — need API secret) ─────────────────────────────────── + server.registerTool( + "create_list", + { + title: "Create contact list", + description: "Create a new contact list." + SECRET_NOTE, + inputSchema: { name: z.string().describe("A unique name for the list.") }, + annotations: { title: "Create contact list", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, + }, + async ({ name }) => asText(await kudosity().v1("POST", "/add-list.json", { name })), + ); + + server.registerTool( + "get_lists", + { + title: "Get contact lists", + description: "List all contact lists on your account." + SECRET_NOTE, + inputSchema: {}, + annotations: { title: "Get contact lists", readOnlyHint: true, openWorldHint: true }, + }, + async () => asText(await kudosity().v1("GET", "/get-lists.json")), + ); + + server.registerTool( + "get_list", + { + title: "Get contact list", + description: "Get a contact list, optionally including its members." + SECRET_NOTE, + inputSchema: { + list_id: z.number().int().describe("Numeric list ID."), + members: z.boolean().optional().describe("Include member contacts in the response."), + }, + annotations: { title: "Get contact list", readOnlyHint: true, openWorldHint: true }, + }, + async ({ list_id, members }) => { + const form: Record = { list_id: String(list_id) }; + if (members) form.members = "true"; + return asText(await kudosity().v1("POST", "/get-list.json", form)); + }, + ); + + server.registerTool( + "add_contact_to_list", + { + title: "Add contact to list", + description: "Add a contact (mobile number) to a list." + SECRET_NOTE, + inputSchema: { + list_id: z.number().int().describe("Numeric ID of the list to add to."), + number: z.string().describe("Mobile number in E.164 format (or local with country_code)."), + first_name: z.string().optional().describe("Contact first name."), + last_name: z.string().optional().describe("Contact last name."), + country_code: z.string().optional().describe("2-letter ISO country code to format a local number, e.g. AU."), + }, + annotations: { title: "Add contact to list", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, + }, + async (a) => { + const form: Record = { list_id: String(a.list_id), msisdn: a.number }; + if (a.first_name) form.first_name = a.first_name; + if (a.last_name) form.last_name = a.last_name; + if (a.country_code) form.countrycode = a.country_code; + return asText(await kudosity().v1("POST", "/add-to-list.json", form)); + }, + ); + + server.registerTool( + "remove_contact_from_list", + { + title: "Remove contact from list", + description: "Remove a contact from a list (list_id 0 = remove from all lists)." + SECRET_NOTE, + inputSchema: { + number: z.string().describe("Mobile number in E.164 format."), + list_id: z.number().int().optional().describe("List ID to remove from. 0 removes from all lists."), + country_code: z.string().optional().describe("2-letter ISO country code for a local number."), + }, + annotations: { title: "Remove contact from list", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }, + async (a) => { + const form: Record = { msisdn: a.number }; + if (a.list_id !== undefined) form.list_id = String(a.list_id); + if (a.country_code) form.countrycode = a.country_code; + return asText(await kudosity().v1("POST", "/delete-from-list.json", form)); + }, + ); + + server.registerTool( + "delete_list", + { + title: "Delete contact list", + description: "Delete a contact list by ID." + SECRET_NOTE, + inputSchema: { list_id: z.number().int().describe("Numeric list ID to delete.") }, + annotations: { title: "Delete contact list", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, + }, + async ({ list_id }) => asText(await kudosity().v1("POST", "/remove-list.json", { list_id: String(list_id) })), + ); + + // ── API discovery (no credentials needed) ────────────────────────────────── + // Reads the live OpenAPI specs from developers.kudosity.com so a user can + // research the full Kudosity API — every endpoint, not just the tools above. + server.registerTool( + "list_specs", + { + title: "List API specs", + description: "List the available Kudosity API specifications and how many endpoints each has. Use this to see what the Kudosity platform can do beyond the built-in tools. No credentials needed.", + inputSchema: {}, + annotations: { title: "List API specs", readOnlyHint: true, openWorldHint: true }, + }, + async () => { + const specs = await loadSpecs(); + const out = Object.entries(specs).map(([title, s]) => ({ + title, + version: s.info?.version, + description: (s.info?.description || "").replace(/\s+/g, " ").slice(0, 400), + server: s.servers?.[0]?.url, + endpoints: Object.keys(s.paths || {}).length, + })); + return asText(out); + }, + ); + + server.registerTool( + "list_endpoints", + { + title: "List API endpoints", + description: "List all endpoints (path + method + summary) for a given Kudosity API spec (see https://developers.kudosity.com/reference). Use list_specs first to get spec titles. No credentials needed.", + inputSchema: { + spec: z.string().describe("Spec title, e.g. 'Transmit Message API' or 'Transmit SMS API'."), + }, + annotations: { title: "List API endpoints", readOnlyHint: true, openWorldHint: true }, + }, + async ({ spec }) => { + const specs = await loadSpecs(); + if (!specs[spec]) return asText({ error: `Unknown spec '${spec}'. Use list_specs to see available specs.` }); + const out: unknown[] = []; + forEachOperation(specs, (m) => out.push({ path: m.path, method: m.method, summary: m.summary }), spec); + return asText(out); + }, + ); + + server.registerTool( + "search_endpoints", + { + title: "Search API endpoints", + description: "Search the Kudosity API reference (https://developers.kudosity.com/reference) for endpoints matching a keyword — across paths, summaries, descriptions, tags and parameters. No credentials needed.", + inputSchema: { + query: z.string().describe("Keyword to search for, e.g. 'delivery', 'opt out', 'balance'."), + spec: z.string().optional().describe("Optional spec title to limit the search."), + }, + annotations: { title: "Search API endpoints", readOnlyHint: true, openWorldHint: true }, + }, + async ({ query, spec }) => { + const specs = await loadSpecs(); + const q = query.toLowerCase(); + const out: unknown[] = []; + forEachOperation( + specs, + (m) => { + const hay = [m.path, m.op.summary, m.op.description, (m.op.tags || []).join(" "), JSON.stringify(m.op.parameters || m.op.requestBody || "")] + .join(" ") + .toLowerCase(); + if (hay.includes(q)) out.push({ spec: m.spec, path: m.path, method: m.method, summary: m.op.summary }); + }, + spec, + ); + return asText(out.slice(0, 50)); + }, + ); + + server.registerTool( + "get_endpoint", + { + title: "Get endpoint details", + description: "Get the full details of one Kudosity API endpoint — description, parameters, request body schema, and auth. Use search_endpoints or list_endpoints first. No credentials needed.", + inputSchema: { + spec: z.string().describe("Spec title, e.g. 'Transmit Message API'."), + path: z.string().describe("The endpoint path, e.g. '/v2/sms'."), + method: z.string().describe("HTTP method, e.g. 'POST'."), + }, + annotations: { title: "Get endpoint details", readOnlyHint: true, openWorldHint: true }, + }, + async ({ spec, path, method }) => { + const specs = await loadSpecs(); + const s = specs[spec]; + if (!s) return asText({ error: `Unknown spec '${spec}'. Use list_specs.` }); + const op = s.paths?.[path]?.[method.toLowerCase()]; + if (!op) return asText({ error: `No ${method.toUpperCase()} ${path} in '${spec}'. Use list_endpoints.` }); + return asText({ + spec, + path, + method: method.toUpperCase(), + server: s.servers?.[0]?.url, + summary: op.summary, + description: op.description, + parameters: op.parameters, + requestBody: op.requestBody, + security: op.security, + }); + }, + ); + + return server; +} diff --git a/test/http-test.mjs b/test/http-test.mjs new file mode 100644 index 0000000..5026c01 --- /dev/null +++ b/test/http-test.mjs @@ -0,0 +1,156 @@ +/** + * Streamable HTTP transport test: runs dist/http.js against mock Kudosity v1/v2 + * APIs and a mock spec host. Proves the behaviour the Claude connector directory + * checks for: authless initialize + tools/list, per-request header credentials, + * actionable errors without credentials, and complete tool annotations. + * No real credentials, no network. Run: node test/http-test.mjs + */ +import { spawn } from "node:child_process"; +import http from "node:http"; +import net from "node:net"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); +const received = []; + +// ── Mock Kudosity API (v2 JSON, v1 form) + a tiny OpenAPI spec ───────────── +const mock = http.createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + received.push({ method: req.method, url: req.url, headers: req.headers, body }); + const send = (code, obj) => { res.statusCode = code; res.setHeader("content-type", "application/json"); res.end(JSON.stringify(obj)); }; + if (req.url === "/spec.json") return send(200, { info: { title: "Mock API", version: "1" }, servers: [{ url: "https://mock" }], paths: { "/v2/sms": { post: { summary: "Send SMS" } } } }); + if (req.method === "POST" && req.url === "/v2/sms") return send(200, { id: "sms-1", status: "queued" }); + if (req.method === "GET" && req.url === "/v2/webhook") return send(200, [{ id: "wh-1" }]); + if (req.method === "GET" && req.url === "/get-balance.json") return send(200, { balance: 42.5, currency: "AUD" }); + return send(404, { error: "not found" }); + }); +}); +await new Promise((r) => mock.listen(0, r)); +const mockPort = mock.address().port; +const mockBase = `http://127.0.0.1:${mockPort}`; + +// ── Pick a free port, spawn the HTTP MCP server ──────────────────────────── +const port = await new Promise((r) => { const s = net.createServer(); s.listen(0, () => { const p = s.address().port; s.close(() => r(p)); }); }); +const srv = spawn("node", ["dist/http.js"], { + env: { + ...process.env, + PORT: String(port), + HOST: "127.0.0.1", + KUDOSITY_API_BASE_URL: mockBase, + KUDOSITY_V1_BASE_URL: mockBase, + KUDOSITY_SPEC_V2_URL: `${mockBase}/spec.json`, + KUDOSITY_SPEC_V1_URL: `${mockBase}/spec.json`, + // Set but NOT allowed (no KUDOSITY_HTTP_ALLOW_ENV_CREDENTIALS) → must be ignored. + KUDOSITY_API_KEY: "env-key-must-be-ignored", + }, + stdio: ["ignore", "ignore", "inherit"], +}); +const base = `http://127.0.0.1:${port}`; +for (let i = 0; i < 50; i++) { + try { const r = await fetch(`${base}/healthz`); if (r.ok) break; } catch {} + await new Promise((r) => setTimeout(r, 100)); +} + +// ── JSON-RPC over POST /mcp (stateless: every call is independent) ───────── +let id = 0; +const rpc = async (method, params, headers = {}) => { + const res = await fetch(`${base}/mcp`, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...headers }, + body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }), + }); + assert.equal(res.status, 200, `${method} → HTTP ${res.status}`); + return res.json(); +}; +const call = (name, args, headers) => rpc("tools/call", { name, arguments: args }, headers); +const text = (r) => r.result.content[0].text; +const basic = (k, s = "") => "Basic " + Buffer.from(`${k}:${s}`).toString("base64"); + +let pass = 0; +const ok = (name) => { console.log(` ✓ ${name}`); pass++; }; + +try { + const health = await (await fetch(`${base}/healthz`)).json(); + assert.equal(health.ok, true); + assert.equal(health.version, pkg.version, "server version must match package.json"); + ok(`GET /healthz → ok, version ${health.version} matches package.json`); + + const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "1" } }); + assert.equal(init.result.serverInfo.name, "kudosity"); + assert.equal(init.result.serverInfo.version, pkg.version); + ok("initialize without credentials → 200, serverInfo present"); + + const list = await rpc("tools/list", {}); + const tools = list.result.tools; + assert.equal(tools.length, 19); + for (const t of tools) { + assert.ok(t.title || t.annotations?.title, `${t.name} missing title`); + assert.ok(t.name.length <= 64, `${t.name} > 64 chars`); + const a = t.annotations ?? {}; + assert.ok(a.readOnlyHint === true || typeof a.destructiveHint === "boolean", `${t.name} lacks readOnlyHint/destructiveHint`); + assert.ok(t.description && !/KUDOSITY_API_SECRET/.test(t.description), `${t.name} description leaks env-var name`); + } + const byName = Object.fromEntries(tools.map((t) => [t.name, t])); + assert.equal(byName.send_sms.annotations.destructiveHint, true); + assert.equal(byName.get_message.annotations.readOnlyHint, true); + assert.equal(byName.delete_webhook.annotations.destructiveHint, true); + ok("tools/list without credentials → 19 tools, every tool has title + readOnly/destructive hint, names ≤ 64"); + + const specs = await call("list_specs", {}); + assert.equal(specs.result.isError, undefined); + assert.equal(JSON.parse(text(specs))[0].endpoints, 1); + ok("list_specs without credentials → works (discovery is authless)"); + + const noCreds = await call("list_webhooks", {}); + assert.equal(noCreds.result.isError, true); + assert.match(text(noCreds), /Authorization: Basic/); + assert.match(text(noCreds), /Settings → API Settings/); + assert.equal(received.some((r) => r.url === "/v2/webhook"), false, "must not call the API without credentials"); + ok("list_webhooks without credentials → isError with header instructions; env KUDOSITY_API_KEY correctly ignored"); + + const sms = await call("send_sms", { recipient: "61400000000", sender: "Kudos", message: "hi" }, { authorization: basic("key-1") }); + assert.equal(sms.result.isError, undefined); + assert.equal(JSON.parse(text(sms)).id, "sms-1"); + const smsReq = received.find((r) => r.url === "/v2/sms"); + assert.equal(smsReq.headers["x-api-key"], "key-1"); + ok("send_sms with `Authorization: Basic key:` → x-api-key forwarded, response parsed"); + + const wh = await call("list_webhooks", {}, { "x-api-key": "key-2" }); + assert.equal(wh.result.isError, undefined); + assert.equal(received.find((r) => r.url === "/v2/webhook").headers["x-api-key"], "key-2"); + ok("list_webhooks with `x-api-key` header → forwarded"); + + const bal = await call("get_balance", {}, { authorization: basic("key-3", "sec-3") }); + assert.equal(bal.result.isError, undefined); + assert.equal(JSON.parse(text(bal)).balance, 42.5); + const balReq = received.find((r) => r.url === "/get-balance.json"); + assert.equal(balReq.headers.authorization, basic("key-3", "sec-3")); + ok("get_balance with `Authorization: Basic key:secret` → v1 Basic auth forwarded"); + + const balNoSecret = await call("get_balance", {}, { "x-api-key": "key-4" }); + assert.equal(balNoSecret.result.isError, true); + assert.match(text(balNoSecret), /secret/i); + ok("get_balance with key but no secret → clear error mentioning the secret"); + + const bearer = await call("list_webhooks", {}, { authorization: "Bearer key-5" }); + assert.equal(bearer.result.isError, undefined); + ok("`Authorization: Bearer key` accepted as API key"); + + const get = await fetch(`${base}/mcp`); + assert.equal(get.status, 405); + const notFound = await fetch(`${base}/nope`, { method: "POST" }); + assert.equal(notFound.status, 404); + ok("GET /mcp → 405 (stateless), POST /nope → 404"); + + console.log(`\n✅ ALL ${pass} HTTP CHECKS PASSED`); + process.exitCode = 0; +} catch (e) { + console.error("\n❌ HTTP TEST FAILED:", e.message); + process.exitCode = 1; +} finally { + srv.kill(); + mock.close(); +} diff --git a/test/oauth-test.mjs b/test/oauth-test.mjs new file mode 100644 index 0000000..4bd78d4 --- /dev/null +++ b/test/oauth-test.mjs @@ -0,0 +1,172 @@ +/** + * OAuth (lazy-auth) test: runs dist/http.js in OAuth mode against a mock + * Kudosity API and a mock CIMD host, then drives the whole flow the way Claude + * does — discovery → 401 gate → DCR and CIMD clients → consent → PKCE code → + * token → authenticated tool call → refresh. No network, no real credentials. + * Run: node test/oauth-test.mjs + */ +import { spawn } from "node:child_process"; +import http from "node:http"; +import net from "node:net"; +import assert from "node:assert/strict"; +import { createHash, randomBytes } from "node:crypto"; + +const freePort = () => new Promise((r) => { const s = net.createServer(); s.listen(0, () => { const p = s.address().port; s.close(() => r(p)); }); }); +const received = []; + +// ── Mock Kudosity API + CIMD document host ────────────────────────────────── +const mockPort = await freePort(); +const mock = http.createServer((req, res) => { + let body = ""; req.on("data", (c) => (body += c)); + req.on("end", () => { + received.push({ method: req.method, url: req.url, headers: req.headers, body }); + const send = (code, obj) => { res.statusCode = code; res.setHeader("content-type", "application/json"); res.end(JSON.stringify(obj)); }; + if (req.url === "/client-metadata") return send(200, { client_id: `http://127.0.0.1:${mockPort}/client-metadata`, client_name: "Claude (test)", redirect_uris: ["https://claude.ai/api/mcp/auth_callback", "http://localhost/callback", "http://127.0.0.1/callback"] }); + if (req.url === "/spec.json") return send(200, { info: { title: "Mock" }, paths: {} }); + const key = req.headers["x-api-key"]; + if (req.method === "GET" && req.url === "/v2/webhook") return key === "good-key" ? send(200, [{ id: "wh-1" }]) : send(401, { error: "unauthorised" }); + if (req.method === "GET" && req.url === "/get-balance.json") return req.headers.authorization === "Basic " + Buffer.from("good-key:good-secret").toString("base64") ? send(200, { balance: 7 }) : send(401, { error: "bad" }); + if (req.method === "POST" && req.url === "/v2/sms") return key === "good-key" ? send(200, { id: "sms-1" }) : send(401, { error: "bad" }); + return send(404, {}); + }); +}); +await new Promise((r) => mock.listen(mockPort, "127.0.0.1", r)); +const mockBase = `http://127.0.0.1:${mockPort}`; + +// ── Server in OAuth mode ──────────────────────────────────────────────────── +const port = await freePort(); +const base = `http://127.0.0.1:${port}`; +const srv = spawn("node", ["dist/http.js"], { + env: { ...process.env, PORT: String(port), HOST: "127.0.0.1", KUDOSITY_API_BASE_URL: mockBase, KUDOSITY_V1_BASE_URL: mockBase, KUDOSITY_SPEC_V2_URL: `${mockBase}/spec.json`, KUDOSITY_SPEC_V1_URL: `${mockBase}/spec.json`, KUDOSITY_MCP_PUBLIC_URL: base, KUDOSITY_MCP_TOKEN_KEY: "test-token-key-please-rotate-me" }, + stdio: ["ignore", "ignore", "inherit"], +}); +for (let i = 0; i < 50; i++) { try { if ((await fetch(`${base}/healthz`)).ok) break; } catch {} await new Promise((r) => setTimeout(r, 100)); } + +let id = 0; +const post = (path, body, headers = {}) => fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...headers }, body: JSON.stringify(body) }); +const rpc = (method, params, headers) => post("/mcp", { jsonrpc: "2.0", id: ++id, method, params }, headers); +const call = (name, args, headers) => rpc("tools/call", { name, arguments: args }, headers); +const form = (path, data, extra = {}) => fetch(`${base}${path}`, { method: "POST", redirect: "manual", headers: { "content-type": "application/x-www-form-urlencoded", ...extra }, body: new URLSearchParams(data).toString() }); +const pkce = () => { const verifier = randomBytes(32).toString("base64url"); return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") }; }; + +let pass = 0; const ok = (n) => { console.log(` ✓ ${n}`); pass++; }; + +/** Run the authorization-code flow for a given client_id + redirect_uri; returns the token response. */ +async function authorize({ client_id, redirect_uri, api_key, api_secret = "", verifierOverride }) { + const { verifier, challenge } = pkce(); + const params = { client_id, redirect_uri, response_type: "code", state: "st-1", code_challenge: challenge, code_challenge_method: "S256", scope: "kudosity", resource: `${base.toUpperCase()}/mcp/` }; + const page = await fetch(`${base}/authorize?` + new URLSearchParams(params)); + assert.equal(page.status, 200); const html = await page.text(); + assert.match(html, /Connect your Kudosity account/); assert.match(html, /API Settings/); + const consent = await form("/authorize", { ...params, api_key, api_secret }); + return { consent, verifier: verifierOverride ?? verifier, params }; +} + +try { + const health = await (await fetch(`${base}/healthz`)).json(); + assert.equal(health.oauth, true); + ok("healthz reports oauth: true"); + + const prm = await (await fetch(`${base}/.well-known/oauth-protected-resource/mcp`)).json(); + assert.equal(prm.resource, `${base}/mcp`); assert.deepEqual(prm.authorization_servers, [base]); + const prmRoot = await (await fetch(`${base}/.well-known/oauth-protected-resource`)).json(); + assert.equal(prmRoot.resource, prm.resource); + const as = await (await fetch(`${base}/.well-known/oauth-authorization-server`)).json(); + assert.equal(as.issuer, base); assert.equal(as.client_id_metadata_document_supported, true); + assert.ok(as.token_endpoint_auth_methods_supported.includes("none")); assert.deepEqual(as.code_challenge_methods_supported, ["S256"]); assert.ok(as.registration_endpoint); + ok("discovery: PRM (root + /mcp) and AS metadata advertise CIMD, DCR, PKCE S256, public client"); + + const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "1" } }); + assert.equal(init.status, 200); + const list = await rpc("tools/list", {}); assert.equal(list.status, 200); assert.equal((await list.json()).result.tools.length, 19); + const pub = await call("list_specs", {}); assert.equal(pub.status, 200); assert.equal((await pub.json()).result.isError, undefined); + ok("without a token: initialize, tools/list and public discovery tools return 200"); + + const gated = await call("send_sms", { recipient: "6140", sender: "K", message: "x" }); + assert.equal(gated.status, 401); + const www = gated.headers.get("www-authenticate"); + assert.match(www, /^Bearer error="invalid_token"/); assert.ok(www.includes(`resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`)); assert.match(www, /scope="kudosity"/); + assert.equal(received.some((r) => r.url === "/v2/sms"), false); + ok("protected tool without a token → HTTP 401 + WWW-Authenticate (lazy-auth gate, API never called)"); + + const bad = await call("send_sms", { recipient: "6140", sender: "K", message: "x" }, { authorization: "Bearer not-a-real-token" }); + assert.equal(bad.status, 401); assert.match(bad.headers.get("www-authenticate"), /invalid_token/); + assert.equal(received.some((r) => r.url === "/v2/sms"), false); + ok("expired/garbage bearer on a protected tool → 401 (so Claude refreshes or re-authorises), API never called"); + const badPublic = await call("list_specs", {}, { authorization: "Bearer not-a-real-token" }); + assert.equal(badPublic.status, 200); + ok("garbage bearer on a public tool → still 200 (public tools never require auth)"); + const headerMode = await call("list_webhooks", {}, { "x-api-key": "good-key" }); + assert.equal(headerMode.status, 200); assert.equal((await headerMode.json()).result.isError, undefined); + ok("x-api-key header still works alongside OAuth (custom connectors with request headers)"); + + // ── DCR client ── + const reg = await post("/register", { client_name: "Test client", redirect_uris: ["https://claude.ai/api/mcp/auth_callback"], token_endpoint_auth_method: "none" }); + assert.equal(reg.status, 201); const client = await reg.json(); + assert.ok(client.client_id.startsWith("dcr_")); assert.equal(client.token_endpoint_auth_method, "none"); + const regBad = await post("/register", { redirect_uris: ["http://evil.example/cb"] }); assert.equal(regBad.status, 400); + ok("DCR: registers a public client statelessly; rejects non-https, non-loopback redirect URIs"); + + const badRedirect = await fetch(`${base}/authorize?` + new URLSearchParams({ client_id: client.client_id, redirect_uri: "https://evil.example/cb", response_type: "code", code_challenge: "x", code_challenge_method: "S256" }), { redirect: "manual" }); + assert.equal(badRedirect.status, 400); + ok("authorize with an unregistered redirect_uri → 400 (never redirects to an attacker)"); + + const noPkce = await fetch(`${base}/authorize?` + new URLSearchParams({ client_id: client.client_id, redirect_uri: "https://claude.ai/api/mcp/auth_callback", response_type: "code", state: "s" }), { redirect: "manual" }); + assert.equal(noPkce.status, 302); assert.match(noPkce.headers.get("location"), /error=invalid_request/); assert.match(noPkce.headers.get("location"), /state=s/); + ok("authorize without PKCE → redirected back with error=invalid_request and state"); + + const wrongKey = await authorize({ client_id: client.client_id, redirect_uri: "https://claude.ai/api/mcp/auth_callback", api_key: "wrong-key" }); + assert.equal(wrongKey.consent.status, 200); assert.match(await wrongKey.consent.text(), /rejected that API key/); + ok("consent with a bad API key → verified against Kudosity, re-shows the form with an error, no code issued"); + + const flow = await authorize({ client_id: client.client_id, redirect_uri: "https://claude.ai/api/mcp/auth_callback", api_key: "good-key", api_secret: "good-secret" }); + assert.equal(flow.consent.status, 302); + const loc = new URL(flow.consent.headers.get("location")); + assert.equal(loc.origin + loc.pathname, "https://claude.ai/api/mcp/auth_callback"); assert.equal(loc.searchParams.get("state"), "st-1"); + const code = loc.searchParams.get("code"); assert.ok(code); + ok("consent with a good key (+secret) → key AND secret verified, 302 to redirect_uri with code + state"); + + const badVerifier = await form("/token", { grant_type: "authorization_code", code, client_id: client.client_id, redirect_uri: "https://claude.ai/api/mcp/auth_callback", code_verifier: "nope" }); + assert.equal(badVerifier.status, 400); assert.equal((await badVerifier.json()).error, "invalid_grant"); + ok("token with wrong code_verifier → invalid_grant"); + + const tok = await form("/token", { grant_type: "authorization_code", code, client_id: client.client_id, redirect_uri: "https://claude.ai/api/mcp/auth_callback", code_verifier: flow.verifier }); + assert.equal(tok.status, 200); const t = await tok.json(); + assert.equal(t.token_type, "Bearer"); assert.ok(t.access_token && t.refresh_token && t.expires_in > 0); + ok("token exchange (form-urlencoded, PKCE ok) → access + refresh tokens"); + + const authed = await call("send_sms", { recipient: "61400000000", sender: "K", message: "hi" }, { authorization: `Bearer ${t.access_token}` }); + assert.equal(authed.status, 200); const aj = await authed.json(); assert.equal(aj.result.isError, undefined); assert.equal(JSON.parse(aj.result.content[0].text).id, "sms-1"); + assert.equal(received.filter((r) => r.url === "/v2/sms").at(-1).headers["x-api-key"], "good-key"); + const bal = await call("get_balance", {}, { authorization: `Bearer ${t.access_token}` }); + assert.equal(JSON.parse((await bal.json()).result.content[0].text).balance, 7); + ok("bearer token → credentials unsealed per request; v2 (x-api-key) and v1 (Basic key:secret) both work"); + + const reused = await form("/token", { grant_type: "refresh_token", refresh_token: t.refresh_token, client_id: client.client_id }); + assert.equal(reused.status, 200); const t2 = await reused.json(); assert.ok(t2.access_token && t2.access_token !== t.access_token); + const badRefresh = await form("/token", { grant_type: "refresh_token", refresh_token: t.access_token }); + assert.equal(badRefresh.status, 400); assert.equal((await badRefresh.json()).error, "invalid_grant"); + ok("refresh_token grant → new tokens; an access token cannot be used as a refresh token (purpose-bound)"); + + // ── CIMD client (how Claude identifies itself) ── + const cimdId = `${mockBase}/client-metadata`; + const cimd = await authorize({ client_id: cimdId, redirect_uri: "http://localhost:54321/callback", api_key: "good-key" }); + assert.equal(cimd.consent.status, 302); + const cloc = new URL(cimd.consent.headers.get("location")); assert.equal(cloc.port, "54321"); + const ctok = await form("/token", { grant_type: "authorization_code", code: cloc.searchParams.get("code"), client_id: cimdId, redirect_uri: "http://localhost:54321/callback", code_verifier: cimd.verifier }); + assert.equal(ctok.status, 200); + ok("CIMD client: metadata fetched from client_id URL; loopback redirect matched with the port ignored (Claude Code)"); + + const cimdPage = await fetch(`${base}/authorize?` + new URLSearchParams({ client_id: cimdId, redirect_uri: "https://claude.ai/api/mcp/auth_callback", response_type: "code", code_challenge: "c", code_challenge_method: "S256" })); + const cimdHtml = await cimdPage.text(); + assert.match(cimdHtml, new RegExp(`127\\.0\\.0\\.1:${mockPort} is asking`)); + ok("consent page names the CIMD host (not the self-asserted client_name) as the relying party"); + + console.log(`\n✅ ALL ${pass} OAUTH CHECKS PASSED`); + process.exitCode = 0; +} catch (e) { + console.error("\n❌ OAUTH TEST FAILED:", e.message); + process.exitCode = 1; +} finally { + srv.kill(); mock.close(); +}