diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..365d820 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.3 + - run: bun install --frozen-lockfile + - run: bun run check + - run: bun run test + - run: bun run build diff --git a/README.md b/README.md index 083ec44..2b757e2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A minimal MCP (Model Context Protocol) server library for Bun with auth forwardi bunx create-futurity-plugin@latest my-plugin cd my-plugin cp .env.example .env -bun run keygen +bun run keygen # copy the private key into FUTURITY_SIGNING_KEY in .env bun run dev ``` @@ -25,7 +25,21 @@ bun add @futurity/plugins ```ts import { mcp, t } from "@futurity/plugins"; +const signingKey = process.env.FUTURITY_SIGNING_KEY; +if (!signingKey) throw new Error("Set FUTURITY_SIGNING_KEY to your Ed25519 private key."); + +const pluginManifest = { + pluginId: "my-plugin", + developerEmail: "developer@example.com", + name: "My Plugin", + version: "1.0.0", + mcpUrl: `${process.env.PUBLIC_URL ?? "http://localhost:3000"}/mcp`, + auth: { type: "none" as const }, + signingKey, +}; + const app = mcp({ + pluginManifest, name: "my-plugin", version: "1.0.0", }); @@ -51,7 +65,13 @@ await app.listen(3000); ### `mcp(options)` -Create an MCP application. +Create an MCP application. `pluginManifest` is required, including for WebSocket +servers; omitting it throws at construction. The manifest is validated and signed +when `app.fetch` is first accessed or a transport starts. The SDK supplies +`specVersion` automatically; you maintain only your plugin's release `version`. + +The snippets below reuse `pluginManifest` from Basic usage; configure its identity, +auth, and public MCP URL for your own plugin. ```typescript const app = mcp({ @@ -60,7 +80,7 @@ const app = mcp({ path: "/mcp", // default: "/mcp" instructions: "...", // optional system instructions capabilities: { ... }, // optional MCP capabilities - pluginManifest: { ... }, // optional auth forwarding manifest + pluginManifest, // required signed v4 manifest }); ``` @@ -156,7 +176,7 @@ await app.listen(3000, "websocket"); ```typescript import { mcp, cors } from "@futurity/plugins"; -const app = mcp({ name: "server", version: "1.0.0" }); +const app = mcp({ name: "server", version: "1.0.0", pluginManifest }); app.use( cors({ @@ -180,6 +200,7 @@ Configure OAuth metadata for `/.well-known/oauth-authorization-server`: const app = mcp({ name: "server", version: "1.0.0", + pluginManifest, oauth: { issuer: "https://auth.example.com", authorizationEndpoint: "https://auth.example.com/oauth/authorize", @@ -192,12 +213,12 @@ const app = mcp({ ## Authentication -There are two auth modes for Futurity plugins: +SDK v3 (`@futurity/plugins@^3.0.0`) emits custom-plugin manifests using `specVersion: 4` only. See [Manifest v4](docs/manifest-v4.md) +for migration, signed metadata, organization credentials, and governance. -| Mode | Description | Use Case | -|------|-------------|----------| -| **Auth Forwarding** (v1) | Platform manages OAuth tokens | Simple integrations | -| **Chained Auth** (v2) | Plugin manages sessions & tokens | Complex integrations, multi-service | +Custom registration supports `forwarding` (platform-managed OAuth) and `none` +(no OAuth exchange, optionally with explicit organization credentials). +Chained authentication is not supported by custom registration. ### Auth Forwarding (v1) @@ -209,7 +230,8 @@ Auth forwarding lets the Futurity platform manage OAuth tokens on behalf of your bun run keygen ``` -This prints an Ed25519 keypair. Keep the private key secret; register the public key with the Futurity API. +In a generated project, this prints an Ed25519 keypair. Set `FUTURITY_SIGNING_KEY` +to the private key before starting the server. Keep the private key secret; register the public key with the Futurity API. #### 2. Configure the plugin manifest @@ -218,13 +240,14 @@ const app = mcp({ name: "my-plugin", version: "1.0.0", pluginManifest: { - specVersion: 2, pluginId: "my-plugin", + developerEmail: "developer@example.com", name: "My Plugin", version: "1.0.0", - signingKey: process.env.FUTURITY_SIGNING_KEY!, + signingKey, // required key loaded above auth: { type: "forwarding", + grantType: "authorization_code", tokenEndpoint: "https://auth.example.com/oauth2/token", authorizationEndpoint: "https://auth.example.com/oauth2/authorize", requiredScopes: ["read", "write"], @@ -246,47 +269,15 @@ GET /.well-known/futurity/plugin The response includes an `X-Futurity-Signature` header with an Ed25519 JWS signature. -### Chained Auth (v2) - -For plugins that need to manage their own sessions and store third-party tokens, use chained auth. The plugin controls the OAuth flow and issues its own tokens. +### Standalone chained-auth runtime -```typescript -const app = mcp({ - name: "my-plugin", - version: "1.0.0", - pluginManifest: { - specVersion: 2, - pluginId: "my-plugin", - name: "My Plugin", - version: "1.0.0", - signingKey: process.env.PLUGIN_SIGNING_KEY!, - auth: { - type: "chained", - authorizationEndpoint: "https://plugin.example.com/auth/authorize", - callbackEndpoint: "https://plugin.example.com/auth/callback", - tokenEndpoint: "https://plugin.example.com/auth/token", - requiredUserContext: ["user_id", "email"], - }, - mcpUrl: "https://plugin.example.com/mcp", - }, - chainedAuth: { - sessionStore: mySessionStore, - platformJwksUrl: "https://platform.example.com/.well-known/jwks.json", - pluginSigningKey: process.env.PLUGIN_SIGNING_KEY!, - handlers: { - onAuthorize: async (userContext, platformState, platformCallback) => { ... }, - onCallback: async (req) => { ... }, - onToken: async (req) => { ... }, - }, - }, -}); -``` +The session, token, and `chainedAuth` routing utilities remain available for +separately integrated hosts. They are not a v4 manifest authentication option; +`PluginManifestSchema` rejects `auth.type: "chained"`. Do not declare `none` as a +workaround for a chained plugin. -See [docs/chained-auth.md](docs/chained-auth.md) for full documentation including: -- Complete implementation guide -- Request binding for anti-replay protection -- Session management -- Platform integration guide +See [docs/chained-auth.md](docs/chained-auth.md) for the runtime protocol and +host integration requirements. ### Signing utilities @@ -306,6 +297,7 @@ Custom auth middleware: const app = mcp({ name: "server", version: "1.0.0", + pluginManifest, auth: async (req) => { const token = req.headers.get("authorization")?.replace("Bearer ", ""); if (!token) return false; @@ -343,7 +335,7 @@ app.tool("set", { The HTTP transport supports multiple concurrent client sessions. ```typescript -const app = mcp({ name: "server", version: "1.0.0" }); +const app = mcp({ name: "server", version: "1.0.0", pluginManifest }); app.tool("example", { handler: async () => ({ ok: true }) }); @@ -378,6 +370,9 @@ The `examples/` directory in this repo contains runnable examples. Clone the rep ```bash git clone https://github.com/futuritywork/plugins.git cd plugins +# Generate a keypair; keep the private key secret. +bun -e 'import { generateKeyPair } from "./packages/plugins/src/signing"; console.log(generateKeyPair());' +export FUTURITY_SIGNING_KEY="" bun examples/calculator.ts # Math operations and unit conversion bun examples/cors.ts # CORS configuration bun examples/database.ts # Document database @@ -389,6 +384,23 @@ bun examples/weather-api.ts # Weather data API bun examples/monday/index.ts # monday.com integration with auth forwarding ``` +Each example requires `FUTURITY_SIGNING_KEY`. The local examples publish +`http://localhost:3000/mcp` by default; set `PUBLIC_URL` to the public HTTPS base +URL before registration. They use `auth.type: "none"` because their tools do not +require OAuth; `oauth.ts` demonstrates discovery metadata only. + +### Repository verification + +```bash +bun install --frozen-lockfile +bun run check +bun run test +bun run build +``` + +`check` typechecks the SDK, all examples, and the scaffold template's TypeScript +against the local SDK source, without requiring a prior build. + ## monday.com Integration Example A complete monday.com MCP server is included demonstrating auth forwarding: diff --git a/bun.lock b/bun.lock index b4f7ec4..4e9c27c 100644 --- a/bun.lock +++ b/bun.lock @@ -2,22 +2,35 @@ "lockfileVersion": 1, "configVersion": 1, "workspaces": { - "": {}, + "": { + "devDependencies": { + "@types/bun": "latest", + "typescript": "^7.0.2", + "zod": "^4.6.7", + }, + }, + "packages/create-plugin": { + "name": "create-futurity-plugin", + "version": "1.1.1", + "bin": { + "create-futurity-plugin": "./index.ts", + }, + }, "packages/plugins": { "name": "@futurity/plugins", - "version": "2.0.0", + "version": "3.0.0", "dependencies": { "jose": "^6.0.11", }, "devDependencies": { - "@modelcontextprotocol/sdk": "^1.24.3", + "@modelcontextprotocol/sdk": "^1.32.1", "@types/bun": "latest", - "typescript": "^5.9.3", - "zod": "^4.1.13", + "typescript": "^7.0.2", + "zod": "^4.6.7", }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.24.3", - "zod": "^4.1.13", + "@modelcontextprotocol/sdk": "^1.32.1", + "zod": "^4.6.7", }, }, }, @@ -26,12 +39,52 @@ "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.28.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.1", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -40,7 +93,7 @@ "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -58,6 +111,8 @@ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "create-futurity-plugin": ["create-futurity-plugin@workspace:packages/create-plugin"], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -200,7 +255,7 @@ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -212,7 +267,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], } diff --git a/docs/chained-auth.md b/docs/chained-auth.md index f45099d..cd743a0 100644 --- a/docs/chained-auth.md +++ b/docs/chained-auth.md @@ -1,6 +1,6 @@ # Chained OAuth Authentication -Chained OAuth is a v2 auth option where the plugin manages its own sessions and stores third-party tokens. Unlike auth forwarding (v1), the plugin has full control over the OAuth flow and token storage. +Chained OAuth is a standalone runtime option where the plugin manages its own sessions and stores third-party tokens. Unlike auth forwarding (v1), the plugin has full control over the OAuth flow and token storage. ## Overview @@ -32,40 +32,16 @@ Chained OAuth is a v2 auth option where the plugin manages its own sessions and │ │ │ │ ``` -## Plugin Manifest (v2) +## Support boundary -```typescript -const app = mcp({ - name: "my-plugin", - version: "1.0.0", - pluginManifest: { - specVersion: 2, - pluginId: "my-plugin", - name: "My Plugin", - version: "1.0.0", - signingKey: process.env.PLUGIN_SIGNING_KEY!, - auth: { - type: "chained", - authorizationEndpoint: "https://plugin.example.com/auth/authorize", - callbackEndpoint: "https://plugin.example.com/auth/callback", - tokenEndpoint: "https://plugin.example.com/auth/token", // optional - requiredUserContext: ["user_id", "email", "organization_id"], - externalServices: [ - { - name: "Monday.com", - authorizationEndpoint: "https://auth.monday.com/oauth2/authorize", - requiredScopes: ["me:read", "boards:read"], - }, - ], - sessionConfig: { - maxSessionDuration: 86400000, // 24 hours - supportsRefresh: true, - }, - }, - mcpUrl: "https://plugin.example.com/mcp", - }, -}); -``` +Futurity custom registration requires [Manifest v4](manifest-v4.md) and rejects +chained authentication. `PluginManifestSchema` accepts only `forwarding` and +`none`; do not pass a chained manifest to `mcp({ pluginManifest: ... })` or use +`none` to bypass this restriction. + +`ChainedAuthSchema`, its type, session/token helpers, and the `chainedAuth` runtime +router remain exported for separately integrated hosts. The protocol below requires +host implementation and is not a supported custom-registration flow. ## Implementation @@ -73,33 +49,43 @@ const app = mcp({ ```typescript import { - mcp, + createChainedAuthRouter, createPendingSession, activateSession, generatePluginToken, generateChainedState, parseChainedState, InMemorySessionStore, // Use a real store in production -} from "@futuritywork/plugins"; +} from "@futurity/plugins"; const sessionStore = new InMemorySessionStore(); -const app = mcp({ - name: "my-plugin", - version: "1.0.0", - chainedAuth: { - sessionStore, - platformJwksUrl: "https://platform.example.com/.well-known/jwks.json", - pluginSigningKey: process.env.PLUGIN_SIGNING_KEY!, - handlers: { - onAuthorize, - onCallback, - onToken, // optional - }, +const authRouter = createChainedAuthRouter({ + sessionStore, + platformJwksUrl: "https://platform.example.com/.well-known/jwks.json", + pluginSigningKey: process.env.PLUGIN_SIGNING_KEY!, + handlers: { + onAuthorize, + onCallback, + onToken, // optional }, }); + +// Mount these handlers in your separately integrated host. +async function handleAuth(req: Request): Promise { + switch (new URL(req.url).pathname) { + case "/auth/authorize": return authRouter.handleAuthorize(req); + case "/auth/callback": return authRouter.handleCallback(req); + case "/auth/token": return authRouter.handleToken(req); + default: return new Response("Not Found", { status: 404 }); + } +} ``` +`McpApp` / `mcp()` require a signed v4 manifest. For this standalone flow, +mount `handleAuth` in your own HTTP host and route MCP requests separately; +the router does not start a server or provide MCP request authentication. + ### 2. Handle Authorization ```typescript @@ -264,9 +250,13 @@ SHA-256(METHOD + "\n" + PATH + "\n" + BODY) ### Plugin Validation ```typescript -import { validateAuthenticatedRequest } from "@futuritywork/plugins"; +import { validateAuthenticatedRequest } from "@futurity/plugins"; -app.middleware(async (req, next) => { +// Call this from your host's MCP route with its MCP request handler. +async function authenticatedMcp( + req: Request, + next: (req: Request) => Promise +): Promise { try { const { session, platformId } = await validateAuthenticatedRequest( req, @@ -281,11 +271,11 @@ app.middleware(async (req, next) => { return next(req); } catch (error) { return Response.json( - { error: "unauthorized", message: error.message }, + { error: "unauthorized", message: error instanceof Error ? error.message : "Invalid request" }, { status: 401 } ); } -}); +} ``` ## Security Model @@ -349,11 +339,11 @@ interface Session { Platforms discover plugin auth configuration from: ``` -GET /.well-known/futurity/plugin +GET ``` Response includes: -- `auth.type: "chained"` - indicates chained auth +- A host-defined chained auth configuration (the SDK v4 manifest endpoint cannot serve it) - `auth.authorizationEndpoint` - where to redirect users - `auth.requiredUserContext` - what user info to include in JWT @@ -432,29 +422,8 @@ GET /.well-known/jwks.json } ``` -## Migration from Auth Forwarding (v1) - -The v1 `authForwarding` format is still supported for backward compatibility: - -```typescript -// v1 format (still works) -{ - specVersion: 1, - authForwarding: { - tokenEndpoint: "...", - authorizationEndpoint: "...", - } -} - -// v2 format (recommended) -{ - specVersion: 2, - auth: { - type: "forwarding", // or "chained" - tokenEndpoint: "...", - authorizationEndpoint: "...", - } -} -``` +## Manifest version migration -The `PluginManifestSchemaCompat` automatically transforms v1 manifests to v2 format internally. +See [Manifest v4](manifest-v4.md) for supported custom-plugin declarations. +There is no chained manifest migration into v4 custom registration; use these +runtime utilities only with a separately integrated host. diff --git a/docs/manifest-v4.md b/docs/manifest-v4.md new file mode 100644 index 0000000..0664102 --- /dev/null +++ b/docs/manifest-v4.md @@ -0,0 +1,115 @@ +# Manifest v4 + +The SDK validates and signs `specVersion: 4` manifests for Futurity custom-plugin +registration. Marketplace v3 manifests are a separate consumer contract; this SDK +no longer produces them. `PluginManifestSchema` validates complete wire manifests +and rejects versions 1, 2, and 3. Author options omit `specVersion`; the SDK always +sets its supported version before validation and signing. + +## Migration + +- Remove `specVersion` from your `pluginManifest` options; the SDK supplies it. +- Set `developerEmail` to a valid developer or support email address. This required + top-level field is included in the signed manifest. +- Replace legacy `authForwarding: { ... }` with `auth: { type: "forwarding", ... }`. +- Use `PluginManifestSchema` instead of the removed `PluginManifestSchemaV1` or + `PluginManifestSchemaCompat` exports. +- Custom registration supports `forwarding` and `none`, not `chained`. + [Chained runtime utilities](chained-auth.md) remain available for separate host + integrations; there is no automatic chained-to-custom migration. +- Keep signing keys. The endpoint remains `/.well-known/futurity/plugin` with + `X-Futurity-Signature` (detached Ed25519 JWS). +- MCP, icon, and absolute authentication URLs must be HTTP(S). Icons are URLs, + not emoji or data URLs. + +This is a breaking library API change. Package versions and publishing are managed +separately; the manifest's own `version` remains the plugin's three-part version. + +## Public plugins and governance + +```typescript +import type { PluginManifestOptions } from "@futurity/plugins"; + +const manifest: PluginManifestOptions = { + pluginId: "public-catalog", + developerEmail: "developer@example.com", + name: "Public Catalog", + version: "1.0.0", + mcpUrl: "https://catalog.example.com/mcp", + signingKey: process.env.FUTURITY_SIGNING_KEY!, + auth: { type: "none" }, + governance: { + credential: "organization", + purpose: "Public product catalog", + defaultAccess: "open", + datasets: { + products: { + title: "Products", + fields: [{ name: "id", type: "string" }], + }, + }, + }, +}; +``` + +Governance is optional. When present, `credential` (`organization` or `user`) and +`datasets` are required. Each dataset needs a title and at least one named, typed +field; dataset IDs cannot be empty or contain dots or whitespace. Field +`references: { dataset, field }` must resolve within the manifest, and every +`restrictedByDefault` entry must name a declared dataset. + +Optional governance metadata includes `timezone`, `purpose`, `defaultAccess`, +`restrictedByDefault`, dataset `description`, `group`, `paths`, and +`probe: { tool, args }`. Fields can declare `identifier`, `references`, `nullable`, +`clock` (`wall` or `instant`), and `unit`. The top-level `icon` is an optional HTTP(S) URL. +These declarations are preserved in the signed payload; enforcement belongs to +the consuming platform, not this library. + +## Signed registration metadata + +`description`, `category`, and `metadata` are optional and nullable. Metadata supports: + +- `tagline`, `overview`: strings. +- `oauth`: optional `token_body` (`form` or `json`), `setup_steps` (strings), and + `console_url` (HTTP(S)). +- `features`: objects with `title`, `description`, `category` (`read`, `write`, + `read_write`), and optional string `icon`. +- `permissions`: strings or objects with `scope`, `label`, `description`, `access` + (`read`, `write`, `read_write`, `admin`), and required nullable HTTP(S) `docsUrl`. +- `setupType`: `user`, `admin`, or `none`. +- `links`: optional HTTP(S) `docs` and `privacy`. + +These fields are included in the signed response, not supplied separately during +registration. Unknown object properties are stripped by schema parsing. + +## Authentication and credential fields + +Forwarding supports `authorization_code` (default), `client_credentials`, and +`urn:ietf:params:oauth:grant-type:saml2-bearer`. Authorization-code grants require +an HTTP(S) `authorizationEndpoint` and absolute HTTP(S) `tokenEndpoint`. +Organization grants may use a root-relative token path such as `/oauth/token`, +resolved by the platform against the configured `api_base_url`. Protocol-relative +paths, backslashes, whitespace, and fragments are rejected. Forwarding also accepts +optional nullable `extra_params`, a string-to-string map of authorization parameters. + +`auth: { type: "none" }` means no OAuth exchange. Its optional `grantType` is +`none`, `api_key`, or `credentials`. Only explicit organization grants collect +credentials; per-user credentials without OAuth are unsupported. + +Both auth modes accept optional nullable `credentials_schema`, an array of fields: +`key`, `label`, `type` (`input`, `password`, `textarea`, `x509-keypair`), and optional +`required`, `placeholder`, `help`, `secondaryKey`. Use `input` for text fields; aliases are not accepted. +Absent `required` means required to the platform; `false` is preserved. + +- Keys must be nonempty and unique across primary and secondary keys. +- Only `x509-keypair` accepts `secondaryKey`, and it requires a nonempty one. +- `pattern` is rejected, not silently removed. +- A `none` grant cannot collect fields; `credentials` requires a nonempty array. +- Explicit field arrays for `api_key` must collect `api_key`; `client_credentials` + must collect `api_base_url`, `client_id`, `client_secret`; SAML must collect + `api_base_url`, `client_id`, `saml_subject_name_id`, `private_key_pem`, and + `public_cert_pem` (which may be the keypair's secondary key). + +Null or omitted field schemas remain null or omitted in the signed payload. The +platform supplies grant defaults; the SDK does not insert credential fields or +change credential delivery, token exchange, or request authentication runtime. diff --git a/docs/migration-v1-to-v2-auth.md b/docs/migration-v1-to-v2-auth.md index 1de9b4e..b681278 100644 --- a/docs/migration-v1-to-v2-auth.md +++ b/docs/migration-v1-to-v2-auth.md @@ -1,6 +1,11 @@ # Migration Guide: Auth Forwarding (v1) to Chained Auth (v2) -This guide covers migrating from v1 auth forwarding to v2 chained OAuth authentication. +This guide describes migrating standalone runtime integration from forwarding to +chained OAuth with a separately integrated host. It is **not a custom-plugin +registration migration**: [Manifest v4](manifest-v4.md) excludes chained auth. +The runtime helpers remain supported, but the host must implement the protocol +in [chained-auth.md](chained-auth.md). The v1/v2 labels here describe auth modes, +not supported manifest versions. ## When to Migrate @@ -24,78 +29,16 @@ This guide covers migrating from v1 auth forwarding to v2 chained OAuth authenti | Session storage | None | Required | | OAuth flow | Platform handles | Plugin handles | | Request auth | Bearer token only | Token + platform assertion | -| Manifest auth type | `"forwarding"` | `"chained"` | +| v4 custom manifest | `auth.type: "forwarding"` | Unsupported; host-owned discovery | ## Step-by-Step Migration -### 1. Update Plugin Manifest +### 1. Confirm host support -**Before (v1):** -```typescript -const app = mcp({ - name: "my-plugin", - version: "1.0.0", - pluginManifest: { - specVersion: 2, - pluginId: "my-plugin", - name: "My Plugin", - version: "1.0.0", - signingKey: process.env.PLUGIN_SIGNING_KEY!, - auth: { - type: "forwarding", - tokenEndpoint: "https://external.com/oauth/token", - authorizationEndpoint: "https://external.com/oauth/authorize", - requiredScopes: ["read", "write"], - deliveryMethod: "header", - }, - mcpUrl: "https://plugin.example.com/mcp", - }, - auth: async (req) => { - const token = req.headers.get("authorization")?.slice(7); - return !!token; - }, -}); -``` - -**After (v2):** -```typescript -const app = mcp({ - name: "my-plugin", - version: "1.0.0", - pluginManifest: { - specVersion: 2, - pluginId: "my-plugin", - name: "My Plugin", - version: "1.0.0", - signingKey: process.env.PLUGIN_SIGNING_KEY!, - auth: { - type: "chained", - authorizationEndpoint: "https://plugin.example.com/auth/authorize", - callbackEndpoint: "https://plugin.example.com/auth/callback", - tokenEndpoint: "https://plugin.example.com/auth/token", // optional - requiredUserContext: ["user_id", "email"], - externalServices: [ - { - name: "External Service", - authorizationEndpoint: "https://external.com/oauth/authorize", - requiredScopes: ["read", "write"], - }, - ], - }, - mcpUrl: "https://plugin.example.com/mcp", - }, - chainedAuth: { - sessionStore, - platformJwksUrl: "https://platform.example.com/.well-known/jwks.json", - pluginSigningKey: process.env.PLUGIN_SIGNING_KEY!, - handlers: { - onAuthorize, - onCallback, - onToken, // optional - }, - }, -}); -``` +Do not configure `pluginManifest` with chained auth: the v4 schema rejects it. +Agree on discovery and assertion support with the host before migrating. Configure +only the standalone `chainedAuth` router as shown in the +[chained runtime guide](chained-auth.md#implementation). ### 2. Add Session Storage @@ -341,7 +284,7 @@ PLUGIN_SIGNING_KEY=... # For manifest signing only **After (v2):** ```bash -PLUGIN_SIGNING_KEY=... # For manifest + token signing +PLUGIN_SIGNING_KEY=... # For standalone plugin token signing PLUGIN_PUBLIC_KEY=... # For token validation (derive from signing key) EXTERNAL_CLIENT_ID=... # OAuth client ID (you manage this now) EXTERNAL_CLIENT_SECRET=... # OAuth client secret @@ -422,8 +365,8 @@ The `chainedAuth` config handles this automatically. ## Checklist -- [ ] Update manifest `auth.type` from `"forwarding"` to `"chained"` -- [ ] Add `authorizationEndpoint` and `callbackEndpoint` to manifest +- [ ] Confirm a separately integrated host supports chained discovery and assertions +- [ ] Configure the standalone `chainedAuth` router without a chained manifest - [ ] Implement session store (or use `InMemorySessionStore` for dev) - [ ] Implement `onAuthorize` handler - [ ] Implement `onCallback` handler @@ -435,13 +378,14 @@ The `chainedAuth` config handles this automatically. ## Rollback -If you need to rollback, simply revert manifest changes to `auth.type: "forwarding"` and restore the `auth` middleware. Session data will be orphaned but the platform will resume token management. +Coordinate rollback with the host before restoring forwarding and its `auth` middleware. +Session data and provider token management require a host-specific migration. ## Platform Coordination The platform team needs to: -1. **Detect chained auth** from your manifest (`auth.type: "chained"`) +1. **Discover chained auth** through a host-defined mechanism (not a v4 custom manifest) 2. **Create user context JWTs** with required claims when redirecting users 3. **Include platform assertions** on all MCP requests 4. **Serve JWKS endpoint** at `/.well-known/jwks.json` diff --git a/examples/calculator.ts b/examples/calculator.ts index bcc0e36..9c72424 100644 --- a/examples/calculator.ts +++ b/examples/calculator.ts @@ -9,11 +9,21 @@ import { z } from "zod"; import { mcp } from "@futurity/plugins"; +import { signingKey, mcpUrl } from "./config"; import { cors } from "@futurity/plugins/cors"; const app = mcp({ name: "calculator", version: "1.0.0", + pluginManifest: { + pluginId: "calculator", + developerEmail: "developer@example.com", + name: "calculator", + version: "1.0.0", + mcpUrl, + auth: { type: "none" }, + signingKey, + }, instructions: `A calculator server with math operations and unit conversions. Tools: diff --git a/examples/config.ts b/examples/config.ts new file mode 100644 index 0000000..1070894 --- /dev/null +++ b/examples/config.ts @@ -0,0 +1,10 @@ +// Shared signing and public URL configuration for the local examples. +const key = process.env.FUTURITY_SIGNING_KEY; +if (!key) { + throw new Error( + "FUTURITY_SIGNING_KEY is required. Generate an Ed25519 keypair (see README.md) and set the private key before running this example." + ); +} + +export const signingKey = key; +export const mcpUrl = `${process.env.PUBLIC_URL ?? "http://localhost:3000"}/mcp`; diff --git a/examples/cors.ts b/examples/cors.ts index b0767cc..ad24ef6 100644 --- a/examples/cors.ts +++ b/examples/cors.ts @@ -1,10 +1,20 @@ import { z } from "zod"; import { mcp } from "@futurity/plugins"; +import { signingKey, mcpUrl } from "./config"; import { cors } from "@futurity/plugins/cors"; const app = mcp({ name: "my-cors-server", version: "1.0.0", + pluginManifest: { + pluginId: "my-cors-server", + developerEmail: "developer@example.com", + name: "my-cors-server", + version: "1.0.0", + mcpUrl, + auth: { type: "none" }, + signingKey, + }, path: "/mcp", }); diff --git a/examples/database.ts b/examples/database.ts index 09c9529..d612cbb 100644 --- a/examples/database.ts +++ b/examples/database.ts @@ -9,6 +9,7 @@ import { z } from "zod"; import { mcp } from "@futurity/plugins"; +import { signingKey, mcpUrl } from "./config"; import { cors } from "@futurity/plugins/cors"; // Document type @@ -39,6 +40,15 @@ function getCollection(name: string): Map { const app = mcp({ name: "memory-database", version: "1.0.0", + pluginManifest: { + pluginId: "memory-database", + developerEmail: "developer@example.com", + name: "memory-database", + version: "1.0.0", + mcpUrl, + auth: { type: "none" }, + signingKey, + }, instructions: `An in-memory document database. Collections: diff --git a/examples/filesystem.ts b/examples/filesystem.ts index a426a2e..45c5315 100644 --- a/examples/filesystem.ts +++ b/examples/filesystem.ts @@ -9,6 +9,7 @@ import { z } from "zod"; import { mcp } from "@futurity/plugins"; +import { signingKey, mcpUrl } from "./config"; import { cors } from "@futurity/plugins/cors"; interface FsNode { @@ -87,6 +88,15 @@ function getParentAndName(path: string): { parent: FsNode | null; name: string } const app = mcp({ name: "virtual-filesystem", version: "1.0.0", + pluginManifest: { + pluginId: "virtual-filesystem", + developerEmail: "developer@example.com", + name: "virtual-filesystem", + version: "1.0.0", + mcpUrl, + auth: { type: "none" }, + signingKey, + }, instructions: `A virtual filesystem server. Tools: diff --git a/examples/monday/index.ts b/examples/monday/index.ts index 9c05a93..82c8fd3 100644 --- a/examples/monday/index.ts +++ b/examples/monday/index.ts @@ -72,8 +72,8 @@ const port = parseInt(process.env.PORT || "3000", 10); const baseUrl = process.env.BASE_URL || `http://localhost:${port}`; const pluginManifest: PluginManifestOptions = { - specVersion: 2, pluginId: "monday", + developerEmail: "developer@example.com", name: "monday.com MCP Server", version: "1.0.0", signingKey, diff --git a/examples/oauth.ts b/examples/oauth.ts index bb90a07..3f602f7 100644 --- a/examples/oauth.ts +++ b/examples/oauth.ts @@ -1,9 +1,19 @@ import { z } from "zod"; import { mcp } from "@futurity/plugins"; +import { signingKey, mcpUrl } from "./config"; const app = mcp({ name: "my-oauth-server", version: "1.0.0", + pluginManifest: { + pluginId: "my-oauth-server", + developerEmail: "developer@example.com", + name: "my-oauth-server", + version: "1.0.0", + mcpUrl, + auth: { type: "none" }, + signingKey, + }, oauth: { issuer: "https://auth.example.com", authorizationEndpoint: "https://auth.example.com/oauth/authorize", diff --git a/examples/stateful.ts b/examples/stateful.ts index 3f994bd..ac6123f 100644 --- a/examples/stateful.ts +++ b/examples/stateful.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { mcp } from "@futurity/plugins"; +import { signingKey, mcpUrl } from "./config"; import { cors } from "@futurity/plugins/cors"; // Global state store @@ -19,6 +20,15 @@ const state = { const app = mcp({ name: "stateful-server", version: "1.0.0", + pluginManifest: { + pluginId: "stateful-server", + developerEmail: "developer@example.com", + name: "stateful-server", + version: "1.0.0", + mcpUrl, + auth: { type: "none" }, + signingKey, + }, instructions: `This server demonstrates stateful operations. Available tools: diff --git a/examples/todo-app.ts b/examples/todo-app.ts index 56f6f9e..7f209f2 100644 --- a/examples/todo-app.ts +++ b/examples/todo-app.ts @@ -10,6 +10,7 @@ import { z } from "zod"; import { mcp } from "@futurity/plugins"; +import { signingKey, mcpUrl } from "./config"; import { cors } from "@futurity/plugins/cors"; // Todo item type @@ -35,6 +36,15 @@ function generateId(): string { const app = mcp({ name: "todo-app", version: "1.0.0", + pluginManifest: { + pluginId: "todo-app", + developerEmail: "developer@example.com", + name: "todo-app", + version: "1.0.0", + mcpUrl, + auth: { type: "none" }, + signingKey, + }, instructions: `A todo list management server. Tools: diff --git a/examples/weather-api.ts b/examples/weather-api.ts index 1c7f31b..d432e4f 100644 --- a/examples/weather-api.ts +++ b/examples/weather-api.ts @@ -9,6 +9,7 @@ import { z } from "zod"; import { mcp } from "@futurity/plugins"; +import { signingKey, mcpUrl } from "./config"; import { cors } from "@futurity/plugins/cors"; // Simulated weather data @@ -71,6 +72,15 @@ function getWeather(cityId: string, date: Date) { const app = mcp({ name: "weather-api", version: "1.0.0", + pluginManifest: { + pluginId: "weather-api", + developerEmail: "developer@example.com", + name: "weather-api", + version: "1.0.0", + mcpUrl, + auth: { type: "none" }, + signingKey, + }, instructions: `A weather information server. Tools: diff --git a/package.json b/package.json index 76027ac..ea01d82 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,17 @@ { "private": true, - "workspaces": ["packages/*"], + "workspaces": [ + "packages/*" + ], "scripts": { "build": "bun run --filter '*' build", "test": "bun run --filter '*' test", - "check": "bun run --filter '*' check" + "check": "bun run --filter '*' check && bun run check:examples", + "check:examples": "tsc --noEmit -p tsconfig.examples.json" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^7.0.2", + "zod": "^4.6.7" } } diff --git a/packages/create-plugin/template/README.md b/packages/create-plugin/template/README.md index 261112b..6765ad3 100644 --- a/packages/create-plugin/template/README.md +++ b/packages/create-plugin/template/README.md @@ -11,7 +11,8 @@ bun run keygen # writes a keypair; put the private half in .env bun run dev # http://localhost:3000/mcp ``` -With `FUTURITY_SIGNING_KEY` set, the manifest is served at +`FUTURITY_SIGNING_KEY` is required; startup fails with setup instructions if it is +missing. The manifest is served at `/.well-known/futurity/plugin` with its detached signature in the `X-Futurity-Signature` header: @@ -42,6 +43,9 @@ you return there arrives on every tool call as `X-Plugin-Access-Token`, and Other grants — per-person OAuth, SAML bearer, an API key, or your own auth flow — are in the plugin authentication reference. +The SDK sets the manifest `specVersion` automatically. Update the separate plugin +`version` manually when you release your plugin. + ## Where to edit | File | What lives there | diff --git a/packages/create-plugin/template/env.example b/packages/create-plugin/template/env.example index 1b54ec5..222e6db 100644 --- a/packages/create-plugin/template/env.example +++ b/packages/create-plugin/template/env.example @@ -1,5 +1,5 @@ # Ed25519 private key that signs the plugin manifest. Generate with `bun run keygen`. -# Without it the server still runs, but it serves no manifest and cannot be registered. +# Required: the server refuses to start without this key. FUTURITY_SIGNING_KEY= # The address Futurity reaches this plugin on. Must be public HTTPS before you diff --git a/packages/create-plugin/template/package.json.tmpl b/packages/create-plugin/template/package.json.tmpl index 32d3499..fdc7c27 100644 --- a/packages/create-plugin/template/package.json.tmpl +++ b/packages/create-plugin/template/package.json.tmpl @@ -8,12 +8,12 @@ "keygen": "bun run scripts/keygen.ts" }, "dependencies": { - "@futurity/plugins": "^2.0.0" + "@futurity/plugins": "^3.0.0" }, "devDependencies": { - "@modelcontextprotocol/sdk": "^1.24.3", + "@modelcontextprotocol/sdk": "^1.32.1", "@types/bun": "latest", - "typescript": "^5.9.3", - "zod": "^4.1.13" + "typescript": "^7.0.2", + "zod": "^4.6.7" } } diff --git a/packages/create-plugin/template/src/index.ts b/packages/create-plugin/template/src/index.ts index 708f0a0..7eec43e 100644 --- a/packages/create-plugin/template/src/index.ts +++ b/packages/create-plugin/template/src/index.ts @@ -11,29 +11,27 @@ const TOKEN_PATH = "/oauth/token"; const signingKey = process.env.FUTURITY_SIGNING_KEY; if (!signingKey) { - console.warn( - "FUTURITY_SIGNING_KEY is not set, so /.well-known/futurity/plugin is not served.\n" + + throw new Error( + "FUTURITY_SIGNING_KEY is required to sign the plugin manifest.\n" + "Run `bun run keygen`, put the private key in .env, and restart to register this plugin.", ); } -const manifest: PluginManifestOptions | undefined = signingKey - ? { - specVersion: 2, - pluginId: "{{slug}}", - name: "{{name}}", - version: "0.1.0", - mcpUrl: `${PUBLIC_URL}/mcp`, - auth: { - type: "forwarding", - grantType: "client_credentials", - tokenEndpoint: `${PUBLIC_URL}${TOKEN_PATH}`, - requiredScopes: [], - deliveryMethod: "header", - }, - signingKey, - } - : undefined; +const manifest: PluginManifestOptions = { + pluginId: "{{slug}}", + developerEmail: "developer@example.com", // Replace with your developer or support email. + name: "{{name}}", + version: "0.1.0", + mcpUrl: `${PUBLIC_URL}/mcp`, + auth: { + type: "forwarding", + grantType: "client_credentials", + tokenEndpoint: `${PUBLIC_URL}${TOKEN_PATH}`, + requiredScopes: [], + deliveryMethod: "header", + }, + signingKey, +}; const app = mcp({ name: "{{slug}}", diff --git a/packages/plugins/package.json b/packages/plugins/package.json index bf9876b..b444205 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@futurity/plugins", - "version": "2.0.0", + "version": "3.0.0", "type": "module", "description": "A minimal MCP (Model Context Protocol) server library for Bun with auth forwarding support", "author": "Futurity", @@ -41,16 +41,16 @@ "prepublishOnly": "bun run build" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.24.3", - "zod": "^4.1.13" + "@modelcontextprotocol/sdk": "^1.32.1", + "zod": "^4.6.7" }, "dependencies": { "jose": "^6.0.11" }, "devDependencies": { - "@modelcontextprotocol/sdk": "^1.24.3", + "@modelcontextprotocol/sdk": "^1.32.1", "@types/bun": "latest", - "typescript": "^5.9.3", - "zod": "^4.1.13" + "typescript": "^7.0.2", + "zod": "^4.6.7" } } diff --git a/packages/plugins/src/app.test.ts b/packages/plugins/src/app.test.ts index 2515544..cd7d922 100644 --- a/packages/plugins/src/app.test.ts +++ b/packages/plugins/src/app.test.ts @@ -1,5 +1,18 @@ import { test, expect, describe, afterEach } from "bun:test"; import { mcp } from "./app"; +import { generateKeyPair, verifyPayload } from "./signing"; +import type { PluginManifestOptions } from "./pluginManifest"; + +const signingKeys = generateKeyPair(); +const pluginManifest: PluginManifestOptions = { + pluginId: "test", + developerEmail: "developer@example.com", + name: "Test", + version: "1.0.0", + mcpUrl: "http://localhost/mcp", + auth: { type: "none" }, + signingKey: signingKeys.privateKey, +}; describe("McpApp.fetch", () => { let app: ReturnType; @@ -9,12 +22,12 @@ describe("McpApp.fetch", () => { }); test("fetch is a function on the app instance", () => { - app = mcp({ name: "test", version: "1.0.0" }); + app = mcp({ name: "test", version: "1.0.0", pluginManifest }); expect(typeof app.fetch).toBe("function"); }); test("fetch handles well-known health check", async () => { - app = mcp({ name: "test", version: "1.0.0" }); + app = mcp({ name: "test", version: "1.0.0", pluginManifest }); const req = new Request("http://localhost/.well-known/health-check"); const res = await app.fetch(req); @@ -23,8 +36,17 @@ describe("McpApp.fetch", () => { expect(await res.text()).toBe("OK 200"); }); + test("discovery includes developerEmail in the signed manifest", async () => { + app = mcp({ name: "test", version: "1.0.0", pluginManifest }); + const res = await app.fetch(new Request("http://localhost/.well-known/futurity/plugin")); + expect(res.status).toBe(200); + const payload = await res.text(); + expect(JSON.parse(payload).developerEmail).toBe("developer@example.com"); + expect(verifyPayload(payload, res.headers.get("X-Futurity-Signature")!, signingKeys.publicKey)).toBe(true); + }); + test("fetch handles MCP initialization", async () => { - app = mcp({ name: "test", version: "1.0.0" }); + app = mcp({ name: "test", version: "1.0.0", pluginManifest }); app.tool("hello", { description: "Say hello", handler: async () => ({ message: "hi" }), @@ -54,7 +76,7 @@ describe("McpApp.fetch", () => { }); test("fetch returns 404 for unknown paths", async () => { - app = mcp({ name: "test", version: "1.0.0" }); + app = mcp({ name: "test", version: "1.0.0", pluginManifest }); const req = new Request("http://localhost/unknown"); const res = await app.fetch(req); @@ -63,7 +85,7 @@ describe("McpApp.fetch", () => { }); test("listen() still works as before", async () => { - app = mcp({ name: "test", version: "1.0.0" }); + app = mcp({ name: "test", version: "1.0.0", pluginManifest }); app.tool("ping", { description: "Ping", handler: async () => ({ pong: true }), diff --git a/packages/plugins/src/app.ts b/packages/plugins/src/app.ts index 923e49c..983c3f9 100644 --- a/packages/plugins/src/app.ts +++ b/packages/plugins/src/app.ts @@ -8,8 +8,8 @@ import { z } from "zod"; import { createChainedAuthRouter } from "./chainedAuth"; import { OAuthOptionsSchema, type OAuthOptions } from "./oauth"; import { + PLUGIN_MANIFEST_SPEC_VERSION, PluginManifestSchema, - PluginManifestSchemaCompat, type PluginManifestOptions, } from "./pluginManifest"; import { signPayload } from "./signing"; @@ -61,7 +61,8 @@ export interface McpAppOptions { capabilities?: ServerOptions["capabilities"]; instructions?: ServerOptions["instructions"]; oauth?: OAuthOptions; - pluginManifest?: PluginManifestOptions; + /** Required signed v4 manifest, validated when the transport is initialized. */ + pluginManifest: PluginManifestOptions; auth?: AuthMiddleware; chainedAuth?: ChainedAuthConfig; path?: string; @@ -88,12 +89,11 @@ export class McpApp { private _wsTransport?: WebSocketTransport; private _bunServer?: ReturnType; - constructor( - private options: McpAppOptions = { - name: "mcp-app", - version: "0.1.0", + /** Throws if the required plugin manifest is missing. */ + constructor(private options: McpAppOptions) { + if (!options?.pluginManifest) { + throw new Error("pluginManifest is required: provide a v4 manifest with a signingKey."); } - ) { if (options.middleware) { this.middlewares.push(...options.middleware); } @@ -205,8 +205,8 @@ export class McpApp { return this._httpHandler; } - private buildWellKnown(): Record | undefined { - let wellKnown: Record | undefined; + private buildWellKnown(): Record { + const wellKnown: Record = {}; if (this.options.oauth) { const oauth = OAuthOptionsSchema.parse(this.options.oauth); @@ -231,22 +231,21 @@ export class McpApp { if (oauth.userInfoEndpoint) authServer.userinfo_endpoint = oauth.userInfoEndpoint; - wellKnown = { "oauth-authorization-server": { body: authServer } }; + wellKnown["oauth-authorization-server"] = { body: authServer }; } - if (this.options.pluginManifest) { - const { signingKey, ...manifestData } = this.options.pluginManifest; - // Use compat schema to support both v1 and v2 manifest formats - const manifest = PluginManifestSchemaCompat.parse(manifestData); - const payload = JSON.stringify(manifest); - const jws = signPayload(payload, signingKey); - - wellKnown ??= {}; - wellKnown["futurity/plugin"] = { - body: manifest, - headers: { "X-Futurity-Signature": jws }, - }; - } + const { signingKey, ...manifestData } = this.options.pluginManifest; + const manifest = PluginManifestSchema.parse({ + ...manifestData, + specVersion: PLUGIN_MANIFEST_SPEC_VERSION, + }); + const payload = JSON.stringify(manifest); + const jws = signPayload(payload, signingKey); + + wellKnown["futurity/plugin"] = { + body: manifest, + headers: { "X-Futurity-Signature": jws }, + }; return wellKnown; } @@ -348,4 +347,4 @@ export class McpApp { } } -export const mcp = (options?: McpAppOptions) => new McpApp(options); +export const mcp = (options: McpAppOptions) => new McpApp(options); diff --git a/packages/plugins/src/chainedAuth.test.ts b/packages/plugins/src/chainedAuth.test.ts index 3b3b53b..42ab2a7 100644 --- a/packages/plugins/src/chainedAuth.test.ts +++ b/packages/plugins/src/chainedAuth.test.ts @@ -23,12 +23,26 @@ import { validateAuthenticatedRequest, } from "./chainedAuth"; import { mcp } from "./app"; +import { generateKeyPair } from "./signing"; +import type { PluginManifestOptions } from "./pluginManifest"; + import type { ChainedAuthHandlers, Session, UserContext, } from "./types"; +// Synthetic discovery data for transport tests, not a chained registration declaration. +const pluginManifest: PluginManifestOptions = { + pluginId: "test", + developerEmail: "developer@example.com", + name: "Test", + version: "1.0.0", + mcpUrl: "http://localhost/mcp", + auth: { type: "none" }, + signingKey: generateKeyPair().privateKey, +}; + // ============================================================================ // Test Key Generation Helpers // ============================================================================ @@ -899,6 +913,7 @@ describe("HTTP Transport Auth Routes", () => { let authorizeCalled = false; app = mcp({ + pluginManifest, name: "test-server", version: "1.0.0", chainedAuth: { @@ -937,6 +952,7 @@ describe("HTTP Transport Auth Routes", () => { let callbackCalled = false; app = mcp({ + pluginManifest, name: "test-server", version: "1.0.0", chainedAuth: { @@ -973,6 +989,7 @@ describe("HTTP Transport Auth Routes", () => { let tokenCalled = false; app = mcp({ + pluginManifest, name: "test-server", version: "1.0.0", chainedAuth: { @@ -1015,6 +1032,7 @@ describe("HTTP Transport Auth Routes", () => { const testPort = 9879; // Use different port to avoid conflicts app = mcp({ + pluginManifest, name: "test-server", version: "1.0.0", chainedAuth: { @@ -1042,6 +1060,7 @@ describe("HTTP Transport Auth Routes", () => { const testPort = 9880; // Use different port to avoid conflicts app = mcp({ + pluginManifest, name: "test-server", version: "1.0.0", chainedAuth: { diff --git a/packages/plugins/src/pluginManifest.test.ts b/packages/plugins/src/pluginManifest.test.ts index deec0cf..b0ab529 100644 --- a/packages/plugins/src/pluginManifest.test.ts +++ b/packages/plugins/src/pluginManifest.test.ts @@ -1,431 +1,465 @@ import { test, expect, describe } from "bun:test"; import { PluginManifestSchema, - PluginManifestSchemaCompat, - PluginManifestSchemaV1, AuthForwardingSchema, ChainedAuthSchema, + GovernanceSchema, + type ChainedAuth, + type Governance, + type PluginManifest, } from "./pluginManifest"; -// v2 manifest with forwarding auth (new format) -const validManifestV2Forwarding = { - specVersion: 2, +const validManifest = { + specVersion: 4, pluginId: "monday", + developerEmail: "developer@example.com", name: "monday.com MCP Server", version: "1.0.0", auth: { type: "forwarding" as const, tokenEndpoint: "https://auth.monday.com/oauth2/token", authorizationEndpoint: "https://auth.monday.com/oauth2/authorize", - requiredScopes: [ - "me:read", - "boards:read", - "boards:write", - "workspaces:read", - "updates:read", - "updates:write", - ], + requiredScopes: ["me:read", "boards:read", "boards:write"], deliveryMethod: "header" as const, }, mcpUrl: "https://mcp.example.com/mcp", }; -// v2 manifest with chained auth -const validManifestV2Chained = { - specVersion: 2, - pluginId: "monday-chained", - name: "monday.com MCP Server (Chained)", - version: "1.0.0", - auth: { - type: "chained" as const, - authorizationEndpoint: "https://plugin.example.com/auth/authorize", - callbackEndpoint: "https://plugin.example.com/auth/callback", - tokenEndpoint: "https://plugin.example.com/auth/token", - requiredUserContext: ["user_id", "organization_id"] as const, - externalServices: [ - { - name: "Monday.com", - authorizationEndpoint: "https://auth.monday.com/oauth2/authorize", - requiredScopes: ["me:read", "boards:read"], - }, - ], - sessionConfig: { - maxSessionDuration: 86400000, - supportsRefresh: true, - }, - }, - mcpUrl: "https://mcp.example.com/mcp", -}; - -// v1 manifest (legacy format with authForwarding) -const validManifestV1 = { - specVersion: 1, - pluginId: "monday", - name: "monday.com MCP Server", - version: "1.0.0", - authForwarding: { - tokenEndpoint: "https://auth.monday.com/oauth2/token", +const chainedAuth = { + type: "chained" as const, + authorizationEndpoint: "https://plugin.example.com/auth/authorize", + callbackEndpoint: "https://plugin.example.com/auth/callback", + tokenEndpoint: "https://plugin.example.com/auth/token", + requiredUserContext: ["user_id", "organization_id"], + externalServices: [{ + name: "Monday.com", authorizationEndpoint: "https://auth.monday.com/oauth2/authorize", - requiredScopes: [ - "me:read", - "boards:read", - "boards:write", - "workspaces:read", - "updates:read", - "updates:write", - ], - deliveryMethod: "header" as const, + requiredScopes: ["me:read", "boards:read"], + }], + sessionConfig: { maxSessionDuration: 86400000, supportsRefresh: true }, +} satisfies ChainedAuth; + +const governance = { + timezone: "America/New_York", + purpose: "Manage projects and their owners", + credential: "organization", + defaultAccess: "restricted", + restrictedByDefault: ["projects"], + datasets: { + people: { + title: "People", + fields: [{ name: "id", type: "string", identifier: "person", nullable: false }], + }, + projects: { + title: "Projects", + description: "Active projects", + group: "Work", + paths: ["/projects"], + fields: [ + { name: "owner", type: "string", references: { dataset: "people", field: "id" }, nullable: true }, + { name: "due", type: "datetime", clock: "wall" }, + { name: "created", type: "datetime", clock: "instant" }, + { name: "duration", type: "number", unit: "seconds" }, + ], + probe: { tool: "list_projects", args: { limit: 1, filter: { active: true } } }, + }, }, - mcpUrl: "https://mcp.example.com/mcp", -}; +} satisfies Governance; -describe("PluginManifestSchema v2", () => { - test("accepts a valid v2 manifest with forwarding auth", () => { - const result = PluginManifestSchema.parse(validManifestV2Forwarding); +describe("PluginManifestSchema v4", () => { + test("accepts a v4 manifest", () => { + const result = PluginManifestSchema.parse(validManifest); + expect(result.specVersion).toBe(4); expect(result.pluginId).toBe("monday"); - expect(result.specVersion).toBe(2); - expect(result.auth.type).toBe("forwarding"); - if (result.auth.type === "forwarding") { - expect(result.auth.tokenEndpoint).toBe( - "https://auth.monday.com/oauth2/token" - ); - } + expect(result.developerEmail).toBe("developer@example.com"); + expect(result.auth).toEqual({ ...validManifest.auth, grantType: "authorization_code" }); + expect(result.governance).toBeUndefined(); + expect(result.icon).toBeUndefined(); }); - test("accepts a valid v2 manifest with chained auth", () => { - const result = PluginManifestSchema.parse(validManifestV2Chained); - expect(result.pluginId).toBe("monday-chained"); - expect(result.specVersion).toBe(2); - expect(result.auth.type).toBe("chained"); - if (result.auth.type === "chained") { - expect(result.auth.authorizationEndpoint).toBe( - "https://plugin.example.com/auth/authorize" - ); - expect(result.auth.callbackEndpoint).toBe( - "https://plugin.example.com/auth/callback" - ); - expect(result.auth.externalServices).toHaveLength(1); - expect(result.auth.sessionConfig?.supportsRefresh).toBe(true); + test.each([undefined, null, "", "not-an-email", "https://example.com", 42])("rejects invalid developerEmail %j", (developerEmail) => { + const result = PluginManifestSchema.safeParse({ ...validManifest, developerEmail }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual(expect.objectContaining({ path: ["developerEmail"] })); } }); - test("applies defaults for optional chained auth fields", () => { - const minimal = { - specVersion: 2, - pluginId: "test", - name: "Test Plugin", - version: "1.0.0", - auth: { - type: "chained" as const, - authorizationEndpoint: "https://plugin.example.com/auth/authorize", - callbackEndpoint: "https://plugin.example.com/auth/callback", - }, - mcpUrl: "https://mcp.example.com/mcp", - }; - const result = PluginManifestSchema.parse(minimal); - expect(result.auth.type).toBe("chained"); - if (result.auth.type === "chained") { - expect(result.auth.requiredUserContext).toEqual(["user_id"]); - expect(result.auth.externalServices).toBeUndefined(); - expect(result.auth.tokenEndpoint).toBeUndefined(); - } + test("rejects chained auth for custom registration", () => { + expect(PluginManifestSchema.safeParse({ ...validManifest, auth: chainedAuth }).success).toBe(false); }); - test("rejects specVersion 0", () => { - const invalid = { ...validManifestV2Forwarding, specVersion: 0 }; - expect(() => PluginManifestSchema.parse(invalid)).toThrow(); + test.each([undefined, "header", "query"])("accepts no auth with deliveryMethod %s", (deliveryMethod) => { + const auth = { type: "none" as const, ...(deliveryMethod && { deliveryMethod }) }; + const result = PluginManifestSchema.parse({ ...validManifest, auth }); + expect(result.auth).toEqual(auth); }); - test("rejects specVersion 3", () => { - const invalid = { ...validManifestV2Forwarding, specVersion: 3 }; - expect(() => PluginManifestSchema.parse(invalid)).toThrow(); + test.each([0, 1, 2, 3, "4", undefined])("rejects specVersion %s", (specVersion) => { + expect(PluginManifestSchema.safeParse({ ...validManifest, specVersion }).success).toBe(false); }); - test("rejects invalid auth type", () => { - const invalid = { - ...validManifestV2Forwarding, - auth: { ...validManifestV2Forwarding.auth, type: "oauth" }, - }; - expect(() => PluginManifestSchema.parse(invalid)).toThrow(); - }); - - test("rejects chained auth without required endpoints", () => { - const invalid = { - specVersion: 2, - pluginId: "test", - name: "Test Plugin", - version: "1.0.0", - auth: { - type: "chained" as const, - authorizationEndpoint: "https://plugin.example.com/auth/authorize", - // missing callbackEndpoint - }, - mcpUrl: "https://mcp.example.com/mcp", - }; - expect(() => PluginManifestSchema.parse(invalid)).toThrow(); + test("rejects legacy authForwarding manifests", () => { + const { auth, ...manifest } = validManifest; + const { type, ...authForwarding } = auth; + for (const specVersion of [1, 2, 3]) { + expect(PluginManifestSchema.safeParse({ ...manifest, specVersion, authForwarding }).success).toBe(false); + } }); - test("rejects invalid requiredUserContext values", () => { - const invalid = { - ...validManifestV2Chained, - auth: { - ...validManifestV2Chained.auth, - requiredUserContext: ["user_id", "invalid_field"], - }, - }; - expect(() => PluginManifestSchema.parse(invalid)).toThrow(); + test.each(["v1", "1.0", "1.0.0-beta", "1.0.0+build", ""])("rejects non-producer version %s", (version) => { + expect(PluginManifestSchema.safeParse({ ...validManifest, version }).success).toBe(false); }); -}); -describe("PluginManifestSchemaV1 (legacy)", () => { - test("accepts a valid v1 manifest", () => { - const result = PluginManifestSchemaV1.parse(validManifestV1); - expect(result.pluginId).toBe("monday"); - expect(result.specVersion).toBe(1); - expect(result.authForwarding.tokenEndpoint).toBe( - "https://auth.monday.com/oauth2/token" - ); - }); - - test("rejects v1 manifest with specVersion 2", () => { - const invalid = { ...validManifestV1, specVersion: 2 }; - expect(() => PluginManifestSchemaV1.parse(invalid)).toThrow(); - }); - - test("applies defaults for optional authForwarding fields", () => { - const minimal = { - specVersion: 1, - pluginId: "test", - name: "Test Plugin", - version: "1.0.0", - mcpUrl: "https://mcp.example.com/mcp", - authForwarding: { - tokenEndpoint: "https://auth.example.com/token", - authorizationEndpoint: "https://auth.example.com/authorize", - }, - }; - const result = PluginManifestSchemaV1.parse(minimal); - expect(result.authForwarding.requiredScopes).toEqual([]); - expect(result.authForwarding.deliveryMethod).toBe("header"); - expect(result.authForwarding.maxTokenTtl).toBeUndefined(); + test.each([ + { pluginId: "" }, + { name: "" }, + { mcpUrl: "not-a-url" }, + { icon: 123 }, + { auth: { ...validManifest.auth, type: "oauth" } }, + { auth: { ...validManifest.auth, deliveryMethod: "cookie" } }, + { auth: { type: "none", deliveryMethod: "cookie" } }, + { auth: { ...chainedAuth, callbackEndpoint: undefined } }, + { auth: { ...chainedAuth, requiredUserContext: ["invalid_field"] } }, + ])("rejects invalid manifest fields %j", (invalid) => { + expect(PluginManifestSchema.safeParse({ ...validManifest, ...invalid }).success).toBe(false); }); -}); -describe("PluginManifestSchemaCompat (backward compatibility)", () => { - test("accepts v2 manifest with forwarding auth", () => { - const result = PluginManifestSchemaCompat.parse(validManifestV2Forwarding); - expect(result.specVersion).toBe(2); - expect(result.auth.type).toBe("forwarding"); + test("preserves governance and an optional icon string", () => { + const result = PluginManifestSchema.parse({ ...validManifest, governance, icon: "https://example.com/icon.png" }); + expect(result.governance).toEqual(governance); + expect(result.icon).toBe("https://example.com/icon.png"); }); - test("accepts v2 manifest with chained auth", () => { - const result = PluginManifestSchemaCompat.parse(validManifestV2Chained); - expect(result.specVersion).toBe(2); - expect(result.auth.type).toBe("chained"); + test.each(["client_credentials", "urn:ietf:params:oauth:grant-type:saml2-bearer"])("accepts admin grant %s without redirect", (grantType) => { + const result = PluginManifestSchema.parse({ + ...validManifest, + auth: { type: "forwarding", tokenEndpoint: validManifest.auth.tokenEndpoint, grantType }, + }); + expect(result.auth).toEqual({ + type: "forwarding", tokenEndpoint: validManifest.auth.tokenEndpoint, + grantType, requiredScopes: [], deliveryMethod: "header", + }); }); - test("accepts v1 manifest and transforms to v2 format", () => { - const result = PluginManifestSchemaCompat.parse(validManifestV1); - expect(result.specVersion).toBe(1); - expect(result.auth.type).toBe("forwarding"); - if (result.auth.type === "forwarding") { - expect(result.auth.tokenEndpoint).toBe( - "https://auth.monday.com/oauth2/token" - ); + test.each([undefined, "authorization_code"])("requires authorizationEndpoint for grant %s", (grantType) => { + const result = PluginManifestSchema.safeParse({ + ...validManifest, + auth: { type: "forwarding", tokenEndpoint: validManifest.auth.tokenEndpoint, grantType }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual(expect.objectContaining({ + path: ["auth", "authorizationEndpoint"], + message: "authorizationEndpoint is required when grantType is authorization_code", + })); } }); - test("rejects invalid version format", () => { - const invalid = { ...validManifestV2Forwarding, version: "v1" }; - expect(() => PluginManifestSchemaCompat.parse(invalid)).toThrow(); + test("rejects invalid grantType", () => { + expect(PluginManifestSchema.safeParse({ + ...validManifest, auth: { ...validManifest.auth, grantType: "password" }, + }).success).toBe(false); + }); +}); + +describe("GovernanceSchema", () => { + test.each(["organization", "user"])("accepts credential %s and empty dataset collection", (credential) => { + expect(GovernanceSchema.parse({ credential, datasets: {} })).toEqual({ credential, datasets: {} }); }); - test("rejects invalid mcpUrl", () => { - const invalid = { ...validManifestV2Forwarding, mcpUrl: "not-a-url" }; - expect(() => PluginManifestSchemaCompat.parse(invalid)).toThrow(); + test("preserves omitted nullable as unknown", () => { + const result = GovernanceSchema.parse(governance); + expect(result.datasets.projects?.fields?.[1]?.nullable).toBeUndefined(); }); - test("rejects empty pluginId", () => { - const invalid = { ...validManifestV2Forwarding, pluginId: "" }; - expect(() => PluginManifestSchemaCompat.parse(invalid)).toThrow(); + test.each(["", "project.items", "project items", "project\titems", "project\nitems"])("rejects dataset id %j", (id) => { + expect(() => GovernanceSchema.parse({ + credential: "user", datasets: { [id]: governance.datasets.people }, + })).toThrow("Data set id"); }); - test("rejects invalid deliveryMethod in forwarding auth", () => { - const invalid = { - ...validManifestV2Forwarding, - auth: { - ...validManifestV2Forwarding.auth, - deliveryMethod: "cookie", + test.each([{ fields: undefined }, { fields: [] }])("rejects fieldless datasets (%j)", ({ fields }) => { + const result = GovernanceSchema.safeParse({ + credential: "user", datasets: { people: { title: "People", fields } }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual(expect.objectContaining({ + path: ["datasets", "people", "fields"], + code: fields === undefined ? "invalid_type" : "too_small", + })); + } + }); + + test.each([ + { dataset: "missing", field: "id" }, + { dataset: "people", field: "missing" }, + ])("rejects undeclared reference %j", (references) => { + expect(() => GovernanceSchema.parse({ + ...governance, + datasets: { + ...governance.datasets, + projects: { title: "Projects", fields: [{ name: "owner", type: "string", references }] }, }, - }; - expect(() => PluginManifestSchemaCompat.parse(invalid)).toThrow(); + })).toThrow("a field references a data set or field that is not declared"); }); -}); -describe("AuthForwardingSchema", () => { - test("requires type field", () => { - const valid = { - type: "forwarding" as const, - tokenEndpoint: "https://auth.example.com/token", - authorizationEndpoint: "https://auth.example.com/authorize", - }; - const result = AuthForwardingSchema.parse(valid); - expect(result.type).toBe("forwarding"); + test.each(["missing", "constructor", "toString", "__proto__"])("rejects restrictions on undeclared dataset %s", (id) => { + expect(() => GovernanceSchema.parse({ ...governance, datasets: {}, restrictedByDefault: [id] })) + .toThrow("restrictedByDefault names a data set that is not declared"); }); - test("defaults grantType to authorization_code", () => { - const valid = { - type: "forwarding" as const, - tokenEndpoint: "https://auth.example.com/token", - authorizationEndpoint: "https://auth.example.com/authorize", + test.each(["constructor", "toString"])("accepts restrictions on explicitly declared dataset %s", (id) => { + const input: Governance = { + credential: "user", + datasets: { [id]: governance.datasets.people }, + restrictedByDefault: [id], }; - const result = AuthForwardingSchema.parse(valid); - expect(result.grantType).toBe("authorization_code"); + expect(GovernanceSchema.parse(input)).toEqual(input); }); - test("accepts client_credentials without authorizationEndpoint", () => { - const valid = { - type: "forwarding" as const, - tokenEndpoint: "https://api.ariba.com/v2/oauth/token", - grantType: "client_credentials" as const, - }; - const result = AuthForwardingSchema.parse(valid); - expect(result.grantType).toBe("client_credentials"); - expect(result.authorizationEndpoint).toBeUndefined(); + test("validates governance through the manifest boundary", () => { + expect(() => PluginManifestSchema.parse({ + ...validManifest, governance: { ...governance, restrictedByDefault: ["missing"] }, + })).toThrow("restrictedByDefault names a data set that is not declared"); }); - test("accepts saml2-bearer without authorizationEndpoint", () => { - const valid = { - type: "forwarding" as const, - tokenEndpoint: "https://dc.successfactors.com/oauth/token", - grantType: "urn:ietf:params:oauth:grant-type:saml2-bearer" as const, - }; - const result = AuthForwardingSchema.parse(valid); - expect(result.grantType).toBe( - "urn:ietf:params:oauth:grant-type:saml2-bearer", - ); - expect(result.authorizationEndpoint).toBeUndefined(); + test.each([ + { credential: "team" }, { credential: undefined }, + { timezone: "" }, { purpose: "" }, { defaultAccess: "private" }, + ])("rejects invalid governance properties %j", (invalid) => { + expect(GovernanceSchema.safeParse({ ...governance, ...invalid }).success).toBe(false); }); -}); -describe("PluginManifestSchema v2 - admin grant types", () => { - test("accepts client_credentials forwarding manifest", () => { - const manifest = { - specVersion: 2, - pluginId: "sap-ariba", - name: "SAP Ariba", - version: "1.0.0", - auth: { - type: "forwarding" as const, - tokenEndpoint: "https://api.ariba.com/v2/oauth/token", - grantType: "client_credentials" as const, - deliveryMethod: "header" as const, - }, - mcpUrl: "https://ariba-plugin.example.com/mcp", - }; - const result = PluginManifestSchema.parse(manifest); - expect(result.auth.type).toBe("forwarding"); - if (result.auth.type === "forwarding") { - expect(result.auth.grantType).toBe("client_credentials"); - expect(result.auth.authorizationEndpoint).toBeUndefined(); - } + test.each([ + { title: "" }, { paths: [""] }, { probe: { tool: "", args: {} } }, + { probe: { tool: "list_people", args: [] } }, + ])("rejects invalid dataset properties %j", (invalid) => { + expect(GovernanceSchema.safeParse({ + credential: "user", datasets: { people: { ...governance.datasets.people, ...invalid } }, + }).success).toBe(false); }); - test("accepts saml2-bearer forwarding manifest", () => { - const manifest = { - specVersion: 2, - pluginId: "sap-successfactors", - name: "SAP SuccessFactors", - version: "1.0.0", - auth: { - type: "forwarding" as const, - tokenEndpoint: "https://dc.successfactors.com/oauth/token", - grantType: - "urn:ietf:params:oauth:grant-type:saml2-bearer" as const, - deliveryMethod: "header" as const, - }, - mcpUrl: "https://sf-plugin.example.com/mcp", - }; - const result = PluginManifestSchema.parse(manifest); - expect(result.auth.type).toBe("forwarding"); - if (result.auth.type === "forwarding") { - expect(result.auth.grantType).toBe( - "urn:ietf:params:oauth:grant-type:saml2-bearer", - ); - } + test.each([ + { name: "" }, { type: "" }, { identifier: "" }, { unit: "" }, + { nullable: "unknown" }, { clock: "local" }, + { references: { dataset: "", field: "id" } }, + { references: { dataset: "people", field: "" } }, + ])("rejects invalid field properties %j", (invalid) => { + expect(GovernanceSchema.safeParse({ + credential: "user", + datasets: { people: { title: "People", fields: [{ name: "id", type: "string", ...invalid }] } }, + }).success).toBe(false); }); +}); - test("rejects authorization_code without authorizationEndpoint", () => { - const manifest = { - specVersion: 2, - pluginId: "test", - name: "Test", - version: "1.0.0", - auth: { - type: "forwarding" as const, - tokenEndpoint: "https://auth.example.com/token", - grantType: "authorization_code" as const, - // missing authorizationEndpoint - }, - mcpUrl: "https://mcp.example.com/mcp", - }; - expect(() => PluginManifestSchema.parse(manifest)).toThrow( - "authorizationEndpoint is required", - ); +describe("AuthForwardingSchema", () => { + test("applies defaults", () => { + const result = AuthForwardingSchema.parse({ + type: "forwarding", tokenEndpoint: validManifest.auth.tokenEndpoint, + }); + expect(result.grantType).toBe("authorization_code"); + expect(result.requiredScopes).toEqual([]); + expect(result.deliveryMethod).toBe("header"); + expect(result.maxTokenTtl).toBeUndefined(); }); - test("rejects invalid grantType", () => { - const manifest = { - specVersion: 2, - pluginId: "test", - name: "Test", - version: "1.0.0", - auth: { - type: "forwarding" as const, - tokenEndpoint: "https://auth.example.com/token", - grantType: "password", - }, - mcpUrl: "https://mcp.example.com/mcp", - }; - expect(() => PluginManifestSchema.parse(manifest)).toThrow(); + test("requires the forwarding type", () => { + expect(AuthForwardingSchema.safeParse({ tokenEndpoint: validManifest.auth.tokenEndpoint }).success).toBe(false); + }); + + test.each(["client_credentials", "urn:ietf:params:oauth:grant-type:saml2-bearer"])("accepts %s without authorizationEndpoint", (grantType) => { + const result = AuthForwardingSchema.parse({ + type: "forwarding", tokenEndpoint: validManifest.auth.tokenEndpoint, grantType, + }); + expect(result.grantType).toBe(grantType); + expect(result.authorizationEndpoint).toBeUndefined(); }); }); describe("ChainedAuthSchema", () => { - test("validates complete chained auth config", () => { - const valid = { - type: "chained" as const, - authorizationEndpoint: "https://plugin.example.com/auth/authorize", - callbackEndpoint: "https://plugin.example.com/auth/callback", - tokenEndpoint: "https://plugin.example.com/auth/token", - requiredUserContext: ["user_id", "email"] as const, - externalServices: [ - { - name: "External API", - authorizationEndpoint: "https://external.com/oauth/authorize", - requiredScopes: ["read", "write"], - }, - ], - sessionConfig: { - maxSessionDuration: 3600000, - supportsRefresh: true, - }, - }; - const result = ChainedAuthSchema.parse(valid); - expect(result.type).toBe("chained"); - expect(result.requiredUserContext).toEqual(["user_id", "email"]); - expect(result.externalServices).toHaveLength(1); - expect(result.sessionConfig?.supportsRefresh).toBe(true); + test("preserves complete config", () => { + expect(ChainedAuthSchema.parse(chainedAuth)).toEqual(chainedAuth); }); test("applies defaults", () => { - const minimal = { - type: "chained" as const, - authorizationEndpoint: "https://plugin.example.com/auth/authorize", - callbackEndpoint: "https://plugin.example.com/auth/callback", - }; - const result = ChainedAuthSchema.parse(minimal); + const result = ChainedAuthSchema.parse({ + type: "chained", + authorizationEndpoint: chainedAuth.authorizationEndpoint, + callbackEndpoint: chainedAuth.callbackEndpoint, + externalServices: [{ name: "External API", authorizationEndpoint: chainedAuth.authorizationEndpoint }], + sessionConfig: {}, + }); + expect(result.requiredUserContext).toEqual(["user_id"]); + expect(result.tokenEndpoint).toBeUndefined(); + expect(result.externalServices?.[0]?.requiredScopes).toEqual([]); + expect(result.sessionConfig?.supportsRefresh).toBe(false); + }); + + test("keeps optional session and external services absent", () => { + const result = ChainedAuthSchema.parse({ + type: "chained", + authorizationEndpoint: chainedAuth.authorizationEndpoint, + callbackEndpoint: chainedAuth.callbackEndpoint, + }); expect(result.requiredUserContext).toEqual(["user_id"]); expect(result.sessionConfig).toBeUndefined(); + expect(result.externalServices).toBeUndefined(); + }); +}); + +const credential = { key: "secret", label: "Secret", type: "password" as const }; +const keypair = { key: "private_key_pem", label: "Certificate", type: "x509-keypair" as const, secondaryKey: "public_cert_pem" }; + +/** Exercises credential rules at the complete manifest boundary. */ +function parseCredentials(credentials_schema: unknown, grantType = "credentials") { + const auth = ["none", "credentials", "api_key"].includes(grantType) + ? { type: "none", grantType, credentials_schema } + : { ...validManifest.auth, grantType, credentials_schema }; + return PluginManifestSchema.safeParse({ ...validManifest, auth }); +} + +describe("manifest credentials", () => { + test("preserves credential display options and keypair keys", () => { + const fields = [{ ...credential, required: true, placeholder: "Enter secret", help: "From your console" }, keypair]; + const result = parseCredentials(fields); + expect(result.success).toBe(true); + if (result.success) expect(result.data.auth.credentials_schema).toEqual(fields); + }); + + test.each([ + { ...keypair, secondaryKey: undefined }, { ...keypair, secondaryKey: "" }, + { ...credential, secondaryKey: "other" }, { ...credential, secondaryKey: "" }, + ])("rejects invalid secondaryKey %j", (field) => { + const result = parseCredentials([field]); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.issues).toContainEqual(expect.objectContaining({ + path: ["auth", "credentials_schema", 0, "secondaryKey"], + message: "secondaryKey is required only for x509-keypair fields", + })); + }); + + test.each([ + [credential, credential], + [keypair, { ...credential, key: keypair.secondaryKey }], + [{ ...credential, key: keypair.secondaryKey }, keypair], + [keypair, { ...keypair, key: "other" }], + [{ ...keypair, secondaryKey: keypair.key }], + ].map((fields) => ({ fields })))("rejects duplicate primary/secondary keys %j", ({ fields }) => { + const result = parseCredentials(fields); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.issues).toContainEqual(expect.objectContaining({ message: "Credential keys must be unique" })); + }); + + test.each([".*", "", null, 42])("rejects unsupported pattern %j", (pattern) => { + expect(parseCredentials([{ ...credential, pattern }]).success).toBe(false); + }); + + test.each([undefined, null, []].map((fields) => ({ fields })))("none accepts no fields, credentials requires fields: %j", ({ fields }) => { + expect(parseCredentials(fields, "none").success).toBe(true); + expect(parseCredentials(fields, "credentials").success).toBe(false); + }); + + test("none (including omitted grant) cannot collect fields", () => { + expect(parseCredentials([credential], "none").success).toBe(false); + expect(PluginManifestSchema.safeParse({ ...validManifest, auth: { type: "none", credentials_schema: [credential] } }).success).toBe(false); + }); + + const grants = [ + { grant: "api_key", keys: ["api_key"] }, + { grant: "client_credentials", keys: ["api_base_url", "client_id", "client_secret"] }, + { grant: "urn:ietf:params:oauth:grant-type:saml2-bearer", keys: ["api_base_url", "client_id", "saml_subject_name_id", "private_key_pem", "public_cert_pem"] }, + ]; + for (const { grant, keys } of grants) { + test(`${grant} requires every key in explicit arrays`, () => { + const fields = keys.map((key) => ({ ...credential, key })); + expect(parseCredentials(fields, grant).success).toBe(true); + expect(parseCredentials([], grant).success).toBe(false); + for (const key of keys) { + const result = parseCredentials(fields.filter((field) => field.key !== key), grant); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.issues).toContainEqual(expect.objectContaining({ + path: ["auth", "credentials_schema"], message: `auth.credentials_schema must collect ${key} for ${grant}`, + })); + } + }); + test.each([undefined, null])(`${grant} preserves platform-default fields %j`, (fields) => { + const result = parseCredentials(fields, grant); + expect(result.success).toBe(true); + if (result.success) expect(result.data.auth.credentials_schema).toBe(fields); + }); + } + + test("SAML counts the secondary certificate key", () => { + expect(parseCredentials([ + ...['api_base_url', 'client_id', 'saml_subject_name_id'].map((key) => ({ ...credential, key })), keypair, + ], "urn:ietf:params:oauth:grant-type:saml2-bearer").success).toBe(true); + }); + + test.each([undefined, null, []].map((fields) => ({ fields })))("authorization_code accepts no fields: %j", ({ fields }) => { + expect(parseCredentials(fields, "authorization_code").success).toBe(true); + }); + + test.each(["authorization_code", undefined])("OAuth rejects credential fields with grant %j", (grantType) => { + const result = PluginManifestSchema.safeParse({ + ...validManifest, + auth: { ...validManifest.auth, grantType, credentials_schema: [credential] }, + }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.issues).toContainEqual(expect.objectContaining({ + path: ["auth", "credentials_schema"], + })); + }); + + for (const key of ["grant_type", "data_api_params", "token_request_params"]) { + test.each(["key", "secondaryKey"] as const)(`rejects reserved ${key} in %s`, (property) => { + const field = property === "key" ? { ...credential, key } : { ...keypair, secondaryKey: key }; + const result = parseCredentials([field]); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.issues).toContainEqual(expect.objectContaining({ + path: ["auth", "credentials_schema", 0], + message: `Credential key ${key} is reserved`, + })); + }); + } +}); + +describe("manifest URLs and metadata", () => { + test.each(["/", "/oauth/token", "/oauth/token?tenant=demo"])("relative token endpoint %s requires an admin grant", (tokenEndpoint) => { + for (const grantType of ["client_credentials", "urn:ietf:params:oauth:grant-type:saml2-bearer"]) { + expect(PluginManifestSchema.safeParse({ ...validManifest, auth: { type: "forwarding", grantType, tokenEndpoint } }).success).toBe(true); + } + expect(PluginManifestSchema.safeParse({ ...validManifest, auth: { ...validManifest.auth, tokenEndpoint } }).success).toBe(false); + }); + + test.each(["//evil.example/token", "/\\evil.example", "/token#fragment", "/has space", "token", "ftp://example.com/token", "javascript:alert(1)", "file:///token"])("rejects token endpoint %j", (tokenEndpoint) => { + expect(PluginManifestSchema.safeParse({ ...validManifest, auth: { type: "forwarding", grantType: "client_credentials", tokenEndpoint } }).success).toBe(false); + }); + + const metadata = { + tagline: "Connect work", overview: "Manage projects", setupType: "admin", + oauth: { token_body: "json", setup_steps: ["Create credentials"], console_url: "https://example.com/console" }, + features: [{ title: "Read", description: "Read projects", category: "read_write", icon: "folder" }], + permissions: ["read", { scope: "write", label: "Write", description: "Change projects", access: "admin", docsUrl: null }], + links: { docs: "https://example.com/docs", privacy: "http://example.com/privacy" }, + } satisfies NonNullable; + test.each([undefined, null, metadata])("preserves supported metadata %j", (metadata) => { + expect(PluginManifestSchema.parse({ ...validManifest, metadata }).metadata).toEqual(metadata); + }); + test.each([ + { setupType: "owner" }, { oauth: { token_body: "xml" } }, { oauth: { setup_steps: [42] } }, + { oauth: { console_url: "ftp://example.com" } }, { links: { docs: "/docs" } }, + { links: { privacy: "javascript:alert(1)" } }, + { features: [{ ...metadata.features[0], category: "admin" }] }, + { permissions: [{ scope: "read", label: "Read", description: "Read", access: "owner", docsUrl: null }] }, + { permissions: [{ scope: "read", label: "Read", description: "Read", access: "read", docsUrl: "file:///docs" }] }, + ])("rejects invalid metadata %j", (invalid) => { + expect(PluginManifestSchema.safeParse({ ...validManifest, metadata: { ...metadata, ...invalid } }).success).toBe(false); + }); + test.each(["ftp://example.com", "javascript:alert(1)"])("rejects non-HTTP display and authorization URLs %s", (url) => { + for (const overrides of [{ mcpUrl: url }, { icon: url }, { auth: { ...validManifest.auth, authorizationEndpoint: url } }]) { + expect(PluginManifestSchema.safeParse({ ...validManifest, ...overrides }).success).toBe(false); + } }); }); diff --git a/packages/plugins/src/pluginManifest.ts b/packages/plugins/src/pluginManifest.ts index b2e733e..1a6342c 100644 --- a/packages/plugins/src/pluginManifest.ts +++ b/packages/plugins/src/pluginManifest.ts @@ -1,26 +1,150 @@ import { z } from "zod"; +/** Manifest protocol version emitted by this SDK. */ +export const PLUGIN_MANIFEST_SPEC_VERSION = 4; + +const HttpUrlSchema = z.url({ protocol: /^https?$/ }); + +/** Signed integration display and setup metadata supported by custom registration. */ +const IntegrationMetadataSchema = z.object({ + tagline: z.string().optional(), + oauth: z + .object({ + token_body: z.enum(["form", "json"]).optional(), + setup_steps: z.array(z.string()).optional(), + console_url: HttpUrlSchema.optional(), + }) + .optional(), + overview: z.string().optional(), + features: z + .array( + z.object({ + title: z.string(), + description: z.string(), + category: z.enum(["read", "write", "read_write"]), + icon: z.string().optional(), + }), + ) + .optional(), + permissions: z + .array( + z.union([ + z.object({ + scope: z.string(), + label: z.string(), + description: z.string(), + access: z.enum(["read", "write", "read_write", "admin"]), + docsUrl: HttpUrlSchema.nullable(), + }), + z.string(), + ]), + ) + .optional(), + setupType: z.enum(["user", "admin", "none"]).optional(), + links: z + .object({ + docs: HttpUrlSchema.optional(), + privacy: HttpUrlSchema.optional(), + }) + .optional(), +}); + +/** Platform-owned credential properties that custom forms must not overwrite. */ +const RESERVED_CREDENTIAL_KEYS = new Set([ + "grant_type", + "data_api_params", + "token_request_params", +]); + +const CredentialFieldSchema = z.object({ + key: z.string().min(1), + label: z.string(), + type: z.enum(["input", "password", "textarea", "x509-keypair"]), + required: z.boolean().optional(), + placeholder: z.string().optional(), + help: z.string().optional(), + // Keep this key in validation so unsupported regexes cannot be silently stripped. + pattern: z.never().optional(), + secondaryKey: z.string().optional(), +}); + +const CredentialsSchema = z + .array(CredentialFieldSchema) + .superRefine((fields, ctx) => { + const keys = new Set(); + fields.forEach((field, index) => { + if ( + field.type === "x509-keypair" + ? !field.secondaryKey + : field.secondaryKey !== undefined + ) { + ctx.addIssue({ + code: "custom", + message: "secondaryKey is required only for x509-keypair fields", + path: [index, "secondaryKey"], + }); + } + for (const key of [ + field.key, + ...(field.secondaryKey !== undefined ? [field.secondaryKey] : []), + ]) { + if (RESERVED_CREDENTIAL_KEYS.has(key)) { + ctx.addIssue({ + code: "custom", + message: `Credential key ${key} is reserved`, + path: [index], + }); + } + if (keys.has(key)) { + ctx.addIssue({ + code: "custom", + message: "Credential keys must be unique", + path: [index], + }); + } + keys.add(key); + } + }); + }) + .nullable() + .optional(); + // Auth Forwarding Schema (platform manages tokens, forwards to plugin) // Supports authorization_code (user-initiated OAuth), client_credentials, and saml2-bearer // (admin-configured). When grantType is not authorization_code, authorizationEndpoint is // not needed since there is no user redirect. -export const AuthForwardingSchema = z.object({ - type: z.literal("forwarding"), - tokenEndpoint: z.url(), - authorizationEndpoint: z.url().optional(), - grantType: z - .enum([ - "authorization_code", - "client_credentials", - "urn:ietf:params:oauth:grant-type:saml2-bearer", - ]) - .default("authorization_code"), - requiredScopes: z.array(z.string()).default([]), - deliveryMethod: z.enum(["header", "query"]).default("header"), - maxTokenTtl: z.number().positive().optional(), -}); +export const AuthForwardingSchema = z + .object({ + type: z.literal("forwarding"), + credentials_schema: CredentialsSchema, + extra_params: z.record(z.string(), z.string()).nullable().optional(), + tokenEndpoint: z.union([ + HttpUrlSchema, + z.string().regex(/^\/(?![/\\])[^\s#\\]*$/), + ]), + authorizationEndpoint: HttpUrlSchema.optional(), + grantType: z + .enum([ + "authorization_code", + "client_credentials", + "urn:ietf:params:oauth:grant-type:saml2-bearer", + ]) + .default("authorization_code"), + requiredScopes: z.array(z.string()).default([]), + deliveryMethod: z.enum(["header", "query"]).default("header"), + maxTokenTtl: z.number().positive().optional(), + }) + .refine( + (auth) => + auth.grantType !== "authorization_code" || + !auth.tokenEndpoint.startsWith("/"), + { + message: "OAuth authorization_code requires an absolute tokenEndpoint", + path: ["tokenEndpoint"], + }, + ); -// v2 Chained Auth Schema (plugin manages its own sessions and stores third-party tokens) +/** Standalone chained-runtime configuration; excluded from v4 custom manifests. */ export const ChainedAuthSchema = z.object({ type: z.literal("chained"), @@ -54,20 +178,149 @@ export const ChainedAuthSchema = z.object({ .optional(), }); +/** No OAuth exchange; explicit organization grants can collect credentials. */ +export const NoAuthSchema = z.object({ + type: z.literal("none"), + credentials_schema: CredentialsSchema, + grantType: z.enum(["none", "api_key", "credentials"]).optional(), + deliveryMethod: z.enum(["header", "query"]).optional(), +}); + // Discriminated union for auth types export const AuthSchema = z.discriminatedUnion("type", [ AuthForwardingSchema, - ChainedAuthSchema, + NoAuthSchema, ]); +const DatasetFieldSchema = z.object({ + name: z.string().min(1), + type: z.string().min(1), + identifier: z.string().min(1).optional(), // what the value matches elsewhere in the same system + references: z + .object({ dataset: z.string().min(1), field: z.string().min(1) }) + .optional(), // the declared column this one's values match + nullable: z.boolean().optional(), // absent = unknown + clock: z.enum(["wall", "instant"]).optional(), + unit: z.string().min(1).optional(), +}); + +const DatasetSchema = z.object({ + title: z.string().min(1), + description: z.string().optional(), + group: z.string().optional(), + paths: z.array(z.string().min(1)).optional(), // upstream paths for audit and conformance + fields: z.array(DatasetFieldSchema).min(1), + probe: z + .object({ tool: z.string().min(1), args: z.record(z.string(), z.unknown()) }) + .optional(), +}); + +/** Declared data sets, their field relationships, and default access policy. */ +export const GovernanceSchema = z + .object({ + timezone: z.string().min(1).optional(), + // what the system is for; routing reads it when two systems hold the same noun + purpose: z.string().min(1).optional(), + credential: z.enum(["organization", "user"]), + datasets: z.record(z.string(), DatasetSchema), + defaultAccess: z.enum(["open", "restricted"]).optional(), + restrictedByDefault: z.array(z.string()).optional(), + }) + .superRefine((data, ctx) => { + for (const id of Object.keys(data.datasets)) { + if (!/^[^.\s]+$/.test(id)) { + ctx.addIssue({ + code: "custom", + message: `Data set id "${id}" has a dot or space; a data set id has neither.`, + path: ["datasets", id], + }); + } + } + }) + .refine( + (data) => + (data.restrictedByDefault ?? []).every((id) => Object.hasOwn(data.datasets, id)), + { + message: "restrictedByDefault names a data set that is not declared", + path: ["restrictedByDefault"], + }, + ) + .refine( + (data) => + Object.values(data.datasets).every((set) => + (set.fields ?? []).every( + ({ references }) => + !references || + (data.datasets[references.dataset]?.fields ?? []).some( + (field) => field.name === references.field, + ), + ), + ), + { + message: "a field references a data set or field that is not declared", + path: ["datasets"], + }, + ); + +/** Keys collected by explicit credential forms; other grants have no required keys. */ +const requiredCredentialKeys: Partial["grantType"]>, readonly string[]>> = { + api_key: ["api_key"], + client_credentials: ["api_base_url", "client_id", "client_secret"], + "urn:ietf:params:oauth:grant-type:saml2-bearer": [ + "api_base_url", + "client_id", + "saml_subject_name_id", + "private_key_pem", + "public_cert_pem", + ], +}; + +/** A v4 custom-plugin producer manifest with validated auth and optional data governance. */ export const PluginManifestSchema = z .object({ - specVersion: z.number().int().min(1).max(2), + specVersion: z.literal(PLUGIN_MANIFEST_SPEC_VERSION), pluginId: z.string().min(1), + /** Developer or support contact included in the signed manifest. */ + developerEmail: z.email(), name: z.string().min(1), version: z.string().regex(/^\d+\.\d+\.\d+$/, "Must be semver (e.g. 1.0.0)"), + icon: HttpUrlSchema.optional(), + description: z.string().nullable().optional(), + category: z.string().nullable().optional(), + metadata: IntegrationMetadataSchema.nullable().optional(), auth: AuthSchema, - mcpUrl: z.url(), + mcpUrl: HttpUrlSchema, + governance: GovernanceSchema.optional(), + }) + .superRefine(({ auth }, ctx) => { + const grant = auth.grantType ?? "none"; + const fields = auth.credentials_schema; + const issue = (message: string) => + ctx.addIssue({ + code: "custom", + message, + path: ["auth", "credentials_schema"], + }); + if (grant === "none" && fields?.length) + issue("A none grant cannot collect credentials"); + if (grant === "authorization_code" && fields?.length) + issue("An authorization_code grant cannot collect credentials"); + if (grant === "credentials" && !fields?.length) + issue("A credentials grant requires fields"); + // Null/omitted fields use platform defaults; explicit arrays must collect each grant key. + if (fields != null) { + const required = requiredCredentialKeys[grant] ?? []; + const keys = new Set( + fields.flatMap((field) => [ + field.key, + ...(field.secondaryKey ? [field.secondaryKey] : []), + ]), + ); + for (const key of required) { + if (!keys.has(key)) + issue(`auth.credentials_schema must collect ${key} for ${grant}`); + } + } }) .refine( (data) => { @@ -84,37 +337,13 @@ export const PluginManifestSchema = z }, ); -// Legacy schema for backward compatibility (v1 with authForwarding field) -export const PluginManifestSchemaV1 = z.object({ - specVersion: z.literal(1), - pluginId: z.string().min(1), - name: z.string().min(1), - version: z.string().regex(/^\d+\.\d+\.\d+$/, "Must be semver (e.g. 1.0.0)"), - authForwarding: AuthForwardingSchema.omit({ type: true }), - mcpUrl: z.url(), -}); - -// Combined schema that accepts both v1 (authForwarding) and v2 (auth) formats -export const PluginManifestSchemaCompat = z.union([ - PluginManifestSchema, - PluginManifestSchemaV1.transform((v1) => ({ - specVersion: v1.specVersion, - pluginId: v1.pluginId, - name: v1.name, - version: v1.version, - mcpUrl: v1.mcpUrl, - auth: { - type: "forwarding" as const, - ...v1.authForwarding, - }, - })), -]); - export type PluginManifest = z.infer; export type Auth = z.infer; export type AuthForwarding = z.infer; export type ChainedAuth = z.infer; +export type Governance = z.infer; -export interface PluginManifestOptions extends PluginManifest { +/** Author-supplied manifest fields; the SDK applies schema defaults and adds specVersion before signing. */ +export interface PluginManifestOptions extends Omit, "specVersion"> { signingKey: string; } diff --git a/packages/plugins/tsconfig.build.json b/packages/plugins/tsconfig.build.json index 69b95c4..e277909 100644 --- a/packages/plugins/tsconfig.build.json +++ b/packages/plugins/tsconfig.build.json @@ -8,6 +8,7 @@ "declaration": true, "emitDeclarationOnly": true, "outDir": "dist", + "rootDir": "./src", "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, diff --git a/tsconfig.examples.json b/tsconfig.examples.json new file mode 100644 index 0000000..1c61bc6 --- /dev/null +++ b/tsconfig.examples.json @@ -0,0 +1,10 @@ +{ + "extends": "./packages/plugins/tsconfig.json", + "compilerOptions": { + "paths": { + "@futurity/plugins": ["./packages/plugins/src/index.ts"], + "@futurity/plugins/*": ["./packages/plugins/src/*.ts"] + } + }, + "include": ["examples/**/*.ts", "packages/create-plugin/template/**/*.ts"] +}