diff --git a/package.json b/package.json index c0db0c7..2243690 100644 --- a/package.json +++ b/package.json @@ -74,11 +74,13 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.211", - "@cloudflare/ai-chat": "^0.8.4", - "@cloudflare/think": "0.12.1", - "@cloudflare/workers-oauth-provider": "^0.4.0", + "@cloudflare/ai-chat": "^0.10.1", + "@cloudflare/think": "0.15.1", + "@cloudflare/workers-oauth-provider": "^0.10.2", "@every-app/sdk": "^0.1.14", - "@modelcontextprotocol/sdk": "1.29.0", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "1.30.0", + "@modelcontextprotocol/server": "2.0.0", "@openrouter/ai-sdk-provider": "^2.9.0", "@tanstack/query-core": "^5.101.2", "@tanstack/react-form": "^1.33.0", @@ -87,7 +89,7 @@ "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-start": "^1.168.26", "@tanstack/react-table": "^8.21.3", - "agents": "0.17.3", + "agents": "0.20.1", "ai": "^6.0.199", "autumn-js": "^1.2.33", "better-auth": "^1.6.22", @@ -117,7 +119,7 @@ }, "devDependencies": { "@cloudflare/vite-plugin": "^1.42.3", - "@cloudflare/workers-types": "^4.20260611.1", + "@cloudflare/workers-types": "^4.20260702.1", "@distilled.cloud/cloudflare": "0.28.2", "@effect/platform-node": "4.0.0-beta.93", "@libsql/client": "^0.15.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ae6b3e..f455e34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,16 +6,19 @@ settings: overrides: '@babel/core': ^7.29.6 + brace-expansion: ^5.0.9 defu: ^6.1.5 dompurify: ^3.4.11 - form-data: ^4.0.6 + fast-uri: ^3.1.5 hono: ^4.12.25 + ip-address: ^10.3.1 + js-yaml: ^4.3.1 launch-editor: ^2.14.1 - postcss: ^8.5.10 + postcss: ^8.5.18 qs: ^6.15.2 shell-quote: ^1.8.4 smol-toml: ^1.6.1 - undici: ^7.28.0 + undici: ^7.29.0 importers: @@ -25,20 +28,26 @@ importers: specifier: ^3.0.211 version: 3.0.211(react@19.2.4)(zod@4.3.6) '@cloudflare/ai-chat': - specifier: ^0.8.4 - version: 0.8.4(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(agents@0.17.3(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260611.1)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(react@19.2.4)(zod@4.3.6) + specifier: ^0.10.1 + version: 0.10.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(agents@0.20.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(@cloudflare/workers-types@4.20260702.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(react@19.2.4)(zod@4.3.6) '@cloudflare/think': - specifier: 0.12.1 - version: 0.12.1(@ai-sdk/provider@3.0.10)(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(agents@0.17.3(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260611.1)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(react@19.2.4)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6) + specifier: 0.15.1 + version: 0.15.1(@ai-sdk/provider@4.0.4)(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(agents@0.20.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(@cloudflare/workers-types@4.20260702.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(react@19.2.4)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6) '@cloudflare/workers-oauth-provider': - specifier: ^0.4.0 - version: 0.4.0 + specifier: ^0.10.2 + version: 0.10.2 '@every-app/sdk': specifier: ^0.1.14 version: 0.1.14(@tanstack/react-router@1.170.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(jose@6.1.3)(react@19.2.4) + '@modelcontextprotocol/client': + specifier: 2.0.0 + version: 2.0.0 '@modelcontextprotocol/sdk': - specifier: 1.29.0 - version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + specifier: 1.30.0 + version: 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + '@modelcontextprotocol/server': + specifier: 2.0.0 + version: 2.0.0 '@openrouter/ai-sdk-provider': specifier: ^2.9.0 version: 2.9.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6) @@ -64,17 +73,17 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) agents: - specifier: 0.17.3 - version: 0.17.3(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260611.1)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6) + specifier: 0.20.1 + version: 0.20.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(@cloudflare/workers-types@4.20260702.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6) ai: specifier: ^6.0.199 version: 6.0.199(zod@4.3.6) autumn-js: specifier: ^1.2.33 - version: 1.2.33(better-auth@1.6.22(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(better-call@1.3.7(zod@4.3.6))(express@5.2.1)(hono@4.12.27)(react@19.2.4) + version: 1.2.33(better-auth@1.6.22(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(better-call@1.3.7(zod@4.3.6))(express@5.2.1)(hono@4.12.27)(react@19.2.4) better-auth: specifier: ^1.6.22 - version: 1.6.22(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 1.6.22(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) cloudflare: specifier: ^5.2.0 version: 5.2.0 @@ -86,7 +95,7 @@ importers: version: 2.0.19 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1) + version: 0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1) fast-xml-parser: specifier: ^5.4.1 version: 5.8.0 @@ -147,10 +156,10 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.42.3 - version: 1.42.3(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260611.1)) + version: 1.42.3(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260702.1)) '@cloudflare/workers-types': - specifier: ^4.20260611.1 - version: 4.20260611.1 + specifier: ^4.20260702.1 + version: 4.20260702.1 '@distilled.cloud/cloudflare': specifier: 0.28.2 version: 0.28.2(effect@4.0.0-beta.93) @@ -189,7 +198,7 @@ importers: version: 4.7.0(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) alchemy: specifier: 2.0.0-beta.61 - version: 2.0.0-beta.61(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@22.19.11)(@types/react@19.2.14)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(effect@4.0.0-beta.93)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260625.1)(ws@8.21.0) + version: 2.0.0-beta.61(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@22.19.11)(@types/react@19.2.14)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(effect@4.0.0-beta.93)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260625.1)(ws@8.21.0) chalk: specifier: ^5.6.2 version: 5.6.2 @@ -234,13 +243,13 @@ importers: version: 3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) wrangler: specifier: ^4.105.0 - version: 4.105.0(@cloudflare/workers-types@4.20260611.1) + version: 4.105.0(@cloudflare/workers-types@4.20260702.1) packages: - '@ai-sdk/anthropic@3.0.85': - resolution: {integrity: sha512-fNeDB644l5wbRNQU0FnI+F7UTtOenMnPtACfMPUJaS2zJfuBlseEa1TMg+otHkETZgaJB+6Na51NQEv0+m7czw==} - engines: {node: '>=18'} + '@ai-sdk/anthropic@4.0.24': + resolution: {integrity: sha512-ywMZXK/uOIxUKwo2vaow0kt2XqMjIEeZWBOxif/oDka3294fUDX0gcUESUjPzd8254Fnvz1lRO70gr9LOgLkww==} + engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -256,9 +265,9 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@3.0.74': - resolution: {integrity: sha512-LPDBWd2WCv0GQs29K2pHcNrGx24hm4D8QEP386HwUAUPr1URho6bNVXHNmIv0FxaW+xDkLpNMTen+mFCUBp2LA==} - engines: {node: '>=18'} + '@ai-sdk/openai@4.0.24': + resolution: {integrity: sha512-rCwhBljlo+PPwlPskwb74erKWjcf4psAvvWkQZxkr7YbE+OqhimVzEucRX8fSMtCH+/sEJf6IkWMLEXL3AZErg==} + engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -274,10 +283,20 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@5.0.15': + resolution: {integrity: sha512-dnDM4/fS17qO+D7LxVU4003V1+8ZDEWgG7rPhvcDNNFs269qLx+Jnm2mTf72ejyjdzu+f0J+rKNSLXWjZWzxwA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@3.0.10': resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} engines: {node: '>=18'} + '@ai-sdk/provider@4.0.4': + resolution: {integrity: sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ==} + engines: {node: '>=22'} + '@ai-sdk/react@3.0.211': resolution: {integrity: sha512-tbQHuEdCtTREHBjLD+NMHmKMX8HpUJDweIXhEuJ/ceVgXfxpRWy3WsAU/oFWGYpHv/21r7JvhYDXA/FXBsElHQ==} engines: {node: '>=18'} @@ -639,21 +658,21 @@ packages: '@clack/prompts@0.11.0': resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} - '@cloudflare/ai-chat@0.8.4': - resolution: {integrity: sha512-y9+Mqe2uZS8vHOCi936IWknNM3Z2FLqU42bq1mdL+OFkI+MWdUZ0oVWR0ZWTYP/MNWgxwgdZBxzmaALQZuwbtA==} + '@cloudflare/ai-chat@0.10.1': + resolution: {integrity: sha512-kSycX0kJ93LbGCkA6j6w6nm0tieSE+MiC62EF3KyLWpsElUc0tHt7XcPs5Z300MvZU1oAoQWhRWcRK9pYHbDlQ==} peerDependencies: - '@ai-sdk/react': ^3.0.0 - agents: '>=0.14.0 <1.0.0' - ai: ^6.0.0 + '@ai-sdk/react': ^3.0.0 || ^4.0.0 + agents: '>=0.17.1 <1.0.0' + ai: ^6.0.0 || ^7.0.0 react: ^19.0.0 zod: ^4.0.0 - '@cloudflare/codemode@0.4.2': - resolution: {integrity: sha512-6sLMZDRY2USbXirrI4tjmGSMYAYM+E/PJIaDQLLbMdvQjk1z1TmJNSLomrZwErO1zFPXvGX2kaqkkxvixkfV2w==} + '@cloudflare/codemode@0.5.1': + resolution: {integrity: sha512-PcX5+qAvupi8p1bMLKhqvPHziZpDubbrxDIvVH+iuuNUaFyOxxWNS9HplfFqIULqUzDPdFf1w7IiSCKHp7GDgg==} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.0 '@tanstack/ai': '>=0.8.0 <1.0.0' - ai: ^6.0.0 + ai: ^6.0.0 || ^7.0.0 zod: ^4.0.0 peerDependenciesMeta: '@modelcontextprotocol/sdk': @@ -669,17 +688,17 @@ packages: resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} - '@cloudflare/shell@0.4.1': - resolution: {integrity: sha512-fOj7W6LJOPUppTxJ8JYSj5umEZtwz0Lf7dGYXjiRPBEtSjoASnJfMpfNqitLLeDC5qx0VIzaa/6oskMWQvUOTw==} + '@cloudflare/shell@0.4.3': + resolution: {integrity: sha512-6ZMKQZqdZeommh8LGFK5C2rep4byJfCuyjR4NBdMqn2wW9tbeDJORfDhP/No75MLx8nJeWdgeXvq3D2Ijpa0XA==} - '@cloudflare/think@0.12.1': - resolution: {integrity: sha512-2q5odiYUl63/j0gIDq7rtz+3wnpNrsEGjpBDo2Pt5etFNzVJLVo2LDU9F5xjz2z5yd3GFPj5gKdzPZaitgqOwQ==} + '@cloudflare/think@0.15.1': + resolution: {integrity: sha512-BAMVRoBArhszewE0ONcq6sNxfB0QsXIZkhdZYl+6hXdUC/tHShRK1ZDaGhHuWhDBBGYsPY07ploFy4WOmCLkwQ==} hasBin: true peerDependencies: - '@ai-sdk/react': ^3.0.204 + '@ai-sdk/react': ^3.0.0 || ^4.0.0 '@chat-adapter/telegram': ^4.29.0 - agents: '>=0.17.1 <1.0.0' - ai: ^6.0.182 + agents: '>=0.18.0 <1.0.0' + ai: ^6.0.0 || ^7.0.0 react: ^19.0.0 vite: '>=6 <9' zod: ^4.0.0 @@ -769,11 +788,11 @@ packages: cpu: [x64] os: [win32] - '@cloudflare/workers-oauth-provider@0.4.0': - resolution: {integrity: sha512-UtbV8hjC2NloB+Ds6J6v/9HiG8rx8MbdeYGCyFwOACT5vANWzDL6SKo3W5UZymsXiameAgC7jAmtUx4cc+Qpaw==} + '@cloudflare/workers-oauth-provider@0.10.2': + resolution: {integrity: sha512-3yWtnPaZyNaiLuv9tWkWfEyqbILFsvfG0Q5RwZGJ2jkSkrOMM8IxKYaGCFakSGbMXmatvyrhFDCDZ70S6TVnqQ==} - '@cloudflare/workers-types@4.20260611.1': - resolution: {integrity: sha512-DLiz8Ol1OIWLigJ+dGvuQ5Nm66D/CHNPasl8YnPiz6fGo10ggYSIVuEDMlFk6oho+piAHstNmZMl088w8xqW6g==} + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -1665,8 +1684,16 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -1675,8 +1702,12 @@ packages: '@cfworker/json-schema': optional: true - '@mongodb-js/saslprep@1.4.11': - resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + + '@mongodb-js/saslprep@1.4.13': + resolution: {integrity: sha512-E3Sv4eCYAlKYUTx8S3ioQcDUscOif+8zZ5OnW1IzJ+Tt+EO+ke8mn+Y3FX6N1H79picwbdOavVOb1jPi2EOyrg==} '@mongodb-js/zstd@7.0.0': resolution: {integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==} @@ -2994,6 +3025,9 @@ packages: '@vitest/utils@3.2.6': resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} @@ -3014,15 +3048,19 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} - agents@0.17.3: - resolution: {integrity: sha512-h0rc+dXwe/B6WblHH+Q3625nN/aryZntj6NIJX7tf+VSjmnI1EZyWtVBYMdX4FJZLShpl/vcx/A1AkxytWCPNQ==} + agents@0.20.1: + resolution: {integrity: sha512-HQRYMeZpD3k8djYBH7atRPojZMee3NvmXkzsmMWXfdHZ94vMljmWqSsD1XZd70LovHyQrw6/R81AZZIsRiFM6Q==} hasBin: true peerDependencies: - '@ai-sdk/react': ^3.0.204 + '@ai-sdk/react': ^3.0.0 || ^4.0.0 + '@cloudflare/codemode': '>=0.5.0' + '@modelcontextprotocol/client': 2.0.0 + '@modelcontextprotocol/sdk': 1.30.0 + '@modelcontextprotocol/server': 2.0.0 '@tanstack/ai': '>=0.10.2 <1.0.0' '@x402/core': ^2.0.0 '@x402/evm': ^2.0.0 - ai: ^6.0.0 + ai: ^6.0.0 || ^7.0.0 chat: ^4.29.0 just-bash: ^3.0.0 react: ^19.0.0 @@ -3031,6 +3069,8 @@ packages: peerDependenciesMeta: '@ai-sdk/react': optional: true + '@cloudflare/codemode': + optional: true '@tanstack/ai': optional: true '@x402/core': @@ -3278,9 +3318,9 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -3911,8 +3951,8 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -4175,8 +4215,8 @@ packages: resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} engines: {node: '>=12.22.0'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + ip-address@10.3.1: + resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -4275,8 +4315,8 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsesc@3.1.0: @@ -4733,8 +4773,8 @@ packages: resolution: {integrity: sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==} engines: {node: '>=18.0.0'} - mongodb-connection-string-url@7.0.1: - resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} + mongodb-connection-string-url@7.0.2: + resolution: {integrity: sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==} engines: {node: '>=20.19.0'} mongodb@7.2.0: @@ -4787,16 +4827,11 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.11: - resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} - engines: {node: ^18 || >=20} - hasBin: true - nanoid@5.1.16: resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} engines: {node: ^18 || >=20} @@ -5032,8 +5067,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -5325,10 +5360,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.5.4: resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -5459,6 +5504,11 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + srvx@0.11.22: + resolution: {integrity: sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ==} + engines: {node: '>=20.16.0'} + hasBin: true + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -5685,8 +5735,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} unenv@2.0.0-rc.24: @@ -5939,14 +5989,14 @@ packages: engines: {node: '>=16'} hasBin: true - workers-ai-provider@3.3.0: - resolution: {integrity: sha512-x5GnN07O3O+3qiXzraMRLNbM34gDuQvnn+Xgw4Xqmhka+StgIWQp8dEQZ/70nVd+rOT75dEZ3/c9JGG/kDyzkw==} + workers-ai-provider@4.0.0: + resolution: {integrity: sha512-FkhwMP1EP/MKXR8Jv1o7wLVKfTqu0h/PUaEmhZ7gyDiioib5Wsh3oPAKhG+7+SWfU3roTDHaEWlJpkJjpXu+fQ==} peerDependencies: - '@ai-sdk/anthropic': ^3.0.0 - '@ai-sdk/google': ^3.0.0 - '@ai-sdk/openai': ^3.0.0 - '@ai-sdk/provider': ^3.0.0 - ai: ^6.0.0 + '@ai-sdk/anthropic': ^4.0.0 + '@ai-sdk/google': ^4.0.0 + '@ai-sdk/openai': ^4.0.0 + '@ai-sdk/provider': ^4.0.0 + ai: ^7.0.0 peerDependenciesMeta: '@ai-sdk/anthropic': optional: true @@ -6041,10 +6091,10 @@ packages: snapshots: - '@ai-sdk/anthropic@3.0.85(zod@4.3.6)': + '@ai-sdk/anthropic@4.0.24(zod@4.3.6)': dependencies: - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.30(zod@4.3.6) + '@ai-sdk/provider': 4.0.4 + '@ai-sdk/provider-utils': 5.0.15(zod@4.3.6) zod: 4.3.6 '@ai-sdk/gateway@3.0.127(zod@4.3.6)': @@ -6061,10 +6111,10 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.3.6 - '@ai-sdk/openai@3.0.74(zod@4.3.6)': + '@ai-sdk/openai@4.0.24(zod@4.3.6)': dependencies: - '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.30(zod@4.3.6) + '@ai-sdk/provider': 4.0.4 + '@ai-sdk/provider-utils': 5.0.15(zod@4.3.6) zod: 4.3.6 '@ai-sdk/provider-utils@4.0.27(zod@4.3.6)': @@ -6081,10 +6131,23 @@ snapshots: eventsource-parser: 3.0.8 zod: 4.3.6 + '@ai-sdk/provider-utils@5.0.15(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 4.0.4 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.0.8 + undici: 7.29.0 + zod: 4.3.6 + '@ai-sdk/provider@3.0.10': dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@4.0.4': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6)': dependencies: '@ai-sdk/provider-utils': 4.0.30(zod@4.3.6) @@ -6508,7 +6571,7 @@ snapshots: '@babel/helper-string-parser': 8.0.0 '@babel/helper-validator-identifier': 8.0.2 - '@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1)': + '@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -6518,45 +6581,45 @@ snapshots: jose: 6.1.3 kysely: 0.29.2 nanostores: 1.1.1 - zod: 4.3.6 + zod: 4.4.3 optionalDependencies: - '@cloudflare/workers-types': 4.20260611.1 + '@cloudflare/workers-types': 4.20260702.1 '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))': + '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 optionalDependencies: - drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1) - '@better-auth/kysely-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(kysely@0.29.2)': + '@better-auth/kysely-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(kysely@0.29.2)': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.2 - '@better-auth/memory-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))': + '@better-auth/mongo-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 optionalDependencies: mongodb: 7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0) - '@better-auth/prisma-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -6581,29 +6644,29 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@cloudflare/ai-chat@0.8.4(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(agents@0.17.3(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260611.1)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(react@19.2.4)(zod@4.3.6)': + '@cloudflare/ai-chat@0.10.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(agents@0.20.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(@cloudflare/workers-types@4.20260702.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(react@19.2.4)(zod@4.3.6)': dependencies: '@ai-sdk/react': 3.0.211(react@19.2.4)(zod@4.3.6) - agents: 0.17.3(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260611.1)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6) + agents: 0.20.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(@cloudflare/workers-types@4.20260702.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6) ai: 6.0.199(zod@4.3.6) - nanoid: 5.1.11 + nanoid: 5.1.16 react: 19.2.4 zod: 4.3.6 - '@cloudflare/codemode@0.4.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6)': + '@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6)': dependencies: '@types/json-schema': 7.0.15 acorn: 8.17.0 optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) ai: 6.0.199(zod@4.3.6) zod: 4.3.6 '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/shell@0.4.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6)': + '@cloudflare/shell@0.4.3(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6)': dependencies: - '@cloudflare/codemode': 0.4.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6) + '@cloudflare/codemode': 0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6) isomorphic-git: 1.38.5 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -6611,20 +6674,20 @@ snapshots: - ai - zod - '@cloudflare/think@0.12.1(@ai-sdk/provider@3.0.10)(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(agents@0.17.3(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260611.1)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(react@19.2.4)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6)': + '@cloudflare/think@0.15.1(@ai-sdk/provider@4.0.4)(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(agents@0.20.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(@cloudflare/workers-types@4.20260702.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(react@19.2.4)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6)': dependencies: - '@ai-sdk/anthropic': 3.0.85(zod@4.3.6) - '@ai-sdk/openai': 3.0.74(zod@4.3.6) - '@cloudflare/codemode': 0.4.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6) - '@cloudflare/shell': 0.4.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6) - agents: 0.17.3(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260611.1)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6) + '@ai-sdk/anthropic': 4.0.24(zod@4.3.6) + '@ai-sdk/openai': 4.0.24(zod@4.3.6) + '@cloudflare/codemode': 0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6) + '@cloudflare/shell': 0.4.3(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6) + agents: 0.20.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(@cloudflare/workers-types@4.20260702.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6) ai: 6.0.199(zod@4.3.6) aywson: 0.0.16 chat: 4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6) create-think: 0.1.1 just-bash: 3.0.2 smol-toml: 1.7.0 - workers-ai-provider: 3.3.0(@ai-sdk/anthropic@3.0.85(zod@4.3.6))(@ai-sdk/openai@3.0.74(zod@4.3.6))(@ai-sdk/provider@3.0.10)(ai@6.0.199(zod@4.3.6)) + workers-ai-provider: 4.0.0(@ai-sdk/anthropic@4.0.24(zod@4.3.6))(@ai-sdk/openai@4.0.24(zod@4.3.6))(@ai-sdk/provider@4.0.4)(ai@6.0.199(zod@4.3.6)) yargs: 18.0.0 zod: 4.3.6 optionalDependencies: @@ -6644,13 +6707,13 @@ snapshots: optionalDependencies: workerd: 1.20260625.1 - '@cloudflare/vite-plugin@1.42.3(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260611.1))': + '@cloudflare/vite-plugin@1.42.3(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260702.1))': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260625.1) miniflare: 4.20260625.0 unenv: 2.0.0-rc.24 vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) - wrangler: 4.105.0(@cloudflare/workers-types@4.20260611.1) + wrangler: 4.105.0(@cloudflare/workers-types@4.20260702.1) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -6687,9 +6750,9 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260704.1': optional: true - '@cloudflare/workers-oauth-provider@0.4.0': {} + '@cloudflare/workers-oauth-provider@0.10.2': {} - '@cloudflare/workers-types@4.20260611.1': {} + '@cloudflare/workers-types@4.20260702.1': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -6783,7 +6846,7 @@ snapshots: effect: 4.0.0-beta.93 ioredis: 5.11.1 mime: 4.1.0 - undici: 7.28.0 + undici: 7.29.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -7328,7 +7391,21 @@ snapshots: '@mixmark-io/domino@2.2.0': {} - '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + jose: 6.1.3 + pkce-challenge: 5.0.1 + zod: 4.4.3 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.4.3 + + '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27) ajv: 8.20.0 @@ -7352,7 +7429,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@mongodb-js/saslprep@1.4.11': + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.4.3 + + '@mongodb-js/saslprep@1.4.13': dependencies: sparse-bitfield: 3.0.3 optional: true @@ -8552,6 +8634,8 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@workflow/serde@4.1.0': {} + '@workflow/serde@4.1.0-beta.2': {} abort-controller@3.0.0: @@ -8569,18 +8653,19 @@ snapshots: dependencies: humanize-ms: 1.2.1 - agents@0.17.3(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260611.1)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6): + agents@0.20.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(@cloudflare/workers-types@4.20260702.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6): dependencies: '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@7.29.7) '@cfworker/json-schema': 4.1.1 - '@cloudflare/codemode': 0.4.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6) - '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + '@modelcontextprotocol/client': 2.0.0 + '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + '@modelcontextprotocol/server': 2.0.0 '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.2)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) cron-schedule: 6.0.0 esbuild: 0.28.1 mimetext: 3.0.28 nanoid: 5.1.16 - partyserver: 0.5.8(@cloudflare/workers-types@4.20260611.1) + partyserver: 0.5.8(@cloudflare/workers-types@4.20260702.1) partysocket: 1.3.0(react@19.2.4) react: 19.2.4 yaml: 2.9.0 @@ -8588,6 +8673,7 @@ snapshots: zod: 4.3.6 optionalDependencies: '@ai-sdk/react': 3.0.211(react@19.2.4)(zod@4.3.6) + '@cloudflare/codemode': 0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6) ai: 6.0.199(zod@4.3.6) chat: 4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6) just-bash: 3.0.2 @@ -8598,7 +8684,6 @@ snapshots: - '@babel/runtime' - '@cloudflare/workers-types' - rolldown - - supports-color ai@6.0.199(zod@4.3.6): dependencies: @@ -8623,11 +8708,11 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.61(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@22.19.11)(@types/react@19.2.14)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(effect@4.0.0-beta.93)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260625.1)(ws@8.21.0): + alchemy@2.0.0-beta.61(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@22.19.11)(@types/react@19.2.14)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(effect@4.0.0-beta.93)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260625.1)(ws@8.21.0): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1080.0 @@ -8664,12 +8749,12 @@ snapshots: picomatch: 4.0.4 react: 19.2.4 rolldown: 1.0.1 - undici: 7.28.0 + undici: 7.29.0 yaml: 2.9.0 optionalDependencies: '@effect/platform-node': 4.0.0-beta.93(effect@4.0.0-beta.93)(ioredis@5.11.1) drizzle-kit: 0.31.10 - drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1) vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) ws: 8.21.0 transitivePeerDependencies: @@ -8704,13 +8789,13 @@ snapshots: auto-bind@5.0.1: {} - autumn-js@1.2.33(better-auth@1.6.22(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(better-call@1.3.7(zod@4.3.6))(express@5.2.1)(hono@4.12.27)(react@19.2.4): + autumn-js@1.2.33(better-auth@1.6.22(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(better-call@1.3.7(zod@4.3.6))(express@5.2.1)(hono@4.12.27)(react@19.2.4): dependencies: query-string: 9.3.1 rou3: 0.6.3 zod: 4.3.6 optionalDependencies: - better-auth: 1.6.22(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + better-auth: 1.6.22(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) better-call: 1.3.7(zod@4.3.6) express: 5.2.1 hono: 4.12.27 @@ -8748,15 +8833,15 @@ snapshots: before-after-hook@4.0.0: {} - better-auth@1.6.22(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): + better-auth@1.6.22(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) - '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1)) - '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(kysely@0.29.2) - '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0)) - '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260611.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1)) + '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(kysely@0.29.2) + '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0)) + '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.1.1 @@ -8770,7 +8855,7 @@ snapshots: optionalDependencies: '@tanstack/react-start': 1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) drizzle-kit: 0.31.10 - drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1) mongodb: 7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0) mysql2: 3.22.6(@types/node@22.19.11) pg: 8.22.0 @@ -8818,7 +8903,7 @@ snapshots: bowser@2.14.1: {} - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -8932,7 +9017,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.28.0 + undici: 7.29.0 whatwg-mimetype: 4.0.0 chokidar@5.0.0: @@ -9166,9 +9251,9 @@ snapshots: esbuild: 0.25.12 tsx: 4.22.4 - drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1): + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1): optionalDependencies: - '@cloudflare/workers-types': 4.20260611.1 + '@cloudflare/workers-types': 4.20260702.1 '@libsql/client': 0.15.15 '@opentelemetry/api': 1.9.1 kysely: 0.29.2 @@ -9369,7 +9454,7 @@ snapshots: express-rate-limit@8.5.1(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.2.0 + ip-address: 10.3.1 express@5.2.1: dependencies: @@ -9422,7 +9507,7 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-uri@3.1.2: {} + fast-uri@3.1.5: {} fast-xml-builder@1.2.0: dependencies: @@ -9580,7 +9665,7 @@ snapshots: h3@2.0.1-rc.20: dependencies: rou3: 0.8.1 - srvx: 0.11.17 + srvx: 0.11.22 has-property-descriptors@1.0.2: dependencies: @@ -9722,7 +9807,7 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.2.0: {} + ip-address@10.3.1: {} ipaddr.js@1.9.1: {} @@ -9799,7 +9884,7 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -10425,7 +10510,7 @@ snapshots: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.34.5 - undici: 7.28.0 + undici: 7.29.0 workerd: 1.20260625.1 ws: 8.21.0 youch: 4.1.0-beta.10 @@ -10435,7 +10520,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 minimist@1.2.8: {} @@ -10448,7 +10533,7 @@ snapshots: modern-tar@0.7.6: {} - mongodb-connection-string-url@7.0.1: + mongodb-connection-string-url@7.0.2: dependencies: '@types/whatwg-url': 13.0.0 whatwg-url: 14.2.0 @@ -10456,9 +10541,9 @@ snapshots: mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0): dependencies: - '@mongodb-js/saslprep': 1.4.11 + '@mongodb-js/saslprep': 1.4.13 bson: 7.3.1 - mongodb-connection-string-url: 7.0.1 + mongodb-connection-string-url: 7.0.2 optionalDependencies: '@aws-sdk/credential-providers': 3.1080.0 '@mongodb-js/zstd': 7.0.0 @@ -10500,9 +10585,7 @@ snapshots: dependencies: lru.min: 1.1.4 - nanoid@3.3.15: {} - - nanoid@5.1.11: {} + nanoid@3.3.16: {} nanoid@5.1.16: {} @@ -10654,9 +10737,9 @@ snapshots: parseurl@1.3.3: {} - partyserver@0.5.8(@cloudflare/workers-types@4.20260611.1): + partyserver@0.5.8(@cloudflare/workers-types@4.20260702.1): dependencies: - '@cloudflare/workers-types': 4.20260611.1 + '@cloudflare/workers-types': 4.20260702.1 nanoid: 5.1.16 partysocket@1.3.0(react@19.2.4): @@ -10738,9 +10821,9 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.15: + postcss@8.5.25: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -11124,8 +11207,14 @@ snapshots: dependencies: seroval: 1.5.4 + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + seroval@1.5.4: {} + seroval@1.5.6: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -11247,8 +11336,8 @@ snapshots: solid-js@1.9.11: dependencies: csstype: 3.2.3 - seroval: 1.5.4 - seroval-plugins: 1.5.4(seroval@1.5.4) + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: @@ -11285,6 +11374,8 @@ snapshots: srvx@0.11.17: {} + srvx@0.11.22: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -11493,7 +11584,7 @@ snapshots: undici-types@6.21.0: {} - undici@7.28.0: {} + undici@7.29.0: {} unenv@2.0.0-rc.24: dependencies: @@ -11627,7 +11718,7 @@ snapshots: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.25 rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: @@ -11755,15 +11846,15 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260704.1 '@cloudflare/workerd-windows-64': 1.20260704.1 - workers-ai-provider@3.3.0(@ai-sdk/anthropic@3.0.85(zod@4.3.6))(@ai-sdk/openai@3.0.74(zod@4.3.6))(@ai-sdk/provider@3.0.10)(ai@6.0.199(zod@4.3.6)): + workers-ai-provider@4.0.0(@ai-sdk/anthropic@4.0.24(zod@4.3.6))(@ai-sdk/openai@4.0.24(zod@4.3.6))(@ai-sdk/provider@4.0.4)(ai@6.0.199(zod@4.3.6)): dependencies: - '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider': 4.0.4 ai: 6.0.199(zod@4.3.6) optionalDependencies: - '@ai-sdk/anthropic': 3.0.85(zod@4.3.6) - '@ai-sdk/openai': 3.0.74(zod@4.3.6) + '@ai-sdk/anthropic': 4.0.24(zod@4.3.6) + '@ai-sdk/openai': 4.0.24(zod@4.3.6) - wrangler@4.105.0(@cloudflare/workers-types@4.20260611.1): + wrangler@4.105.0(@cloudflare/workers-types@4.20260702.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260625.1) @@ -11774,7 +11865,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260625.1 optionalDependencies: - '@cloudflare/workers-types': 4.20260611.1 + '@cloudflare/workers-types': 4.20260702.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil @@ -11797,7 +11888,7 @@ snapshots: '@oozcitak/dom': 2.0.2 '@oozcitak/infra': 2.0.2 '@oozcitak/util': 10.0.0 - js-yaml: 4.3.0 + js-yaml: 4.3.1 xtend@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e2487c1..b7b8c5a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,15 @@ minimumReleaseAge: 11520 minimumReleaseAgeExclude: - "@every-app/*" + # TEMPORARY (remove after 2026-08-15): workers-oauth-provider 0.10.2 was + # published 2026-08-07; needed for the MCP SDK v2 migration plus the + # ChatGPT DCR client-auth-negotiation fix. + - "@cloudflare/workers-oauth-provider" + # TEMPORARY (remove after 2026-08-09): security releases published + # 2026-07-30/31; required to clear current high-severity advisories. + - "brace-expansion" + - "fast-uri" + - "js-yaml" # TEMPORARY (remove after 2026-07-16): alchemy 2.0.0-beta.61 and its # matching @distilled.cloud/*@0.28.2 published 2026-07-08 — 2 days shy of # the window. Exact-pinned in package.json. @@ -27,23 +36,29 @@ auditConfig: overrides: # GHSA-4x5r-pxfx-6jf8 — waiting on @tanstack/devtools-vite "@babel/core": "^7.29.6" + # GHSA-3jxr-9vmj-r5cp, GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895 — waiting on just-bash + brace-expansion: "^5.0.9" # GHSA-737v-mqg7-c878 — waiting on better-auth defu: "^6.1.5" # 13 advisories incl. GHSA-x4vx-rjvf-j5p4, GHSA-gvmj-g25r-r7wr — waiting on posthog-js dompurify: "^3.4.11" - # GHSA-hmw2-7cc7-3qxx — waiting on cloudflare - form-data: "^4.0.6" + # GHSA-v2hh-gcrm-f6hx, GHSA-7p8r-x3mc-p8w7, GHSA-4c8g-83qw-93j6 — waiting on @modelcontextprotocol/sdk + fast-uri: "^3.1.5" # 9 advisories incl. GHSA-88fw-hqm2-52qc (CORS) — waiting on @modelcontextprotocol/sdk hono: "^4.12.25" + # GHSA-mwp4-54f8-5fhr — waiting on @modelcontextprotocol/sdk + ip-address: "^10.3.1" + # GHSA-5p4m-2wfm-xmqj — waiting on @tanstack/start-plugin-core + js-yaml: "^4.3.1" # GHSA-v6wh-96g9-6wx3 — waiting on @tanstack/devtools-vite launch-editor: "^2.14.1" - # GHSA-qx2v-qp2m-jg93 — waiting on vite - postcss: "^8.5.10" + # GHSA-qx2v-qp2m-jg93, GHSA-r28c-9q8g-f849 — waiting on vite + postcss: "^8.5.18" # GHSA-q8mj-m7cp-5q26 — waiting on @modelcontextprotocol/sdk qs: "^6.15.2" # GHSA-w7jw-789q-3m8p — waiting on @tanstack/devtools-vite shell-quote: "^1.8.4" # GHSA-v3rj-xjv7-4jmq — waiting on knip smol-toml: "^1.6.1" - # 7 advisories incl. GHSA-vmh5-mc38-953g — waiting on cheerio + miniflare - undici: "^7.28.0" + # 8 advisories incl. GHSA-vmh5-mc38-953g, GHSA-4cwx-7wf7-3272 — waiting on cheerio + miniflare + undici: "^7.29.0" diff --git a/src/server.ts b/src/server.ts index 5132a93..854e22b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -181,13 +181,32 @@ export { SamChatAgent } from "./server/features/sam/SamChatAgent"; // Durable Object class for the per-audit crawl scratchpad. export { AuditScratchpad } from "./server/features/audit/AuditScratchpad"; +// Daily OAuth KV garbage collection; must match a trigger in wrangler.jsonc. +const MCP_OAUTH_PURGE_CRON = "17 3 * * *"; + export default { fetch, async scheduled( - _controller: ScheduledController, + controller: ScheduledController, env: Env, _ctx: ExecutionContext, ) { + if (controller.cron === MCP_OAUTH_PURGE_CRON) { + // Only hosted mode runs the OAuth provider (and has OAUTH_KV bound). + if (isHostedAuthMode(getAuthMode(env.AUTH_MODE))) { + const result = await openSeoOAuthProvider.purgeExpiredData( + env as OpenSeoOAuthEnv, + ); + console.log("[mcp-oauth] purged expired OAuth data", result); + if (!result.done) { + // The sweep only advances past live records via deletions; a + // persistent incomplete scan means the keyspace outgrew the batch. + console.warn("[mcp-oauth] purge did not cover the full keyspace"); + } + } + return; + } + // Watchdog first: reconcile audits stuck in "running" whose workflow died // without reaching mark-failed (OOM/CPU kills, expired instances). Runs // before the rank loop so a slow tick can't delay or starve it. Its diff --git a/src/server/features/sam/SamChatAgent.ts b/src/server/features/sam/SamChatAgent.ts index 97908e9..fce0808 100644 --- a/src/server/features/sam/SamChatAgent.ts +++ b/src/server/features/sam/SamChatAgent.ts @@ -29,7 +29,7 @@ import { } from "@/server/billing/subscription"; import { getPublicOrigin } from "@/server/mcp/public-origin"; import { MCP_SCOPE } from "@/lib/oauth-resource"; -import { buildFirstPartyMcpAuthContext } from "@/server/mcp/context"; +import type { ToolAuthContext } from "@/server/mcp/context"; // SAM's writable context blocks, backed by sam_project_memory rows shared by // every chat session in the project. @@ -255,13 +255,14 @@ export class SamChatAgent extends Think { const baseUrl = (await this.ctx.storage.get(PUBLIC_ORIGIN_KEY)) ?? "https://app.openseo.so"; - const authContext = buildFirstPartyMcpAuthContext({ + const authContext: ToolAuthContext = { userId: ctx.row.userId, userEmail: ctx.userEmail, organizationId, baseUrl, + clientId: null, scopes: [MCP_SCOPE], - }); + }; return { tools: buildSamMcpTools(authContext, { diff --git a/src/server/features/sam/samChatTools.ts b/src/server/features/sam/samChatTools.ts index b2c182d..8bfdefe 100644 --- a/src/server/features/sam/samChatTools.ts +++ b/src/server/features/sam/samChatTools.ts @@ -1,12 +1,9 @@ import { tool, type Tool, type ToolSet } from "ai"; import { z, type ZodRawShape } from "zod"; import { withPgClient } from "@/db"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { - createWorkersOAuthMcpProps, - type McpToolAuthContext, - type ToolExtra, -} from "@/server/mcp/context"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { type ToolAuthContext, type ToolContext } from "@/server/mcp/context"; +import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation"; import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview"; import { getBacklinksProfileTool } from "@/server/mcp/tools/get-backlinks-profile"; import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions"; @@ -41,10 +38,11 @@ const SAM_MAX_MAPPED_URLS = 60; // the exact same definitions the MCP server registers, so the in-app agent and // the MCP server can never drift in what a tool does or how it bills. type McpToolDefinition = { + name: string; config: { description: string; inputSchema: Shape }; handler: ( args: z.infer>, - extra: ToolExtra, + context: ToolContext, ) => Promise; }; @@ -62,9 +60,11 @@ function toModelOutput(result: CallToolResult): unknown { : { summary }; } -// Adapt one MCP tool into an AI SDK tool. The MCP handler reads auth from `extra` -// (via requireMcpToolAuthContext) and self-gates project access against the org, -// so SAM gets identical scoping and metering for free. +// Adapt one OpenSEO tool into an AI SDK tool. The shared handler receives the +// same explicit auth context as the MCP transport, and runs through the same +// instrumentation wrapper, so project scoping, credit metering, and the +// mcp:tool_call telemetry (source "in_app_agent", null clientId) all match the +// external MCP path. // // SAM always runs inside one project (the session row), so we bind that project // server-side: any tool with a `projectId` input has it stripped from the schema @@ -72,11 +72,12 @@ function toModelOutput(result: CallToolResult): unknown { // the id, can't target another project, and can't hallucinate a wrong one. function adaptMcpTool( def: McpToolDefinition, - extra: ToolExtra, + context: ToolContext, projectId: string, ): Tool { const { projectId: _projectIdSchema, ...modelShape } = def.config.inputSchema; const bindsProject = "projectId" in def.config.inputSchema; + const handler = instrumentMcpToolHandler(def.name, undefined, def.handler); return tool({ description: def.config.description, @@ -93,7 +94,7 @@ function adaptMcpTool( // request scope, so each execution scopes its own Postgres client // (no-op in D1 mode) — same rule as the DO's other DB-touching seams. return toModelOutput( - await withPgClient(() => def.handler(fullArgs, extra)), + await withPgClient(() => handler(fullArgs, context)), ); } catch (error) { // Surface the failure to the model so it can recover or report it, @@ -174,32 +175,19 @@ function scrapeTools(projectDomain: string | null): ToolSet { /** * Builds SAM's tool surface as an AI SDK ToolSet: the full MCP toolset plus the * free site-reading tools. Every tool the OpenSEO MCP server exposes is - * available; auth/billing context is carried on a synthetic `ToolExtra` the - * handlers read exactly as they would on the real MCP route. DataForSEO spend - * is metered inside the shared client, so tool calls draw down the org's - * credits automatically. + * available. Auth and billing context are passed directly to the shared tool + * handlers. DataForSEO spend is metered inside the shared client, so tool calls + * draw down the org's credits automatically. */ export function buildSamMcpTools( - authContext: McpToolAuthContext, + authContext: ToolAuthContext, project: { id: string; domain: string | null }, ): ToolSet { const projectId = project.id; - const extra: ToolExtra = { - // Placeholder to satisfy ToolExtra — no tool handler or the DataForSEO - // client reads this signal (true on the real MCP route too), so aborting a - // turn does not cancel in-flight tool requests. - signal: new AbortController().signal, - requestId: 0, - authInfo: { - token: "sam-session", - clientId: authContext.clientId ?? "sam", - scopes: authContext.scopes, - extra: createWorkersOAuthMcpProps(authContext), - }, - sendNotification: () => Promise.resolve(), - sendRequest: () => - Promise.reject(new Error("sendRequest is unsupported in the SAM agent")), - }; + const toolContext: ToolContext = { auth: authContext }; + const adaptTool = ( + definition: McpToolDefinition, + ) => adaptMcpTool(definition, toolContext, projectId); // Note: no `list_projects`. SAM is bound to the session's project, so // discovering other projects isn't part of its job — every project-scoped tool @@ -214,55 +202,23 @@ export function buildSamMcpTools( execute: () => Promise.resolve({ factSheet: openSeoFactSheet }), }), ...scrapeTools(project.domain), - whoami: adaptMcpTool(whoamiTool, extra, projectId), - list_saved_keywords: adaptMcpTool(listSavedKeywordsTool, extra, projectId), - research_keywords: adaptMcpTool(researchKeywordsTool, extra, projectId), - save_keywords: adaptMcpTool(saveKeywordsTool, extra, projectId), - get_domain_overview: adaptMcpTool(getDomainOverviewTool, extra, projectId), - get_domain_keyword_suggestions: adaptMcpTool( - getDomainKeywordSuggestionsTool, - extra, - projectId, - ), - get_backlinks_overview: adaptMcpTool( - getBacklinksOverviewTool, - extra, - projectId, - ), - get_backlinks_profile: adaptMcpTool( - getBacklinksProfileTool, - extra, - projectId, - ), - get_serp_results: adaptMcpTool(getSerpResultsTool, extra, projectId), - get_rank_tracker: adaptMcpTool(getRankTrackerTool, extra, projectId), - get_ranked_keywords: adaptMcpTool(getRankedKeywordsTool, extra, projectId), - find_serp_competitors: adaptMcpTool( - findSerpCompetitorsTool, - extra, - projectId, - ), - search_local_businesses: adaptMcpTool( - searchLocalBusinessesTool, - extra, - projectId, - ), - get_local_serp_results: adaptMcpTool( - getLocalSerpResultsTool, - extra, - projectId, - ), - get_google_business_questions: adaptMcpTool( - getGoogleBusinessQuestionsTool, - extra, - projectId, - ), - get_keyword_metrics: adaptMcpTool(getKeywordMetricsTool, extra, projectId), - get_search_console_performance: adaptMcpTool( - getSearchConsolePerformanceTool, - extra, - projectId, - ), - inspect_urls: adaptMcpTool(inspectUrlsTool, extra, projectId), + whoami: adaptTool(whoamiTool), + list_saved_keywords: adaptTool(listSavedKeywordsTool), + research_keywords: adaptTool(researchKeywordsTool), + save_keywords: adaptTool(saveKeywordsTool), + get_domain_overview: adaptTool(getDomainOverviewTool), + get_domain_keyword_suggestions: adaptTool(getDomainKeywordSuggestionsTool), + get_backlinks_overview: adaptTool(getBacklinksOverviewTool), + get_backlinks_profile: adaptTool(getBacklinksProfileTool), + get_serp_results: adaptTool(getSerpResultsTool), + get_rank_tracker: adaptTool(getRankTrackerTool), + get_ranked_keywords: adaptTool(getRankedKeywordsTool), + find_serp_competitors: adaptTool(findSerpCompetitorsTool), + search_local_businesses: adaptTool(searchLocalBusinessesTool), + get_local_serp_results: adaptTool(getLocalSerpResultsTool), + get_google_business_questions: adaptTool(getGoogleBusinessQuestionsTool), + get_keyword_metrics: adaptTool(getKeywordMetricsTool), + get_search_console_performance: adaptTool(getSearchConsolePerformanceTool), + inspect_urls: adaptTool(inspectUrlsTool), }; } diff --git a/src/server/mcp/context.test.ts b/src/server/mcp/context.test.ts index 676f480..f29004d 100644 --- a/src/server/mcp/context.test.ts +++ b/src/server/mcp/context.test.ts @@ -1,43 +1,75 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + props: {} as Record, +})); + +vi.mock("agents/mcp/server", () => ({ + getMcpAuthContext: () => ({ props: mocks.props }), +})); + import { + createMcpToolContext, createWorkersOAuthMcpProps, MCP_AUTH_CONTEXT_PROP, - withWorkersOAuthMcpScopes, workersOAuthMcpPropsSchema, } from "@/server/mcp/context"; -const mcpContext = { +const applicationContext = { userId: "user_123", userEmail: "alice@example.com", organizationId: "org_123", - clientId: "client_123", - scopes: ["offline_access", "mcp"], - audience: "https://open-seo.test/mcp", - subject: "user_123", baseUrl: "https://open-seo.test", }; -describe("withWorkersOAuthMcpScopes", () => { - it("stores the OpenSEO MCP context in Workers OAuth props", () => { - const props = createWorkersOAuthMcpProps(mcpContext); +describe("OpenSEO tool auth context", () => { + it("stores only application-specific identity in Workers OAuth props", () => { + const props = createWorkersOAuthMcpProps(applicationContext); expect(workersOAuthMcpPropsSchema.parse(props)).toEqual({ - [MCP_AUTH_CONTEXT_PROP]: mcpContext, + [MCP_AUTH_CONTEXT_PROP]: applicationContext, }); }); - it("updates access-token props with downscoped token scopes", () => { - const props = createWorkersOAuthMcpProps(mcpContext); + it("rejects unrecognized provider props", () => { + expect(workersOAuthMcpPropsSchema.safeParse({}).success).toBe(false); + }); - expect(withWorkersOAuthMcpScopes(props, ["mcp"])).toEqual({ - [MCP_AUTH_CONTEXT_PROP]: { - ...mcpContext, - scopes: ["mcp"], - }, + it("prefers standard OAuth client metadata over the props fallback", () => { + mocks.props = createWorkersOAuthMcpProps({ + ...applicationContext, + clientId: "stale-client", + scopes: ["offline_access"], + }); + + expect( + createMcpToolContext({ + http: { + authInfo: { + token: "access-token", + clientId: "client-1", + scopes: ["mcp"], + }, + }, + }).auth, + ).toMatchObject({ + ...applicationContext, + clientId: "client-1", + scopes: ["mcp"], }); }); - it("leaves unrecognized provider props alone", () => { - expect(withWorkersOAuthMcpScopes({}, ["mcp"])).toBeUndefined(); + it("falls back to encrypted props with workers-oauth-provider 0.10", () => { + mocks.props = createWorkersOAuthMcpProps({ + ...applicationContext, + clientId: "client-1", + scopes: ["mcp"], + }); + + expect(createMcpToolContext({}).auth).toMatchObject({ + ...applicationContext, + clientId: "client-1", + scopes: ["mcp"], + }); }); }); diff --git a/src/server/mcp/context.ts b/src/server/mcp/context.ts index bfa8e4d..27d590d 100644 --- a/src/server/mcp/context.ts +++ b/src/server/mcp/context.ts @@ -1,123 +1,92 @@ -import type { - ServerNotification, - ServerRequest, -} from "@modelcontextprotocol/sdk/types.js"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import { AsyncLocalStorage } from "node:async_hooks"; +import type { ServerContext } from "@modelcontextprotocol/server"; +import { getMcpAuthContext } from "agents/mcp/server"; import { z } from "zod"; import type { BillingCustomerContext } from "@/server/billing/subscription"; -import { getMcpResource } from "@/lib/oauth-resource"; import { buildDashboardUrl } from "@/server/mcp/urls"; -type McpAuth = { +export type ToolAuthContext = { userId: string; userEmail: string; organizationId: string; scopes: string[]; clientId: string | null; - audience: string; - subject: string; + baseUrl: string; +}; + +export type ToolContext = { + auth: ToolAuthContext; }; export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth"; export const MCP_ROUTE = "/mcp"; -const mcpToolAuthContextSchema = z.object({ +const applicationAuthContextSchema = z.object({ userId: z.string().min(1), userEmail: z.string().min(1), organizationId: z.string().min(1), - clientId: z.string().nullable(), - scopes: z.array(z.string()), - audience: z.string().min(1), - subject: z.string().min(1), baseUrl: z.string().url(), + // Compatibility fallback until workers-oauth-provider supplies the verified + // context marker consumed by Agents SDK 0.20.x (the + // cloudflare.workers-oauth-provider.verified-context.v1 symbol, which mints + // context.http.authInfo — watch the provider changelog). Once it ships, + // delete these two fields and the fallback in createMcpToolContext, and read + // clientId/scopes in transport.ts from authInfo instead of props. + clientId: z.string().min(1).nullable().optional(), + scopes: z.array(z.string()).optional(), }); -export type McpToolAuthContext = z.infer; - -export type ToolExtra = RequestHandlerExtra; +type ApplicationAuthContext = z.infer; export const workersOAuthMcpPropsSchema = z.object({ - [MCP_AUTH_CONTEXT_PROP]: mcpToolAuthContextSchema, + [MCP_AUTH_CONTEXT_PROP]: applicationAuthContextSchema, }); -const mcpToolAuthContextStorage = new AsyncLocalStorage(); - -/** - * Auth context for first-party (non-OAuth) callers — the self-hosted MCP - * transport and the SAM agent. Centralizes the invariants both sites relied - * on by convention: `subject` is the user id, `clientId` is null, and the - * audience derives from the base URL. - */ -export function buildFirstPartyMcpAuthContext(input: { - userId: string; - userEmail: string; - organizationId: string; - baseUrl: string; - scopes?: string[]; -}): McpToolAuthContext { - return { - userId: input.userId, - userEmail: input.userEmail, - organizationId: input.organizationId, - clientId: null, - scopes: input.scopes ?? [], - audience: getMcpResource(input.baseUrl), - subject: input.userId, - baseUrl: input.baseUrl, - }; -} +// The hosted /mcp route only ever sees provider-minted tokens, whose props +// always carry the OAuth client identity — require it so scope enforcement +// fails closed instead of silently degrading to first-party. +export const hostedWorkersOAuthMcpPropsSchema = z.object({ + [MCP_AUTH_CONTEXT_PROP]: applicationAuthContextSchema.extend({ + clientId: z.string().min(1), + scopes: z.array(z.string()), + }), +}); export function createWorkersOAuthMcpProps( - context: McpToolAuthContext, -): Record { + context: ApplicationAuthContext, +): Record { return { [MCP_AUTH_CONTEXT_PROP]: context, }; } -export function withWorkersOAuthMcpScopes( - props: unknown, - scopes: string[], -): Record | undefined { - const result = workersOAuthMcpPropsSchema.safeParse(props); - if (!result.success) return undefined; - - return createWorkersOAuthMcpProps({ - ...result.data[MCP_AUTH_CONTEXT_PROP], - scopes, - }); -} - -export function runWithMcpToolAuthContext( - context: McpToolAuthContext, - callback: () => T, -) { - return mcpToolAuthContextStorage.run(context, callback); -} - -export function requireMcpToolAuthContext( - extra: ToolExtra, -): McpToolAuthContext { - const rawContext = - mcpToolAuthContextStorage.getStore() ?? - extra.authInfo?.extra?.[MCP_AUTH_CONTEXT_PROP]; - const result = mcpToolAuthContextSchema.safeParse(rawContext); - +export function createMcpToolContext( + context: Pick, +): ToolContext { + const result = workersOAuthMcpPropsSchema.safeParse( + getMcpAuthContext()?.props, + ); if (!result.success) { throw new Error(`MCP auth context missing: ${result.error.message}`); } - return result.data; -} + // Scope enforcement happens once, at the hosted transport boundary + // (handleAuthenticatedOpenSeoMcpRequest); this only assembles identity. + const applicationAuth = result.data[MCP_AUTH_CONTEXT_PROP]; + const authInfo = context.http?.authInfo; + const clientId = authInfo?.clientId ?? applicationAuth.clientId ?? null; + const scopes = authInfo?.scopes ?? applicationAuth.scopes ?? []; -export function getAuth(extra: ToolExtra): McpAuth { - const { baseUrl: _baseUrl, ...auth } = requireMcpToolAuthContext(extra); - return auth; + return { + auth: { + ...applicationAuth, + clientId, + scopes, + }, + }; } export function buildBillingCustomer( - auth: McpAuth, + auth: Pick, projectId: string, ): BillingCustomerContext { return { @@ -129,7 +98,10 @@ export function buildBillingCustomer( } export function buildProjectMeta( - context: { auth: Pick; baseUrl: string }, + context: { + auth: Pick; + baseUrl: string; + }, projectId: string, path?: string, params?: Record, diff --git a/src/server/mcp/formatters.ts b/src/server/mcp/formatters.ts index c7623b4..50c7e2a 100644 --- a/src/server/mcp/formatters.ts +++ b/src/server/mcp/formatters.ts @@ -1,4 +1,4 @@ -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/server"; type McpResponseMeta = { url?: string; @@ -9,6 +9,22 @@ type McpResponseMeta = { creditsRemaining?: number; }; +// The generic overload preserves each tool's concrete structuredContent shape +// so tests (and callers) can access fields without casting. The type parameter +// appears in exactly one position — a required `structuredContent: T` — which +// is what makes inference work; a `T` shared between an optional field and an +// intersection member collapses to `{}`. +export function mcpResponse>(opts: { + text: string; + meta?: McpResponseMeta; + structuredContent: T; +}): CallToolResult & { + structuredContent: T & { meta?: Record }; +}; +export function mcpResponse(opts: { + text: string; + meta?: McpResponseMeta; +}): CallToolResult; export function mcpResponse(opts: { text: string; meta?: McpResponseMeta; diff --git a/src/server/mcp/instrumentation.test.ts b/src/server/mcp/instrumentation.test.ts index 9d20212..5a2c392 100644 --- a/src/server/mcp/instrumentation.test.ts +++ b/src/server/mcp/instrumentation.test.ts @@ -1,12 +1,8 @@ -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { instrumentMcpToolHandler } from "./instrumentation"; -import { - runWithMcpToolAuthContext, - type McpToolAuthContext, - type ToolExtra, -} from "@/server/mcp/context"; +import { type ToolAuthContext, type ToolContext } from "@/server/mcp/context"; import { AppError } from "@/server/lib/errors"; const mocks = vi.hoisted(() => ({ @@ -36,39 +32,39 @@ vi.mock("@/server/lib/self-host-telemetry", () => ({ incrementSelfHostMcpToolCallCount: mocks.incrementSelfHostMcpToolCallCount, })); -const toolExtra: ToolExtra = { - signal: new AbortController().signal, - requestId: 1, - sendNotification: vi.fn(), - sendRequest: vi.fn(), -}; - -const outputSchema = { items: z.array(z.object({}).passthrough()) }; +const outputSchema = z.object({ + items: z.array(z.object({}).passthrough()), +}); function okResult(structuredContent: Record): CallToolResult { return { content: [{ type: "text", text: "ok" }], structuredContent }; } -const authContext: McpToolAuthContext = { +const authContext: ToolAuthContext = { userId: "user-1", userEmail: "user@example.com", organizationId: "org-1", clientId: "client-1", scopes: ["mcp"], - audience: "https://app.openseo.so/mcp", - subject: "user-1", baseUrl: "https://app.openseo.so", }; +const toolContext: ToolContext = { auth: authContext }; + describe("instrumentMcpToolHandler", () => { - beforeEach(() => {}); + beforeEach(() => { + mocks.captureServerError.mockReset(); + mocks.captureServerEvent.mockReset(); + mocks.recordExternalMcpToolCall.mockReset(); + mocks.incrementSelfHostMcpToolCallCount.mockReset(); + }); it("passes a valid result through without reporting", async () => { const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => okResult({ items: [{ domain: "example.com" }] }), ); - const result = await wrapped({}, toolExtra); + const result = await wrapped({}, toolContext); expect(result.structuredContent).toEqual({ items: [{ domain: "example.com" }], @@ -81,7 +77,7 @@ describe("instrumentMcpToolHandler", () => { okResult({ items: "not-an-array" }), ); - await wrapped({}, toolExtra); + await wrapped({}, toolContext); expect(mocks.captureServerError).toHaveBeenCalledTimes(1); expect(mocks.captureServerError.mock.calls[0][1]).toMatchObject({ @@ -96,7 +92,7 @@ describe("instrumentMcpToolHandler", () => { throw boom; }); - await expect(wrapped({}, toolExtra)).rejects.toThrow("upstream exploded"); + await expect(wrapped({}, toolContext)).rejects.toThrow("upstream exploded"); expect(mocks.captureServerError).toHaveBeenCalledTimes(1); expect(mocks.captureServerError.mock.calls[0][0]).toBe(boom); }); @@ -106,16 +102,16 @@ describe("instrumentMcpToolHandler", () => { throw new AppError("NOT_FOUND"); }); - await expect(wrapped({}, toolExtra)).rejects.toThrow("NOT_FOUND"); + await expect(wrapped({}, toolContext)).rejects.toThrow("NOT_FOUND"); expect(mocks.captureServerError).not.toHaveBeenCalled(); }); - it("captures a usage event when auth context is present", async () => { + it("captures a usage event for every call", async () => { const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => okResult({ items: [] }), ); - await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)); + await wrapped({}, toolContext); expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1); expect(mocks.incrementSelfHostMcpToolCallCount).toHaveBeenCalledTimes(1); @@ -137,7 +133,7 @@ describe("instrumentMcpToolHandler", () => { okResult({ items: "not-an-array" }), ); - await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)); + await wrapped({}, toolContext); expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({ event: "mcp:tool_call", @@ -154,7 +150,7 @@ describe("instrumentMcpToolHandler", () => { okResult({ status: "error", error: { code: "ga4_not_connected" } }), ); - await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)); + await wrapped({}, toolContext); expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({ event: "mcp:tool_call", @@ -172,7 +168,7 @@ describe("instrumentMcpToolHandler", () => { okResult({ ok: false, reason: "audit_not_ready" }), ); - await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)); + await wrapped({}, toolContext); expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({ event: "mcp:tool_call", @@ -186,9 +182,7 @@ describe("instrumentMcpToolHandler", () => { throw new AppError("NOT_FOUND"); }); - await expect( - runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)), - ).rejects.toThrow("NOT_FOUND"); + await expect(wrapped({}, toolContext)).rejects.toThrow("NOT_FOUND"); expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({ event: "mcp:tool_call", @@ -196,23 +190,12 @@ describe("instrumentMcpToolHandler", () => { }); }); - it("skips the usage event when auth context is missing", async () => { - const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => - okResult({ items: [] }), - ); - - await wrapped({}, toolExtra); - - expect(mocks.captureServerEvent).not.toHaveBeenCalled(); - expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled(); - }); - it("records the activation milestone for a successful external call", async () => { const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => okResult({ items: [] }), ); - await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)); + await wrapped({}, toolContext); expect(mocks.recordExternalMcpToolCall).toHaveBeenCalledExactlyOnceWith( "org-1", @@ -224,9 +207,7 @@ describe("instrumentMcpToolHandler", () => { okResult({ items: [] }), ); - await runWithMcpToolAuthContext({ ...authContext, clientId: null }, () => - wrapped({}, toolExtra), - ); + await wrapped({}, { auth: { ...authContext, clientId: null } }); expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled(); }); @@ -236,9 +217,7 @@ describe("instrumentMcpToolHandler", () => { throw new AppError("NOT_FOUND"); }); - await expect( - runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)), - ).rejects.toThrow("NOT_FOUND"); + await expect(wrapped({}, toolContext)).rejects.toThrow("NOT_FOUND"); expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled(); }); diff --git a/src/server/mcp/instrumentation.ts b/src/server/mcp/instrumentation.ts index c98ea4a..e6a2076 100644 --- a/src/server/mcp/instrumentation.ts +++ b/src/server/mcp/instrumentation.ts @@ -1,38 +1,38 @@ import { waitUntil } from "cloudflare:workers"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { - getParseErrorMessage, - normalizeObjectSchema, - safeParseAsync, - type AnySchema, - type ZodRawShapeCompat, -} from "@modelcontextprotocol/sdk/server/zod-compat.js"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import type { z } from "zod"; import { asAppError } from "@/server/lib/errors"; import { recordExternalMcpToolCall } from "@/server/features/activation/mcpActivation"; import { captureServerError, captureServerEvent } from "@/server/lib/posthog"; import { shouldCaptureAppErrorCode } from "@/shared/error-codes"; -import { getAuth, type ToolExtra } from "@/server/mcp/context"; +import { type ToolContext } from "@/server/mcp/context"; import { incrementSelfHostMcpToolCallCount } from "@/server/lib/self-host-telemetry"; type ToolHandler = ( args: TArgs, - extra: ToolExtra, + context: ToolContext, ) => CallToolResult | Promise; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function formatValidationIssues(error: z.ZodError) { + return error.issues + .map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`) + .join("; ") + .slice(0, 500); +} + /** * Usage analytics for every MCP tool invocation. `clientId` distinguishes * external MCP clients (OAuth) from the in-app agent (first-party auth, null * clientId); self-hosted installs never report because captureServerEvent is - * gated to hosted mode. Analytics must never affect the tool call, so a - * missing auth context (e.g. in tests) is swallowed. + * gated to hosted mode. */ function captureMcpToolCall( toolName: string, - extra: ToolExtra, + context: ToolContext, outcome: { success: boolean; errorCode?: string; @@ -44,29 +44,25 @@ function captureMcpToolCall( ) { waitUntil(incrementSelfHostMcpToolCallCount()); - try { - const auth = getAuth(extra); - waitUntil( - captureServerEvent({ - distinctId: auth.userId, - event: "mcp:tool_call", - organizationId: auth.organizationId, - properties: { - tool: toolName, - success: outcome.success, - error_code: outcome.errorCode, - client_id: auth.clientId, - source: auth.clientId ? "mcp_client" : "in_app_agent", - duration_ms: outcome.durationMs, - project_id: outcome.projectId, - row_count: outcome.rowCount, - quota_remaining: outcome.quotaRemaining, - }, - }), - ); - } catch { - // no auth context — skip analytics - } + const auth = context.auth; + waitUntil( + captureServerEvent({ + distinctId: auth.userId, + event: "mcp:tool_call", + organizationId: auth.organizationId, + properties: { + tool: toolName, + success: outcome.success, + error_code: outcome.errorCode, + client_id: auth.clientId, + source: auth.clientId ? "mcp_client" : "in_app_agent", + duration_ms: outcome.durationMs, + project_id: outcome.projectId, + row_count: outcome.rowCount, + quota_remaining: outcome.quotaRemaining, + }, + }), + ); } /** @@ -80,51 +76,43 @@ function captureMcpToolCall( * - Output-schema validation failures. The SDK validates structuredContent * against the output schema *after* the handler returns and converts a * failure into a -32602 JSON-RPC error it never rethrows, so we re-run the - * same validation (via the SDK's own helpers) to surface the mismatch - * instead of shipping it silently. + * same validation to surface the mismatch instead of shipping it silently. */ export function instrumentMcpToolHandler( toolName: string, - outputSchema: AnySchema | ZodRawShapeCompat | undefined, + outputSchema: z.ZodType | undefined, handler: ToolHandler, -): (args: TArgs, extra: ToolExtra) => Promise { - const normalizedOutputSchema = normalizeObjectSchema(outputSchema); - - return async (args, extra) => { +): (args: TArgs, context: ToolContext) => Promise { + return async (args, context) => { const startedAt = performance.now(); try { - const result = await handler(args, extra); + const result = await handler(args, context); // The SDK converts an output-schema mismatch into a client-visible // JSON-RPC error, so count it as a failed call, not a success. let outputValidationFailed = false; - if ( - normalizedOutputSchema && - !result.isError && - result.structuredContent - ) { - const validation = await safeParseAsync( - normalizedOutputSchema, + if (outputSchema && !result.isError && result.structuredContent) { + const validation = await outputSchema.safeParseAsync( result.structuredContent, ); if (!validation.success) { outputValidationFailed = true; - // getParseErrorMessage reports type-level mismatches (expected vs - // received *types*), so it carries no row data. Keep it that way: - // output schemas must not gain value-echoing refinements (enums on - // user data, etc.) that would surface response values in PostHog. + // Keep this type-level and privacy-safe: output schemas must not gain + // value-echoing refinements that would surface response data here. waitUntil( captureServerError( new Error(`MCP output validation failed for ${toolName}`), { errorCode: "MCP_OUTPUT_VALIDATION", tool: toolName, - issues: getParseErrorMessage(validation.error).slice(0, 500), + issues: formatValidationIssues(validation.error), }, ), ); } } - const structured = result.structuredContent; + const structured = isRecord(result.structuredContent) + ? result.structuredContent + : undefined; const returnedFailure = structured?.status === "error" || structured?.ok === false; const returnedError = @@ -148,7 +136,7 @@ export function instrumentMcpToolHandler( !result.isError && !outputValidationFailed && !returnedFailure; captureMcpToolCall( toolName, - extra, + context, outputValidationFailed ? { success: false, @@ -179,19 +167,15 @@ export function instrumentMcpToolHandler( // the request's DB scope; a per-isolate memo keeps this off the hot // path after the first call. if (succeeded) { - try { - const auth = getAuth(extra); - if (auth.clientId) { - await recordExternalMcpToolCall(auth.organizationId); - } - } catch { - // no auth context — skip milestone tracking + const auth = context.auth; + if (auth.clientId) { + await recordExternalMcpToolCall(auth.organizationId); } } return result; } catch (error) { const appError = asAppError(error); - captureMcpToolCall(toolName, extra, { + captureMcpToolCall(toolName, context, { success: false, errorCode: appError?.code ?? "INTERNAL_ERROR", durationMs: Math.round(performance.now() - startedAt), diff --git a/src/server/mcp/oauth-provider.test.ts b/src/server/mcp/oauth-provider.test.ts new file mode 100644 index 0000000..a008d73 --- /dev/null +++ b/src/server/mcp/oauth-provider.test.ts @@ -0,0 +1,351 @@ +import { + GrantType, + type OAuthProviderOptions, + type TokenExchangeCallbackOptions, +} from "@cloudflare/workers-oauth-provider"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import type { createOpenSeoOAuthProvider } from "./oauth-provider"; + +const mocks = vi.hoisted(() => ({ + options: [] as OAuthProviderOptions[], + requests: [] as Request[], + purges: [] as unknown[], +})); + +vi.mock("cloudflare:workers", () => ({ + waitUntil: (promise: Promise) => void promise, +})); + +// The whole module is doubled because the real one imports "cloudflare:workers" +// at top level, which vitest's node environment cannot load even via +// importOriginal. The error-class doubles must stay signature-compatible with +// the real AuthorizationError/OAuthError. +vi.mock("@cloudflare/workers-oauth-provider", () => { + class AuthorizationError extends Error { + readonly description: string; + readonly redirectUri?: string; + readonly state?: string; + readonly issuer?: string; + + constructor( + readonly code: string, + options: { + description: string; + redirectUri?: string; + state?: string; + issuer?: string; + }, + ) { + super(options.description); + this.description = options.description; + this.redirectUri = options.redirectUri; + this.state = options.state; + this.issuer = options.issuer; + } + } + + class OAuthError extends Error { + constructor( + readonly code: string, + options?: { description?: string }, + ) { + super(options?.description); + } + } + + return { + AuthorizationError, + GrantType: { REFRESH_TOKEN: "refresh_token" }, + OAuthError, + OAuthProvider: class { + constructor(options: OAuthProviderOptions) { + mocks.options.push(options); + } + + fetch(request: Request) { + mocks.requests.push(request); + return Promise.resolve(new Response(null, { status: 204 })); + } + + purgeExpiredData(env: unknown, options?: { batchSize?: number }) { + mocks.purges.push({ env, options }); + return Promise.resolve({ + grantsChecked: 0, + grantsPurged: 0, + tokensChecked: 0, + tokensPurged: 0, + done: true, + }); + } + }, + }; +}); + +vi.mock("@/lib/auth", () => ({ + getHostedBaseUrl: () => "https://app.openseo.so", +})); + +vi.mock("@/middleware/ensure-user/hosted", () => ({ + resolveHostedContext: vi.fn(), +})); + +vi.mock("@/server/features/activation/mcpActivation", () => ({ + recordMcpAuthorized: vi.fn(), +})); + +vi.mock("@/server/lib/posthog", () => ({ + captureServerEvent: vi.fn(), +})); + +vi.mock("@/server/mcp/transport", () => ({ + handleAuthenticatedOpenSeoMcpRequest: vi.fn(), +})); + +const executionContext = { + props: {}, + waitUntil() {}, + passThroughOnException() {}, +} as ExecutionContext; + +async function dispatch( + provider: ReturnType, + request: Request, +) { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- mocked provider does not read its KV-backed environment in these configuration tests + return provider.fetch(request, {} as never, executionContext); +} + +function tokenExchangeOptions( + requestedScope: string[], +): TokenExchangeCallbackOptions { + return { + grantType: GrantType.REFRESH_TOKEN, + clientId: "client-1", + userId: "user-1", + grantId: "grant-1", + scope: ["offline_access", "mcp"], + requestedScope, + props: { + openSeoAuth: { + userId: "user-1", + userEmail: "user@example.com", + organizationId: "org-1", + baseUrl: "https://app.openseo.so", + clientId: "client-1", + scopes: ["offline_access", "mcp"], + }, + }, + }; +} + +async function invokeDefaultHandler( + request: Request, + env: Record, +) { + const defaultHandler = mocks.options[0]?.defaultHandler; + if ( + !defaultHandler || + typeof defaultHandler !== "object" || + !("fetch" in defaultHandler) || + typeof defaultHandler.fetch !== "function" + ) { + throw new Error("Missing default handler"); + } + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Workers' handler type requires incoming-only CF metadata; this test Request never inspects request.cf + const workerRequest = request as Request< + unknown, + IncomingRequestCfProperties + >; + const rawResponse: unknown = await defaultHandler.fetch( + workerRequest, + env, + executionContext, + ); + if (!(rawResponse instanceof Response)) { + throw new Error("Default handler did not return a response"); + } + return rawResponse; +} + +describe("OpenSEO OAuth provider configuration", () => { + beforeEach(() => { + mocks.options.length = 0; + mocks.requests.length = 0; + mocks.purges.length = 0; + }); + + it("binds tokens and protected-resource metadata to the canonical MCP URL", async () => { + const { createOpenSeoOAuthProvider } = await import("./oauth-provider"); + const provider = createOpenSeoOAuthProvider(() => new Response("app")); + + await dispatch(provider, new Request("https://app.openseo.so/health")); + + expect(mocks.options).toHaveLength(1); + expect(mocks.options[0]?.resourceMetadata).toEqual({ + resource: "https://app.openseo.so/mcp", + scopes_supported: ["mcp"], + resource_name: "OpenSEO MCP", + }); + expect(mocks.options[0]?.scopesSupported).toEqual([ + "offline_access", + "mcp", + ]); + expect(mocks.options[0]?.clientRegistrationTTL).toBe(60 * 60 * 24 * 365); + }); + + it("purges OAuth KV data without needing a prior request", async () => { + const { createOpenSeoOAuthProvider } = await import("./oauth-provider"); + const provider = createOpenSeoOAuthProvider(() => new Response("app")); + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- mocked provider does not read its KV-backed environment + const result = await provider.purgeExpiredData({} as never); + + expect(result.done).toBe(true); + expect(mocks.purges).toHaveLength(1); + // The lazily built provider still pins the hosted resource. + expect(mocks.options[0]?.resourceMetadata).toMatchObject({ + resource: "https://app.openseo.so/mcp", + }); + }); + + it("rejects token exchanges that drop the required MCP scope", async () => { + const { OAuthError } = await import("@cloudflare/workers-oauth-provider"); + const { createOpenSeoOAuthProvider } = await import("./oauth-provider"); + const provider = createOpenSeoOAuthProvider(() => new Response("app")); + + await dispatch(provider, new Request("https://app.openseo.so/health")); + + const callback = mocks.options[0]?.tokenExchangeCallback; + if (!callback) throw new Error("Missing token exchange callback"); + expect(() => + callback(tokenExchangeOptions(["offline_access"])), + ).toThrowError(OAuthError); + expect(callback(tokenExchangeOptions(["mcp"]))).toEqual({ + accessTokenProps: { + openSeoAuth: { + userId: "user-1", + userEmail: "user@example.com", + organizationId: "org-1", + baseUrl: "https://app.openseo.so", + clientId: "client-1", + scopes: ["mcp"], + }, + }, + }); + }); + + it("lets the provider issue Perplexity a real client secret", async () => { + const { createOpenSeoOAuthProvider } = await import("./oauth-provider"); + const provider = createOpenSeoOAuthProvider(() => new Response("app")); + + await dispatch( + provider, + new Request("https://app.openseo.so/api/auth/oauth2/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Perplexity", + redirect_uris: ["https://www.perplexity.ai/api/mcp/oauth/callback"], + }), + }), + ); + + await expect(mocks.requests[0]?.json()).resolves.toMatchObject({ + token_endpoint_auth_method: "client_secret_post", + }); + }); + + it("includes the authorization-server issuer when consent is denied", async () => { + const { createOpenSeoOAuthProvider } = await import("./oauth-provider"); + const provider = createOpenSeoOAuthProvider(() => new Response("app")); + await dispatch(provider, new Request("https://app.openseo.so/health")); + + const response = await invokeDefaultHandler( + new Request("https://app.openseo.so/api/oauth/consent", { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://app.openseo.so", + }, + body: JSON.stringify({ accept: false, query: "state=state-1" }), + }), + { + OAUTH_PROVIDER: { + parseAuthRequest: () => + Promise.resolve({ + clientId: "client-1", + redirectUri: "https://client.example/callback", + scope: ["mcp"], + state: "state-1", + issuer: "https://app.openseo.so", + }), + }, + }, + ); + + const body = z + .object({ redirectTo: z.string().url() }) + .parse(await response.json()); + expect( + Object.fromEntries(new URL(body.redirectTo).searchParams), + ).toMatchObject({ + error: "access_denied", + state: "state-1", + iss: "https://app.openseo.so", + }); + }); + + it("redirects safe authorization errors with state and issuer", async () => { + const { AuthorizationError } = + await import("@cloudflare/workers-oauth-provider"); + const { createOpenSeoOAuthProvider } = await import("./oauth-provider"); + const provider = createOpenSeoOAuthProvider(() => new Response("app")); + await dispatch(provider, new Request("https://app.openseo.so/health")); + + const response = await invokeDefaultHandler( + new Request("https://app.openseo.so/api/auth/oauth2/authorize"), + { + OAUTH_PROVIDER: { + parseAuthRequest: () => + Promise.reject( + new AuthorizationError("invalid_scope", { + description: "Unsupported scope", + redirectUri: "https://client.example/callback", + state: "state-1", + issuer: "https://app.openseo.so", + }), + ), + }, + }, + ); + + expect(response.status).toBe(302); + const location = response.headers.get("Location"); + if (!location) throw new Error("Missing OAuth redirect"); + expect(Object.fromEntries(new URL(location).searchParams)).toMatchObject({ + error: "invalid_scope", + error_description: "Unsupported scope", + state: "state-1", + iss: "https://app.openseo.so", + }); + }); + + it("does not expose unexpected authorization failures as client errors", async () => { + const { createOpenSeoOAuthProvider } = await import("./oauth-provider"); + const provider = createOpenSeoOAuthProvider(() => new Response("app")); + await dispatch(provider, new Request("https://app.openseo.so/health")); + + await expect( + invokeDefaultHandler( + new Request("https://app.openseo.so/api/auth/oauth2/authorize"), + { + OAUTH_PROVIDER: { + parseAuthRequest: () => + Promise.reject(new Error("internal storage detail")), + }, + }, + ), + ).rejects.toThrow("internal storage detail"); + }); +}); diff --git a/src/server/mcp/oauth-provider.ts b/src/server/mcp/oauth-provider.ts index d280875..cdc1e79 100644 --- a/src/server/mcp/oauth-provider.ts +++ b/src/server/mcp/oauth-provider.ts @@ -1,5 +1,7 @@ import { waitUntil } from "cloudflare:workers"; import { + AuthorizationError, + OAuthError, OAuthProvider, type AuthRequest, type OAuthHelpers, @@ -17,13 +19,11 @@ import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation" import { captureServerEvent } from "@/server/lib/posthog"; import { createWorkersOAuthMcpProps, + MCP_AUTH_CONTEXT_PROP, MCP_ROUTE, - withWorkersOAuthMcpScopes, + workersOAuthMcpPropsSchema, } from "@/server/mcp/context"; -import { - normalizeClientRegistrationRequest, - withCompatibilityClientSecret, -} from "@/server/mcp/oauth-registration"; +import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registration"; import { getPublicOrigin } from "@/server/mcp/public-origin"; import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport"; import { resolveHostedContext } from "@/middleware/ensure-user/hosted"; @@ -33,7 +33,6 @@ const OAUTH_TOKEN_PATH = "/api/auth/oauth2/token"; const OAUTH_REGISTER_PATH = "/api/auth/oauth2/register"; const OAUTH_CONSENT_RESPONSE_PATH = "/api/oauth/consent"; -const WWW_AUTHENTICATE_HEADER = "WWW-Authenticate"; const OAUTH_AUTHORIZATION_PARAM_NAMES = [ "response_type", "client_id", @@ -48,6 +47,11 @@ const OAUTH_AUTHORIZATION_PARAM_NAMES = [ // preserve MCP sessions across normal usage. const MCP_ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 24; const MCP_REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; +// DCR client records expire on a fixed clock from registration (the provider +// defaults to 90 days), and an actively refreshing client breaks with +// invalid_client the moment its record lapses. A year keeps that cliff rare; +// rolling 30-day refresh tokens already reap inactive clients' sessions. +const MCP_CLIENT_REGISTRATION_TTL_SECONDS = 60 * 60 * 24 * 365; export type OpenSeoOAuthEnv = Env & { OAUTH_KV: KVNamespace; @@ -56,10 +60,6 @@ export type OpenSeoOAuthEnv = Env & { type AppFetch = (request: Request) => Response | Promise; -type OAuthExecutionContext = ExecutionContext & { - props?: unknown; -}; - type ExportedHandlerWithFetch = ExportedHandler & { fetch: NonNullable["fetch"]>; }; @@ -77,10 +77,6 @@ function getOAuthHelpers(env: OpenSeoOAuthEnv) { return env.OAUTH_PROVIDER; } -function getMcpResourceForRequest(request: Request) { - return getMcpResource(getPublicOrigin(request)); -} - function getRelativeRequestTarget(request: Request) { const url = new URL(request.url); return `${url.pathname}${url.search}`; @@ -92,10 +88,35 @@ function redirectToSignIn(request: Request) { return Response.redirect(signInUrl.toString(), 302); } -function invalidOAuthRequestResponse(error: unknown) { - return new Response( - error instanceof Error ? error.message : "Invalid OAuth request", - { status: 400 }, +function oauthErrorRedirect(input: { + redirectUri: string; + code: string; + description: string; + state?: string; + issuer?: string; +}) { + const redirectUrl = new URL(input.redirectUri); + redirectUrl.searchParams.set("error", input.code); + redirectUrl.searchParams.set("error_description", input.description); + if (input.state) redirectUrl.searchParams.set("state", input.state); + if (input.issuer) redirectUrl.searchParams.set("iss", input.issuer); + return redirectUrl.toString(); +} + +function authorizationErrorResponse(error: AuthorizationError) { + if (!error.redirectUri) { + return new Response(error.description, { status: 400 }); + } + + return Response.redirect( + oauthErrorRedirect({ + redirectUri: error.redirectUri, + code: error.code, + description: error.description, + state: error.state, + issuer: error.issuer, + }), + 302, ); } @@ -109,19 +130,17 @@ function jsonResponse(body: unknown, init?: ResponseInit) { }); } -function oauthErrorResponse(error: { +function logOAuthError(error: { code: string; description: string; status: number; - headers: Record; }) { // 401s here are the standard OAuth discovery handshake, not failures: an // unauthenticated /mcp hit returns `invalid_token` (which triggers the - // client's .well-known discovery), and clients registered as confidential - // before the public-client DCR fix still draw `invalid_client` until they - // retry with the secret or re-register. Log those at debug so they stop - // masquerading as errors; keep 5xx at error and everything else (bad client - // metadata, etc.) at warn. + // client's .well-known discovery), and stale client registrations draw + // `invalid_client` until the client re-registers. Log those at debug so + // they stop masquerading as errors; keep 5xx at error and everything else + // (bad client metadata, etc.) at warn. const line = `[oauth] ${error.status} ${error.code}: ${error.description}`; if (error.status === 401) { console.debug(line); @@ -131,22 +150,8 @@ function oauthErrorResponse(error: { console.warn(line); } - const headers = new Headers(error.headers); - headers.set("Content-Type", "application/json"); - if (headers.has(WWW_AUTHENTICATE_HEADER)) { - headers.set("Access-Control-Expose-Headers", WWW_AUTHENTICATE_HEADER); - } - - return new Response( - JSON.stringify({ - error: error.code, - error_description: error.description, - }), - { - status: error.status, - headers, - }, - ); + // Returning void delegates the standards-compliant body, bearer challenge, + // and CORS headers to workers-oauth-provider. } function csrfProtected(request: Request) { @@ -217,29 +222,6 @@ function buildAuthorizeRequestFromConsentQuery( }); } -function withDefaultMcpResource(authRequest: AuthRequest, request: Request) { - const mcpResource = getMcpResourceForRequest(request); - if (!authRequest.resource) { - return { - ...authRequest, - resource: mcpResource, - }; - } - - const requestedResources = Array.isArray(authRequest.resource) - ? authRequest.resource - : [authRequest.resource]; - - if (requestedResources.some((resource) => resource !== mcpResource)) { - throw new Error(`OAuth resource must be ${mcpResource}`); - } - - return { - ...authRequest, - resource: mcpResource, - }; -} - function getGrantedMcpScopes(requestedScopes: string[]) { if (requestedScopes.length === 0) { return [...MCP_OAUTH_SCOPES]; @@ -256,14 +238,13 @@ function getGrantedMcpScopes(requestedScopes: string[]) { } function deniedRedirect(authRequest: AuthRequest) { - const redirectUrl = new URL(authRequest.redirectUri); - redirectUrl.searchParams.set("error", "access_denied"); - redirectUrl.searchParams.set("error_description", "The user denied access"); - if (authRequest.state) { - redirectUrl.searchParams.set("state", authRequest.state); - } - - return redirectUrl.toString(); + return oauthErrorRedirect({ + redirectUri: authRequest.redirectUri, + code: "access_denied", + description: "The user denied access", + state: authRequest.state, + issuer: authRequest.issuer, + }); } async function handleOAuthAuthorizeRequest( @@ -275,7 +256,10 @@ async function handleOAuthAuthorizeRequest( try { await oauth.parseAuthRequest(request); } catch (error) { - return invalidOAuthRequestResponse(error); + if (error instanceof AuthorizationError) { + return authorizationErrorResponse(error); + } + throw error; } const sessionBlocker = await getAuthorizeSessionBlocker(request); @@ -317,11 +301,11 @@ async function handleOAuthConsentResponse( let authRequest: AuthRequest; try { authRequest = await oauth.parseAuthRequest(authorizeRequest); - authRequest = withDefaultMcpResource(authRequest, request); } catch (error) { + if (!(error instanceof AuthorizationError)) throw error; return jsonResponse( { - error: error instanceof Error ? error.message : "Invalid OAuth request", + error: error.description, }, { status: 400 }, ); @@ -348,16 +332,13 @@ async function handleOAuthConsentResponse( ); } - const audience = getMcpResourceForRequest(request); const props = createWorkersOAuthMcpProps({ userId: context.userId, userEmail: context.userEmail, organizationId: context.organizationId, + baseUrl: getHostedBaseUrl(), clientId: authRequest.clientId, scopes, - audience, - subject: context.userId, - baseUrl: getHostedBaseUrl(), }); const { redirectTo } = await oauth.completeAuthorization({ @@ -410,16 +391,11 @@ function createDefaultHandler( const mcpApiHandler: ExportedHandlerWithFetch = { async fetch(request, env, ctx) { - return handleAuthenticatedOpenSeoMcpRequest( - request, - (ctx as OAuthExecutionContext).props, - env, - ctx, - ); + return handleAuthenticatedOpenSeoMcpRequest(request, ctx.props, env, ctx); }, }; -export function createOpenSeoOAuthProvider(appFetch: AppFetch) { +function createProvider(appFetch: AppFetch, resource: string) { const options: OAuthProviderOptions = { apiRoute: MCP_ROUTE, apiHandler: mcpApiHandler, @@ -430,39 +406,64 @@ export function createOpenSeoOAuthProvider(appFetch: AppFetch) { scopesSupported: [...MCP_OAUTH_SCOPES], accessTokenTTL: MCP_ACCESS_TOKEN_TTL_SECONDS, refreshTokenTTL: MCP_REFRESH_TOKEN_TTL_SECONDS, + clientRegistrationTTL: MCP_CLIENT_REGISTRATION_TTL_SECONDS, resourceMetadata: { - scopes_supported: [...MCP_OAUTH_SCOPES], + resource, + scopes_supported: [MCP_SCOPE], resource_name: "OpenSEO MCP", }, tokenExchangeCallback: ({ props, requestedScope }) => { - const accessTokenProps = withWorkersOAuthMcpScopes(props, requestedScope); + if (!requestedScope.includes(MCP_SCOPE)) { + throw new OAuthError("invalid_scope", { + description: "The mcp scope is required", + }); + } - return accessTokenProps ? { accessTokenProps } : undefined; + const authContext = + workersOAuthMcpPropsSchema.parse(props)[MCP_AUTH_CONTEXT_PROP]; + return { + accessTokenProps: createWorkersOAuthMcpProps({ + ...authContext, + scopes: requestedScope, + }), + }; }, - onError: oauthErrorResponse, + onError: logOAuthError, }; - const provider = new OAuthProvider(options); + return new OAuthProvider(options); +} + +export function createOpenSeoOAuthProvider(appFetch: AppFetch) { + // Built lazily because the canonical resource comes from BETTER_AUTH_URL, + // which is not readable at module-init time. It is the same base URL the + // consent handler stamps into grant props. + let provider: OAuthProvider | undefined; + const getProvider = () => + (provider ??= createProvider(appFetch, getMcpResource(getHostedBaseUrl()))); return { async fetch(request: Request, env: OpenSeoOAuthEnv, ctx: ExecutionContext) { const url = new URL(request.url); if (url.pathname === OAUTH_REGISTER_PATH) { - // Register secretless MCP clients as true public clients so refresh - // grants never require client authentication, then dress the DCR - // response as confidential for clients (Perplexity) that reject - // responses without a client_secret. Cloudflare still owns client - // creation, secret hashing, and token storage. - const response = await provider.fetch( + return getProvider().fetch( await normalizeClientRegistrationRequest(request), env, ctx, ); - return withCompatibilityClientSecret(response); } - return provider.fetch(request, env, ctx); + return getProvider().fetch(request, env, ctx); + }, + + // Cron GC for OAUTH_KV: sweeps orphaned grants/tokens (e.g. from expired + // client registrations) that KV TTLs alone don't reclaim. The sweep only + // advances past live records by deleting dead ones, so give it a batch + // large enough to cover the whole keyspace in one pass while staying + // within the invocation's subrequest budget. + purgeExpiredData(env: OpenSeoOAuthEnv) { + return getProvider().purgeExpiredData(env, { batchSize: 200 }); }, }; } diff --git a/src/server/mcp/oauth-refresh.e2e.test.ts b/src/server/mcp/oauth-refresh.e2e.test.ts new file mode 100644 index 0000000..e5dc77f --- /dev/null +++ b/src/server/mcp/oauth-refresh.e2e.test.ts @@ -0,0 +1,465 @@ +import { createHash, randomBytes } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import type { createOpenSeoOAuthProvider } from "./oauth-provider"; + +// End-to-end OAuth lifecycle against the REAL @cloudflare/workers-oauth-provider +// (only the Workers runtime shims and app session resolution are mocked), +// shaped after how Codex actually behaves: DCR with no token_endpoint_auth_method, +// PKCE S256, form-encoded token requests, and refresh with client_id only — +// no client secret, no scope parameter. Refresh breakage has bitten real +// clients before (PR #420); these tests pin the full register → authorize → +// consent → token → use → refresh → rotate chain. + +const BASE = "https://app.openseo.so"; +const MCP_RESOURCE = `${BASE}/mcp`; + +vi.mock("cloudflare:workers", () => ({ + waitUntil: (promise: Promise) => void promise, + // The provider imports WorkerEntrypoint for its class-based apiHandler + // support; this suite uses object handlers, so a bare stand-in suffices. + // oxlint-disable-next-line typescript/no-extraneous-class + WorkerEntrypoint: class {}, +})); + +vi.mock("@/lib/auth", () => ({ + getHostedBaseUrl: () => "https://app.openseo.so", +})); + +vi.mock("@/middleware/ensure-user/hosted", () => ({ + resolveHostedContext: () => + Promise.resolve({ + userId: "user-1", + userEmail: "user@example.com", + organizationId: "org-1", + }), +})); + +vi.mock("@/server/features/activation/mcpActivation", () => ({ + recordMcpAuthorized: () => Promise.resolve(), +})); + +vi.mock("@/server/lib/posthog", () => ({ + captureServerEvent: () => Promise.resolve(), +})); + +// The API side of the provider: echo the decrypted grant props back so tests +// can assert what a tool call would actually see after each token exchange. +vi.mock("@/server/mcp/transport", () => ({ + handleAuthenticatedOpenSeoMcpRequest: ( + _request: Request, + props: unknown, + ): Promise => Promise.resolve(Response.json({ props })), +})); + +// Minimal in-memory KVNamespace honoring expirationTtl against Date.now(), so +// vi.setSystemTime drives token/grant expiry. +function createKvFake() { + const store = new Map< + string, + { value: string; metadata?: unknown; expiresAt?: number } + >(); + const live = (key: string) => { + const entry = store.get(key); + if (!entry) return undefined; + if (entry.expiresAt !== undefined && Date.now() >= entry.expiresAt) { + store.delete(key); + return undefined; + } + return entry; + }; + return { + get(key: string, type?: unknown) { + const entry = live(key); + if (!entry) return Promise.resolve(null); + const wantsJson = + type === "json" || + (typeof type === "object" && + type !== null && + (type as { type?: string }).type === "json"); + return Promise.resolve(wantsJson ? JSON.parse(entry.value) : entry.value); + }, + put( + key: string, + value: string, + options?: { expirationTtl?: number; metadata?: unknown }, + ) { + store.set(key, { + value, + metadata: options?.metadata, + expiresAt: + options?.expirationTtl !== undefined + ? Date.now() + options.expirationTtl * 1000 + : undefined, + }); + return Promise.resolve(); + }, + delete(key: string) { + store.delete(key); + return Promise.resolve(); + }, + list(options?: { prefix?: string; cursor?: string; limit?: number }) { + const keys = [...store.keys()] + .filter((key) => live(key)) + .filter((key) => !options?.prefix || key.startsWith(options.prefix)) + .slice(0, options?.limit ?? 1000) + .map((name) => { + const entry = store.get(name); + return { + name, + metadata: entry?.metadata, + expiration: entry?.expiresAt + ? Math.floor(entry.expiresAt / 1000) + : undefined, + }; + }); + return Promise.resolve({ keys, list_complete: true, cursor: "" }); + }, + }; +} + +const ctx: ExecutionContext = { + waitUntil() {}, + passThroughOnException() {}, + props: {}, +}; + +type Provider = ReturnType; +type Env = Parameters[1]; + +let provider: Provider; +let env: Env; + +beforeEach(async () => { + vi.useRealTimers(); + const { createOpenSeoOAuthProvider } = await import("./oauth-provider"); + provider = createOpenSeoOAuthProvider(() => new Response("app")); + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the provider touches only OAUTH_KV + env = { OAUTH_KV: createKvFake() } as unknown as Env; +}); + +const registrationSchema = z.looseObject({ + client_id: z.string(), + token_endpoint_auth_method: z.string(), + client_secret: z.string().optional(), +}); + +const tokenResponseSchema = z.looseObject({ + access_token: z.string(), + refresh_token: z.string(), + token_type: z.string(), +}); + +const tokenErrorSchema = z.looseObject({ + error: z.string(), +}); + +const propsEchoSchema = z.object({ + props: z.object({ + openSeoAuth: z.looseObject({ + userId: z.string(), + organizationId: z.string(), + clientId: z.string(), + scopes: z.array(z.string()), + }), + }), +}); + +function dispatch(request: Request) { + return provider.fetch(request, env, ctx); +} + +// Codex's DCR registration omits token_endpoint_auth_method entirely. +async function registerCodexStyleClient( + overrides: Record = {}, +) { + const response = await dispatch( + new Request(`${BASE}/api/auth/oauth2/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Codex", + redirect_uris: ["http://localhost:1455/auth/callback"], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + ...overrides, + }), + }), + ); + expect(response.status).toBe(201); + return registrationSchema.parse(await response.json()); +} + +async function authorizeAndGetCode(clientId: string, codeVerifier: string) { + const challenge = createHash("sha256") + .update(codeVerifier) + .digest("base64url"); + const query = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: "http://localhost:1455/auth/callback", + scope: "offline_access mcp", + state: "state-1", + code_challenge: challenge, + code_challenge_method: "S256", + resource: MCP_RESOURCE, + }); + + // The authorize route validates the request and bounces to the consent UI. + const authorizeResponse = await dispatch( + new Request(`${BASE}/api/auth/oauth2/authorize?${query.toString()}`), + ); + expect(authorizeResponse.status).toBe(302); + expect(authorizeResponse.headers.get("Location")).toContain("/oauth-consent"); + + // The consent UI posts acceptance back with the original authorize query. + const consentResponse = await dispatch( + new Request(`${BASE}/api/oauth/consent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: BASE, + }, + body: JSON.stringify({ accept: true, query: query.toString() }), + }), + ); + expect(consentResponse.status).toBe(200); + const { redirectTo } = z + .object({ redirectTo: z.string() }) + .parse(await consentResponse.json()); + const code = new URL(redirectTo).searchParams.get("code"); + if (!code) throw new Error("authorization code missing from redirect"); + return code; +} + +function tokenRequest(params: Record) { + return dispatch( + new Request(`${BASE}/api/auth/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params).toString(), + }), + ); +} + +async function exchangeCode( + clientId: string, + code: string, + codeVerifier: string, +) { + const response = await tokenRequest({ + grant_type: "authorization_code", + code, + redirect_uri: "http://localhost:1455/auth/callback", + client_id: clientId, + code_verifier: codeVerifier, + resource: MCP_RESOURCE, + }); + expect(response.status).toBe(200); + return tokenResponseSchema.parse(await response.json()); +} + +// Codex-shaped refresh: client_id only — no secret, no scope, no resource. +function refresh(clientId: string, refreshToken: string) { + return tokenRequest({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + }); +} + +async function callMcp(accessToken: string) { + const response = await dispatch( + new Request(MCP_RESOURCE, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}` }, + }), + ); + expect(response.status).toBe(200); + return propsEchoSchema.parse(await response.json()).props.openSeoAuth; +} + +async function setupSession() { + const client = await registerCodexStyleClient(); + const codeVerifier = randomBytes(32).toString("base64url"); + const code = await authorizeAndGetCode(client.client_id, codeVerifier); + const tokens = await exchangeCode(client.client_id, code, codeVerifier); + return { client, tokens }; +} + +describe("Codex-style OAuth token refresh (real workers-oauth-provider)", () => { + it("registers a secretless client as public and completes the code exchange", async () => { + const { client, tokens } = await setupSession(); + + expect(client.token_endpoint_auth_method).toBe("none"); + const auth = await callMcp(tokens.access_token); + expect(auth).toMatchObject({ + userId: "user-1", + organizationId: "org-1", + clientId: client.client_id, + }); + expect(auth.scopes).toContain("mcp"); + }); + + it("refreshes with client_id only and the new token still carries the mcp scope", async () => { + const { client, tokens } = await setupSession(); + + const response = await refresh(client.client_id, tokens.refresh_token); + expect(response.status).toBe(200); + const refreshed = tokenResponseSchema.parse(await response.json()); + expect(refreshed.access_token).not.toBe(tokens.access_token); + + const auth = await callMcp(refreshed.access_token); + expect(auth.scopes).toContain("mcp"); + expect(auth.clientId).toBe(client.client_id); + + // And the rotated refresh token keeps working for the next cycle. + const second = await refresh(client.client_id, refreshed.refresh_token); + expect(second.status).toBe(200); + }); + + it("honors the previous refresh token until the rotated one is used", async () => { + // Codex can lose a token response (crash, retry): the provider keeps the + // pre-rotation token valid until the new one is first used. + const { client, tokens } = await setupSession(); + + const first = tokenResponseSchema.parse( + await (await refresh(client.client_id, tokens.refresh_token)).json(), + ); + // Retry with the ORIGINAL token before ever using the rotated one. + const retry = await refresh(client.client_id, tokens.refresh_token); + expect(retry.status).toBe(200); + + const retried = tokenResponseSchema.parse(await retry.json()); + // Once a newer refresh token is used, the stale original is rejected. + const afterUse = await refresh(client.client_id, retried.refresh_token); + expect(afterUse.status).toBe(200); + const stale = await refresh(client.client_id, tokens.refresh_token); + expect(stale.status).toBe(400); + expect(tokenErrorSchema.parse(await stale.json()).error).toBe( + "invalid_grant", + ); + void first; + }); + + it("accepts a refresh carrying the canonical RFC 8707 resource and rejects a mismatched one", async () => { + const { client, tokens } = await setupSession(); + + const exact = await tokenRequest({ + grant_type: "refresh_token", + refresh_token: tokens.refresh_token, + client_id: client.client_id, + resource: MCP_RESOURCE, + }); + expect(exact.status).toBe(200); + const rotated = tokenResponseSchema.parse(await exact.json()); + + const mismatched = await tokenRequest({ + grant_type: "refresh_token", + refresh_token: rotated.refresh_token, + client_id: client.client_id, + resource: `${BASE}/other`, + }); + expect(mismatched.status).toBeGreaterThanOrEqual(400); + expect(mismatched.status).toBeLessThan(500); + }); + + it("rejects an RFC 3986-equivalent resource variant (upstream #282 — flips when fixed)", async () => { + // workers-oauth-provider compares resources with strict string equality, + // so a trailing-slash variant of the canonical resource fails refresh with + // invalid_target (cloudflare/workers-oauth-provider#282, open). Codex + // omits the param entirely and never hits this; Cloudflare's MCP Server + // Portal normalizes URLs this way and does. When upstream ships equivalence + // matching, this test fails — flip the assertion to 200 and drop the note. + const { client, tokens } = await setupSession(); + + const slashVariant = await tokenRequest({ + grant_type: "refresh_token", + refresh_token: tokens.refresh_token, + client_id: client.client_id, + resource: `${MCP_RESOURCE}/`, + }); + expect(slashVariant.status).toBe(400); + expect(tokenErrorSchema.parse(await slashVariant.json()).error).toBe( + "invalid_target", + ); + }); + + it("rejects a refresh that drops the mcp scope with a structured error", async () => { + const { client, tokens } = await setupSession(); + + const response = await tokenRequest({ + grant_type: "refresh_token", + refresh_token: tokens.refresh_token, + client_id: client.client_id, + scope: "offline_access", + }); + expect(response.status).toBe(400); + expect(tokenErrorSchema.parse(await response.json()).error).toBe( + "invalid_scope", + ); + }); + + it("returns a structured error, never a 500, for a refresh at the end of the grant's life", async () => { + // workers-oauth-provider < 0.8.1 crashed (uncaught 500) refreshing a grant + // in its final minute because KV rejects sub-60s TTLs — a client that + // retries a 500 and gives up looks exactly like "refresh is broken". + const { client, tokens } = await setupSession(); + + vi.useFakeTimers({ toFake: ["Date"] }); + try { + // 30-day refresh TTL (MCP_REFRESH_TOKEN_TTL_SECONDS), 30s before the end. + vi.setSystemTime(Date.now() + 30 * 24 * 60 * 60 * 1000 - 30 * 1000); + const response = await refresh(client.client_id, tokens.refresh_token); + expect(response.status).toBeLessThan(500); + if (response.status !== 200) { + expect( + tokenErrorSchema.parse(await response.json()).error, + ).toBeTruthy(); + } + } finally { + vi.useRealTimers(); + } + }); + + it("still rejects a secretless refresh from a client registered as confidential", async () => { + // The pre-PR-#420 failure mode: a client stored with client_secret_basic + // that never sends its secret fails refresh until it re-registers. + const client = await registerCodexStyleClient({ + token_endpoint_auth_method: "client_secret_basic", + }); + expect(client.client_secret).toBeTruthy(); + const codeVerifier = randomBytes(32).toString("base64url"); + const code = await authorizeAndGetCode(client.client_id, codeVerifier); + + // Confidential clients must authenticate even at the code exchange — and + // since 0.9.0 the registered method is enforced: client_secret_basic means + // the Authorization header, not a client_secret form field. + const basicAuth = Buffer.from( + `${client.client_id}:${client.client_secret ?? ""}`, + ).toString("base64"); + const exchange = await dispatch( + new Request(`${BASE}/api/auth/oauth2/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: "http://localhost:1455/auth/callback", + code_verifier: codeVerifier, + }).toString(), + }), + ); + expect(exchange.status).toBe(200); + const tokens = tokenResponseSchema.parse(await exchange.json()); + + const secretless = await refresh(client.client_id, tokens.refresh_token); + expect(secretless.status).toBe(401); + expect(tokenErrorSchema.parse(await secretless.json()).error).toBe( + "invalid_client", + ); + }); +}); diff --git a/src/server/mcp/oauth-registration.test.ts b/src/server/mcp/oauth-registration.test.ts index 3d536f7..c2d5ccc 100644 --- a/src/server/mcp/oauth-registration.test.ts +++ b/src/server/mcp/oauth-registration.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from "vitest"; -import { z } from "zod"; -import { - normalizeClientRegistrationRequest, - withCompatibilityClientSecret, -} from "@/server/mcp/oauth-registration"; - -const registrationBodySchema = z.looseObject({ client_secret: z.string() }); +import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registration"; describe("normalizeClientRegistrationRequest", () => { it("keeps explicit public registrations public", async () => { @@ -29,7 +23,7 @@ describe("normalizeClientRegistrationRequest", () => { }); }); - it("defaults omitted token auth methods to public clients", async () => { + it("registers Perplexity as a real confidential client", async () => { const request = new Request( "https://app.openseo.so/api/auth/oauth2/register", { @@ -44,6 +38,26 @@ describe("normalizeClientRegistrationRequest", () => { const normalized = await normalizeClientRegistrationRequest(request); + await expect(normalized.json()).resolves.toMatchObject({ + token_endpoint_auth_method: "client_secret_post", + }); + }); + + it("defaults other omitted token auth methods to public clients", async () => { + const request = new Request( + "https://app.openseo.so/api/auth/oauth2/register", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + redirect_uris: ["http://localhost:1455/auth/callback"], + client_name: "Codex", + }), + }, + ); + + const normalized = await normalizeClientRegistrationRequest(request); + await expect(normalized.json()).resolves.toMatchObject({ token_endpoint_auth_method: "none", }); @@ -92,55 +106,3 @@ describe("normalizeClientRegistrationRequest", () => { ); }); }); - -function registrationResponse(body: Record, status = 201) { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -describe("withCompatibilityClientSecret", () => { - it("adds a placeholder secret to public client registrations", async () => { - const response = await withCompatibilityClientSecret( - registrationResponse({ - client_id: "abc123", - token_endpoint_auth_method: "none", - client_id_issued_at: 1753228800, - }), - ); - - const body = registrationBodySchema.parse(await response.json()); - - expect(body).toMatchObject({ - client_id: "abc123", - token_endpoint_auth_method: "client_secret_post", - client_secret_expires_at: 0, - client_secret_issued_at: 1753228800, - }); - expect(body.client_secret).toMatch(/^[0-9a-f]{32}$/); - }); - - it("leaves confidential registrations untouched", async () => { - const original = registrationResponse({ - client_id: "abc123", - token_endpoint_auth_method: "client_secret_post", - client_secret: "real-secret", - }); - - const response = await withCompatibilityClientSecret(original); - - expect(response).toBe(original); - }); - - it("leaves registration errors untouched", async () => { - const original = registrationResponse( - { error: "invalid_client_metadata" }, - 400, - ); - - const response = await withCompatibilityClientSecret(original); - - expect(response).toBe(original); - }); -}); diff --git a/src/server/mcp/oauth-registration.ts b/src/server/mcp/oauth-registration.ts index 1e68b5d..18d2c45 100644 --- a/src/server/mcp/oauth-registration.ts +++ b/src/server/mcp/oauth-registration.ts @@ -1,18 +1,14 @@ import { z } from "zod"; const PUBLIC_CLIENT_AUTH_METHOD = "none"; -const COMPAT_CLIENT_AUTH_METHOD = "client_secret_post"; +const CONFIDENTIAL_CLIENT_AUTH_METHOD = "client_secret_post"; +const PERPLEXITY_CALLBACK = "https://www.perplexity.ai/api/mcp/oauth/callback"; const MAX_CLIENT_REGISTRATION_BODY_BYTES = 1024 * 1024; -// Loose so every field the provider cares about survives the round trip; these -// shims only read the auth method and secret. +// Loose so every field the provider cares about survives the round trip. const clientMetadataSchema = z.looseObject({ token_endpoint_auth_method: z.string().optional(), -}); - -const clientRegistrationSchema = z.looseObject({ - token_endpoint_auth_method: z.string().optional(), - client_secret: z.string().optional(), + redirect_uris: z.array(z.string()).optional(), }); export async function normalizeClientRegistrationRequest(request: Request) { @@ -49,15 +45,15 @@ export async function normalizeClientRegistrationRequest(request: Request) { return request; } - // The provider defaults an omitted auth method to client_secret_basic, - // which requires client authentication on every grant — including refresh. - // MCP clients that discard the secret (Codex did) then lose their session - // at first token expiry with "invalid_client: missing client_secret". - // Register them as public clients instead; PKCE plus the provider's - // grant-to-client binding secure the public-client flow. + // Perplexity requires a client secret, so let the provider create and store a + // real one. Other MCP clients that omit the method are public clients: some + // discard DCR secrets and would otherwise fail their first token refresh. + const isPerplexity = parsed.data.redirect_uris?.includes(PERPLEXITY_CALLBACK); const metadata = { ...parsed.data, - token_endpoint_auth_method: PUBLIC_CLIENT_AUTH_METHOD, + token_endpoint_auth_method: isPerplexity + ? CONFIDENTIAL_CLIENT_AUTH_METHOD + : PUBLIC_CLIENT_AUTH_METHOD, }; const headers = new Headers(request.headers); @@ -70,47 +66,3 @@ export async function normalizeClientRegistrationRequest(request: Request) { body: JSON.stringify(metadata), }); } - -export async function withCompatibilityClientSecret(response: Response) { - if (response.status !== 201) { - return response; - } - - let rawBody: unknown; - try { - rawBody = await response.clone().json(); - } catch { - return response; - } - - const parsed = clientRegistrationSchema.safeParse(rawBody); - if ( - !parsed.success || - parsed.data.token_endpoint_auth_method !== PUBLIC_CLIENT_AUTH_METHOD || - parsed.data.client_secret !== undefined - ) { - return response; - } - - // Perplexity registers as a public client but rejects DCR responses without - // a client_secret (its validator accepts client_secret_post but not - // client_secret_basic). Dress the response as confidential while the stored - // client stays public: the token endpoint skips secret validation for public - // clients, so clients that send this placeholder and clients that never - // store it both keep working — including refresh grants. - const compatBody = { - ...parsed.data, - token_endpoint_auth_method: COMPAT_CLIENT_AUTH_METHOD, - client_secret: crypto.randomUUID().replaceAll("-", ""), - client_secret_expires_at: 0, - client_secret_issued_at: parsed.data.client_id_issued_at, - }; - - const headers = new Headers(response.headers); - headers.delete("Content-Length"); - - return new Response(JSON.stringify(compatBody), { - status: response.status, - headers, - }); -} diff --git a/src/server/mcp/output-schemas.ts b/src/server/mcp/output-schemas.ts index acfa165..5f34690 100644 --- a/src/server/mcp/output-schemas.ts +++ b/src/server/mcp/output-schemas.ts @@ -1,5 +1,18 @@ import { z } from "zod"; +// Tools author schemas as either a raw Zod shape (most tools) or a full +// z.object (the GA4 tools). The SDK accepts both; this normalization exists so +// TS overload resolution stays simple and instrumentation receives a real +// ZodType to validate with. +export function objectSchema(schema: z.ZodType | z.ZodRawShape): z.ZodType; +export function objectSchema( + schema: z.ZodType | z.ZodRawShape | undefined, +): z.ZodType | undefined; +export function objectSchema(schema: z.ZodType | z.ZodRawShape | undefined) { + if (!schema) return undefined; + return schema instanceof z.ZodType ? schema : z.object(schema); +} + const mcpMetaOutputSchema = z .object({ url: z.string().optional(), diff --git a/src/server/mcp/project-auth.test.ts b/src/server/mcp/project-auth.test.ts index 9861583..10d42f2 100644 --- a/src/server/mcp/project-auth.test.ts +++ b/src/server/mcp/project-auth.test.ts @@ -1,7 +1,5 @@ -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -import type { ToolExtra } from "@/server/mcp/context"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; +import { makeToolContext } from "@/server/mcp/tools/tool-test-support"; const mocks = vi.hoisted(() => ({ getProjectForOrganization: vi.fn(), @@ -13,30 +11,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({ }, })); -const authContext = { - userId: "user_123", - userEmail: "alice@example.com", - organizationId: "org_123", - clientId: "client_123", - scopes: ["mcp"], - audience: "https://open-seo.test/mcp", - subject: "user_123", - baseUrl: "https://open-seo.test", -}; - -const toolExtra: ToolExtra = { - signal: new AbortController().signal, - requestId: 1, - sendNotification: vi.fn(), - sendRequest: vi.fn(), - authInfo: { - token: "token", - clientId: "client_123", - scopes: ["mcp"], - resource: new URL("https://open-seo.test/mcp"), - extra: { [MCP_AUTH_CONTEXT_PROP]: authContext }, - } satisfies AuthInfo, -}; +const toolContext = makeToolContext(); describe("withMcpProjectAuth", () => { beforeEach(() => { @@ -57,7 +32,7 @@ describe("withMcpProjectAuth", () => { const wrapped = withMcpProjectAuth(handler); await expect( - wrapped({ projectId: "project_123" }, toolExtra), + wrapped({ projectId: "project_123" }, toolContext), ).resolves.toBe("ok"); expect(mocks.getProjectForOrganization).toHaveBeenCalledWith( @@ -71,7 +46,7 @@ describe("withMcpProjectAuth", () => { const handler = vi.fn().mockReturnValue("ok"); const wrapped = withMcpProjectAuth(handler); - await wrapped({ projectId: "project_123" }, toolExtra); + await wrapped({ projectId: "project_123" }, toolContext); expect(handler).toHaveBeenCalledWith( { projectId: "project_123" }, @@ -82,8 +57,6 @@ describe("withMcpProjectAuth", () => { organizationId: "org_123", clientId: "client_123", scopes: ["mcp"], - audience: "https://open-seo.test/mcp", - subject: "user_123", }, baseUrl: "https://open-seo.test", billing: { @@ -109,9 +82,9 @@ describe("withMcpProjectAuth", () => { const handler = vi.fn(); const wrapped = withMcpProjectAuth(handler); - await expect(wrapped({ projectId: "project_123" }, toolExtra)).rejects.toBe( - error, - ); + await expect( + wrapped({ projectId: "project_123" }, toolContext), + ).rejects.toBe(error); expect(handler).not.toHaveBeenCalled(); }); @@ -126,7 +99,7 @@ describe("withMcpProjectAuth", () => { const wrapped = withMcpProjectAuth(handler); await expect( - wrapped({ projectId: "someone-elses-project" }, toolExtra), + wrapped({ projectId: "someone-elses-project" }, toolContext), ).rejects.toThrow(); expect(handler).not.toHaveBeenCalled(); diff --git a/src/server/mcp/project-auth.ts b/src/server/mcp/project-auth.ts index 0eb0f26..1f5c6b6 100644 --- a/src/server/mcp/project-auth.ts +++ b/src/server/mcp/project-auth.ts @@ -1,17 +1,16 @@ import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { AppError } from "@/server/lib/errors"; -import { - buildBillingCustomer, - requireMcpToolAuthContext, - type ToolExtra, -} from "@/server/mcp/context"; +import { buildBillingCustomer, type ToolContext } from "@/server/mcp/context"; type ProjectScopedArgs = { projectId: string; }; -async function requireProjectAccess(extra: ToolExtra, projectId: string) { - const { baseUrl, ...auth } = requireMcpToolAuthContext(extra); +async function requireProjectAccess( + toolContext: ToolContext, + projectId: string, +) { + const { baseUrl, ...auth } = toolContext.auth; // Authorize the caller-supplied projectId against the token's organization. // Assert on the result instead of relying on the lookup throwing, so this @@ -42,8 +41,8 @@ export function withMcpProjectAuth( context: McpProjectAuthContext, ) => Promise | TResult, ) { - return async (args: TArgs, extra: ToolExtra) => { - const context = await requireProjectAccess(extra, args.projectId); + return async (args: TArgs, toolContext: ToolContext) => { + const context = await requireProjectAccess(toolContext, args.projectId); return handler(args, context); }; } diff --git a/src/server/mcp/server.ts b/src/server/mcp/server.ts index bafd6ad..e503a66 100644 --- a/src/server/mcp/server.ts +++ b/src/server/mcp/server.ts @@ -1,12 +1,11 @@ -import type { +import { + type CallToolResult, McpServer, - ToolCallback, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { - AnySchema, - ZodRawShapeCompat, -} from "@modelcontextprotocol/sdk/server/zod-compat.js"; -import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; + type ToolAnnotations, +} from "@modelcontextprotocol/server"; +import type { z } from "zod"; +import { createMcpToolContext, type ToolContext } from "@/server/mcp/context"; +import { objectSchema } from "@/server/mcp/output-schemas"; import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation"; import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview"; import { getBacklinksProfileTool } from "@/server/mcp/tools/get-backlinks-profile"; @@ -19,9 +18,6 @@ import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker"; import { removeRankTrackingKeywordsTool } from "@/server/mcp/tools/remove-rank-tracking-keywords"; import { runRankTrackerTool } from "@/server/mcp/tools/run-rank-tracker"; import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results"; -import { createProjectTool } from "@/server/mcp/tools/create-project"; -import { listProjectsTool } from "@/server/mcp/tools/list-projects"; -import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords"; import { getGoogleAnalyticsAudienceBreakdownTool, getGoogleAnalyticsEcommercePerformanceTool, @@ -34,6 +30,9 @@ import { getGoogleAnalyticsTrafficAcquisitionTool, getSearchOpportunitiesTool, } from "@/server/mcp/tools/google-analytics-tools"; +import { createProjectTool } from "@/server/mcp/tools/create-project"; +import { listProjectsTool } from "@/server/mcp/tools/list-projects"; +import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords"; import { findSerpCompetitorsTool, getGoogleBusinessQuestionsTool, @@ -56,75 +55,118 @@ import { } from "@/server/mcp/tools/site-audit-tools"; import { whoamiTool } from "@/server/mcp/tools/whoami"; -// Each handler is wrapped so failures reach PostHog because the MCP route has -// no error middleware of its own. -function registerInstrumentedTool< - In extends ZodRawShapeCompat | AnySchema, - Out extends ZodRawShapeCompat | AnySchema, ->( +type ToolSchema = z.ZodType | z.ZodRawShape; + +// Tools declare inputSchema as either a raw Zod shape (most tools) or a full +// z.object (the GA4 tools); both normalize to one object schema at +// registration. +type ToolArgs = Input extends z.ZodType + ? z.infer + : Input extends z.ZodRawShape + ? z.infer> + : never; + +type OpenSeoToolDefinition = { + name: string; + config: { + title?: string; + description?: string; + inputSchema: Input; + outputSchema?: ToolSchema; + annotations?: ToolAnnotations; + }; + handler: ( + args: ToolArgs, + context: ToolContext, + ) => CallToolResult | Promise; +}; + +function registerOpenSeoTool( server: McpServer, - tool: { - name: string; - config: { - inputSchema?: In; - outputSchema?: Out; - title?: string; - description?: string; - annotations?: ToolAnnotations; - }; - handler: ToolCallback; - }, + tool: OpenSeoToolDefinition, ) { + const outputSchema = objectSchema(tool.config.outputSchema); + const handler = instrumentMcpToolHandler( + tool.name, + outputSchema, + tool.handler, + ); + server.registerTool( tool.name, - tool.config, - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- instrumentation preserves the callback arguments validated by ToolCallback - instrumentMcpToolHandler( - tool.name, - tool.config.outputSchema, - tool.handler, - ) as ToolCallback, + { + ...tool.config, + inputSchema: objectSchema(tool.config.inputSchema), + outputSchema, + }, + (args, context) => + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- args were validated against the tool's own inputSchema just above + handler(args as ToolArgs, createMcpToolContext(context)), ); } -export function registerOpenSeoMcpTools(server: McpServer) { - registerInstrumentedTool(server, whoamiTool); - registerInstrumentedTool(server, listProjectsTool); - registerInstrumentedTool(server, createProjectTool); - registerInstrumentedTool(server, listSavedKeywordsTool); - registerInstrumentedTool(server, researchKeywordsTool); - registerInstrumentedTool(server, saveKeywordsTool); - registerInstrumentedTool(server, getDomainOverviewTool); - registerInstrumentedTool(server, getDomainKeywordSuggestionsTool); - registerInstrumentedTool(server, getBacklinksOverviewTool); - registerInstrumentedTool(server, getBacklinksProfileTool); - registerInstrumentedTool(server, getSerpResultsTool); - registerInstrumentedTool(server, createRankTrackerTool); - registerInstrumentedTool(server, getRankTrackerTool); - registerInstrumentedTool(server, addRankTrackingKeywordsTool); - registerInstrumentedTool(server, removeRankTrackingKeywordsTool); - registerInstrumentedTool(server, estimateRankTrackerCostTool); - registerInstrumentedTool(server, runRankTrackerTool); - registerInstrumentedTool(server, getRankedKeywordsTool); - registerInstrumentedTool(server, findSerpCompetitorsTool); - registerInstrumentedTool(server, searchLocalBusinessesTool); - registerInstrumentedTool(server, getLocalSerpResultsTool); - registerInstrumentedTool(server, getGoogleBusinessQuestionsTool); - registerInstrumentedTool(server, getKeywordMetricsTool); - registerInstrumentedTool(server, getSearchConsolePerformanceTool); - registerInstrumentedTool(server, inspectUrlsTool); - registerInstrumentedTool(server, getGoogleAnalyticsOrganicLandingPagesTool); - registerInstrumentedTool(server, getGoogleAnalyticsPagePerformanceTool); - registerInstrumentedTool(server, getGoogleAnalyticsKeyEventsTool); - registerInstrumentedTool(server, getSearchOpportunitiesTool); - registerInstrumentedTool(server, getGoogleAnalyticsOrganicOverviewTool); - registerInstrumentedTool(server, getGoogleAnalyticsTrafficAcquisitionTool); - registerInstrumentedTool(server, getGoogleAnalyticsMeasurementHealthTool); - registerInstrumentedTool(server, getGoogleAnalyticsEcommercePerformanceTool); - registerInstrumentedTool(server, getGoogleAnalyticsSiteSearchTool); - registerInstrumentedTool(server, getGoogleAnalyticsAudienceBreakdownTool); - registerInstrumentedTool(server, runSiteAuditTool); - registerInstrumentedTool(server, getAuditStatusTool); - registerInstrumentedTool(server, getAuditIssuesTool); - registerInstrumentedTool(server, getAuditPagesTool); +export function createOpenSeoMcpServer() { + const server = new McpServer( + { + name: "OpenSEO MCP", + title: "OpenSEO", + version: "0.0.11", + description: + "SEO research tools for AI agents: keyword research and metrics, SERP and local SERP results, domain and backlink analysis, rank tracking, and Google Search Console performance.", + websiteUrl: "https://openseo.so", + icons: [ + { + src: "https://openseo.so/android-chrome-512x512.png", + mimeType: "image/png", + sizes: ["512x512"], + }, + ], + }, + { + instructions: + "OpenSEO research tools use credits. Proceed with normal focused research, but ask the user for confirmation before planned batches over 2,000 credits.", + }, + ); + + registerOpenSeoTool(server, whoamiTool); + registerOpenSeoTool(server, listProjectsTool); + registerOpenSeoTool(server, createProjectTool); + registerOpenSeoTool(server, listSavedKeywordsTool); + registerOpenSeoTool(server, researchKeywordsTool); + registerOpenSeoTool(server, saveKeywordsTool); + registerOpenSeoTool(server, getDomainOverviewTool); + registerOpenSeoTool(server, getDomainKeywordSuggestionsTool); + registerOpenSeoTool(server, getBacklinksOverviewTool); + registerOpenSeoTool(server, getBacklinksProfileTool); + registerOpenSeoTool(server, getSerpResultsTool); + registerOpenSeoTool(server, createRankTrackerTool); + registerOpenSeoTool(server, getRankTrackerTool); + registerOpenSeoTool(server, addRankTrackingKeywordsTool); + registerOpenSeoTool(server, removeRankTrackingKeywordsTool); + registerOpenSeoTool(server, estimateRankTrackerCostTool); + registerOpenSeoTool(server, runRankTrackerTool); + registerOpenSeoTool(server, getRankedKeywordsTool); + registerOpenSeoTool(server, findSerpCompetitorsTool); + registerOpenSeoTool(server, searchLocalBusinessesTool); + registerOpenSeoTool(server, getLocalSerpResultsTool); + registerOpenSeoTool(server, getGoogleBusinessQuestionsTool); + registerOpenSeoTool(server, getKeywordMetricsTool); + registerOpenSeoTool(server, getSearchConsolePerformanceTool); + registerOpenSeoTool(server, inspectUrlsTool); + registerOpenSeoTool(server, getGoogleAnalyticsOrganicLandingPagesTool); + registerOpenSeoTool(server, getGoogleAnalyticsPagePerformanceTool); + registerOpenSeoTool(server, getGoogleAnalyticsKeyEventsTool); + registerOpenSeoTool(server, getSearchOpportunitiesTool); + registerOpenSeoTool(server, getGoogleAnalyticsOrganicOverviewTool); + registerOpenSeoTool(server, getGoogleAnalyticsTrafficAcquisitionTool); + registerOpenSeoTool(server, getGoogleAnalyticsMeasurementHealthTool); + registerOpenSeoTool(server, getGoogleAnalyticsEcommercePerformanceTool); + registerOpenSeoTool(server, getGoogleAnalyticsSiteSearchTool); + registerOpenSeoTool(server, getGoogleAnalyticsAudienceBreakdownTool); + registerOpenSeoTool(server, runSiteAuditTool); + registerOpenSeoTool(server, getAuditStatusTool); + registerOpenSeoTool(server, getAuditIssuesTool); + registerOpenSeoTool(server, getAuditPagesTool); + + return server; } diff --git a/src/server/mcp/tools/create-project.test.ts b/src/server/mcp/tools/create-project.test.ts index 46bbf01..f76ba76 100644 --- a/src/server/mcp/tools/create-project.test.ts +++ b/src/server/mcp/tools/create-project.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createProjectTool } from "./create-project"; -import { makeToolExtra } from "./tool-test-support"; +import { makeToolContext } from "./tool-test-support"; const mocks = vi.hoisted(() => ({ createProject: vi.fn(), @@ -12,7 +12,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({ }, })); -const toolExtra = makeToolExtra(); +const toolContext = makeToolContext(); describe("create_project MCP tool", () => { beforeEach(() => {}); @@ -28,7 +28,7 @@ describe("create_project MCP tool", () => { const result = await createProjectTool.handler( { name: "Acme", domain: "acme.com", locationCode: 2840 }, - toolExtra, + toolContext, ); // The schema does not derive languageCode; the service resolves it from @@ -62,7 +62,7 @@ describe("create_project MCP tool", () => { languageCode: "en", }); - await createProjectTool.handler({ name: "Just a name" }, toolExtra); + await createProjectTool.handler({ name: "Just a name" }, toolContext); expect(mocks.createProject).toHaveBeenCalledWith("org_123", { name: "Just a name", @@ -73,7 +73,7 @@ describe("create_project MCP tool", () => { await expect( createProjectTool.handler( { name: "Bad market", languageCode: "en" }, - toolExtra, + toolContext, ), ).rejects.toThrow(); expect(mocks.createProject).not.toHaveBeenCalled(); diff --git a/src/server/mcp/tools/create-project.ts b/src/server/mcp/tools/create-project.ts index 2ea2ecb..74f0ed8 100644 --- a/src/server/mcp/tools/create-project.ts +++ b/src/server/mcp/tools/create-project.ts @@ -1,9 +1,6 @@ import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { mcpResponse } from "@/server/mcp/formatters"; -import { - requireMcpToolAuthContext, - type ToolExtra, -} from "@/server/mcp/context"; +import { type ToolContext } from "@/server/mcp/context"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { buildDashboardUrl } from "@/server/mcp/urls"; import { languageCodeSchema, locationCodeSchema } from "@/server/mcp/schemas"; @@ -65,8 +62,8 @@ export const createProjectTool = { destructiveHint: false, }, }, - handler: async (args: Args, extra: ToolExtra) => { - const { baseUrl, ...auth } = requireMcpToolAuthContext(extra); + handler: async (args: Args, context: ToolContext) => { + const { baseUrl, ...auth } = context.auth; // Reuse the app's create schema so the market pair rule (a languageCode // requires a locationCode) is enforced identically to the dashboard, and // the domain is normalized the same way. diff --git a/src/server/mcp/tools/dataforseo-research-tools.google-ads.test.ts b/src/server/mcp/tools/dataforseo-research-tools.google-ads.test.ts index 9639888..dcdb98f 100644 --- a/src/server/mcp/tools/dataforseo-research-tools.google-ads.test.ts +++ b/src/server/mcp/tools/dataforseo-research-tools.google-ads.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics"; import { getKeywordMetricsTool } from "./dataforseo-research-tools"; -import { makeToolExtra } from "./tool-test-support"; +import { makeToolContext } from "./tool-test-support"; const mocks = vi.hoisted(() => ({ createDataforseoClient: vi.fn(), @@ -31,7 +31,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({ }, })); -const toolExtra = makeToolExtra(); +const toolContext = makeToolContext(); describe("get_keyword_metrics for Google-Ads-only locations", () => { beforeEach(() => { @@ -68,7 +68,7 @@ describe("get_keyword_metrics for Google-Ads-only locations", () => { locationCode: 2352, languageCode: "is", }, - toolExtra, + toolContext, ); expect(keywordOverview).not.toHaveBeenCalled(); @@ -120,7 +120,7 @@ describe("get_keyword_metrics for Google-Ads-only locations", () => { keywords: ["seo tools"], includeClickstreamData: true, }, - toolExtra, + toolContext, ); expect(keywordOverview).toHaveBeenCalledWith( diff --git a/src/server/mcp/tools/dataforseo-research-tools.market.test.ts b/src/server/mcp/tools/dataforseo-research-tools.market.test.ts index c29e982..51e335e 100644 --- a/src/server/mcp/tools/dataforseo-research-tools.market.test.ts +++ b/src/server/mcp/tools/dataforseo-research-tools.market.test.ts @@ -3,7 +3,7 @@ import { findSerpCompetitorsTool, getRankedKeywordsTool, } from "./dataforseo-research-tools"; -import { makeToolExtra } from "./tool-test-support"; +import { makeToolContext } from "./tool-test-support"; // Market resolution for get_ranked_keywords: the explicit country selector and // the project's default-market fallback (projects.locationCode/languageCode). @@ -27,7 +27,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({ }, })); -const toolExtra = makeToolExtra(); +const toolContext = makeToolContext(); function setProject(market: { locationCode: number; languageCode: string }) { mocks.getProjectForOrganization.mockResolvedValue({ @@ -55,7 +55,7 @@ async function runRankedKeywords(args: MarketArgs) { }); await getRankedKeywordsTool.handler( { projectId: "project_1", target: "acmeexample.com", ...args }, - toolExtra, + toolContext, ); return rankedKeywords; } @@ -67,7 +67,7 @@ async function runSerpCompetitors(args: MarketArgs) { }); await findSerpCompetitorsTool.handler( { projectId: "project_1", keywords: ["seo"], ...args }, - toolExtra, + toolContext, ); return serpCompetitors; } diff --git a/src/server/mcp/tools/dataforseo-research-tools.test.ts b/src/server/mcp/tools/dataforseo-research-tools.test.ts index 387cd87..9bf929c 100644 --- a/src/server/mcp/tools/dataforseo-research-tools.test.ts +++ b/src/server/mcp/tools/dataforseo-research-tools.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics"; import * as researchTools from "./dataforseo-research-tools"; -import { makeToolExtra, textContent } from "./tool-test-support"; +import { makeToolContext, textContent } from "./tool-test-support"; const mocks = vi.hoisted(() => ({ createDataforseoClient: vi.fn(), @@ -31,7 +31,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({ }, })); -const toolExtra = makeToolExtra(); +const toolContext = makeToolContext(); const usProjectRow = { id: "project_1", @@ -70,7 +70,7 @@ describe("DataForSEO research MCP tools", () => { }, categories: ["cafe"], }, - toolExtra, + toolContext, ); expect(businessListings).toHaveBeenCalledWith( @@ -116,7 +116,7 @@ describe("DataForSEO research MCP tools", () => { zoom: 14, }, }, - toolExtra, + toolContext, ); expect(local).toHaveBeenCalledWith( @@ -164,7 +164,7 @@ describe("DataForSEO research MCP tools", () => { radiusKm: 5, }, }, - toolExtra, + toolContext, ); expect(questionsAnswers).toHaveBeenCalledWith( @@ -201,7 +201,7 @@ describe("DataForSEO research MCP tools", () => { target: "acmeexample.com", excludeBrandTerms: ["acme"], }, - toolExtra, + toolContext, ); expect(rankedKeywords).toHaveBeenCalledWith( @@ -228,7 +228,7 @@ describe("DataForSEO research MCP tools", () => { keywords: ["coffee"], excludeDomains: ["directory.example"], }, - toolExtra, + toolContext, ); const content = z @@ -285,7 +285,7 @@ describe("DataForSEO research MCP tools", () => { const result = await getKeywordMetricsTool.handler( { projectId: "project_1", keywords: ["seo automation"] }, - toolExtra, + toolContext, ); expect(keywordOverview).toHaveBeenCalledWith( @@ -340,7 +340,7 @@ describe("DataForSEO research MCP tools", () => { keywords: ["low", "high", "medium"], sortBy: "search_volume", }, - toolExtra, + toolContext, ); const rows = z @@ -372,7 +372,7 @@ describe("DataForSEO research MCP tools", () => { keywords: ["seo"], includeMonthlyTrends: false, }, - toolExtra, + toolContext, ); const rows = z diff --git a/src/server/mcp/tools/google-analytics-tools.test.ts b/src/server/mcp/tools/google-analytics-tools.test.ts index fd0ee8b..aad0128 100644 --- a/src/server/mcp/tools/google-analytics-tools.test.ts +++ b/src/server/mcp/tools/google-analytics-tools.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { makeGa4ReportResult } from "@/server/features/ga4/services/ga4-test-fixtures"; import { Ga4ReportError } from "@/server/lib/ga4Errors"; import * as tools from "./google-analytics-tools"; -import { makeToolExtra } from "./tool-test-support"; +import { makeToolContext } from "./tool-test-support"; const mocks = vi.hoisted(() => ({ runReport: vi.fn(), @@ -37,7 +37,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({ }, })); -const toolExtra = makeToolExtra(); +const toolContext = makeToolContext(); const reportResult = makeGa4ReportResult({ rowCount: 1, totalRowCount: 1, @@ -70,7 +70,7 @@ describe("Google Analytics MCP tools", () => { const { getGoogleAnalyticsOrganicLandingPagesTool } = tools; const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler( { projectId: "project_1", limit: 10, offset: 0 }, - toolExtra, + toolContext, ); expect(mocks.runReport).toHaveBeenCalledWith({ projectId: "project_1", @@ -99,7 +99,7 @@ describe("Google Analytics MCP tools", () => { limit: 100, offset: 0, }, - toolExtra, + toolContext, ); await getGoogleAnalyticsKeyEventsTool.handler( { @@ -110,7 +110,7 @@ describe("Google Analytics MCP tools", () => { limit: 100, offset: 0, }, - toolExtra, + toolContext, ); expect(mocks.runReport).toHaveBeenNthCalledWith( 1, @@ -146,7 +146,7 @@ describe("Google Analytics MCP tools", () => { limit: 100, offset: 0, }, - toolExtra, + toolContext, ); expect(result.structuredContent).toMatchObject({ status: "error", @@ -168,7 +168,7 @@ describe("Google Analytics MCP tools", () => { const { getSearchOpportunitiesTool } = tools; const result = await getSearchOpportunitiesTool.handler( { projectId: "project_1", limit: 25 }, - toolExtra, + toolContext, ); expect(mocks.getOpportunities).toHaveBeenCalledWith({ projectId: "project_1", @@ -193,7 +193,7 @@ describe("Google Analytics MCP tools", () => { limit: 100, offset: 0, }, - toolExtra, + toolContext, ), ], [ @@ -208,7 +208,7 @@ describe("Google Analytics MCP tools", () => { limit: 100, offset: 0, }, - toolExtra, + toolContext, ), ], [ @@ -216,7 +216,7 @@ describe("Google Analytics MCP tools", () => { () => tools.getGoogleAnalyticsSiteSearchTool.handler( { projectId: "project_1", limit: 100, offset: 0 }, - toolExtra, + toolContext, ), ], [ @@ -231,7 +231,7 @@ describe("Google Analytics MCP tools", () => { limit: 100, offset: 0, }, - toolExtra, + toolContext, ), ], ] as const)( @@ -265,11 +265,11 @@ describe("Google Analytics MCP tools", () => { } = tools; const overview = await getGoogleAnalyticsOrganicOverviewTool.handler( { projectId: "project_1", trend: "weekly" }, - toolExtra, + toolContext, ); const health = await getGoogleAnalyticsMeasurementHealthTool.handler( { projectId: "project_1" }, - toolExtra, + toolContext, ); expect(mocks.getOrganicOverview).toHaveBeenCalledWith({ projectId: "project_1", diff --git a/src/server/mcp/tools/google-analytics-tools.ts b/src/server/mcp/tools/google-analytics-tools.ts index b2b692d..0f5974b 100644 --- a/src/server/mcp/tools/google-analytics-tools.ts +++ b/src/server/mcp/tools/google-analytics-tools.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- all GA4 MCP tools are intentionally kept in one module */ -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/server"; import { z } from "zod"; import { Ga4MeasurementHealthService } from "@/server/features/ga4/services/Ga4MeasurementHealthService"; import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService"; diff --git a/src/server/mcp/tools/list-projects.ts b/src/server/mcp/tools/list-projects.ts index 0dc1bc2..56a6d29 100644 --- a/src/server/mcp/tools/list-projects.ts +++ b/src/server/mcp/tools/list-projects.ts @@ -1,9 +1,6 @@ import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { mcpResponse } from "@/server/mcp/formatters"; -import { - requireMcpToolAuthContext, - type ToolExtra, -} from "@/server/mcp/context"; +import { type ToolContext } from "@/server/mcp/context"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { buildDashboardUrl } from "@/server/mcp/urls"; import { z } from "zod"; @@ -36,8 +33,8 @@ export const listProjectsTool = { destructiveHint: false, }, }, - handler: async (_args: Record, extra: ToolExtra) => { - const { baseUrl, ...auth } = requireMcpToolAuthContext(extra); + handler: async (_args: Record, context: ToolContext) => { + const { baseUrl, ...auth } = context.auth; const projects = await ProjectService.listProjects(auth.organizationId); const lines = projects.length === 0 diff --git a/src/server/mcp/tools/output-schema-validation.test.ts b/src/server/mcp/tools/output-schema-validation.test.ts index 5b8c39c..1c333ca 100644 --- a/src/server/mcp/tools/output-schema-validation.test.ts +++ b/src/server/mcp/tools/output-schema-validation.test.ts @@ -1,12 +1,9 @@ -import { - normalizeObjectSchema, - safeParseAsync, -} from "@modelcontextprotocol/sdk/server/zod-compat.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AppError } from "@/server/lib/errors"; +import { objectSchema } from "@/server/mcp/output-schemas"; import * as researchTools from "./dataforseo-research-tools"; import { getBacklinksProfileTool } from "./get-backlinks-profile"; -import { makeMcpAuthContext, makeToolExtra } from "./tool-test-support"; +import { makeToolContext } from "./tool-test-support"; const mocks = vi.hoisted(() => ({ getProjectForOrganization: vi.fn(), @@ -42,12 +39,10 @@ class ProviderRow { ) {} } -const authExtra = makeToolExtra( - makeMcpAuthContext({ - userEmail: "team@example.com", - baseUrl: "https://app.example.com", - }), -); +const toolContext = makeToolContext({ + userEmail: "team@example.com", + baseUrl: "https://app.example.com", +}); const backlinkPage = { rows: [ @@ -100,13 +95,12 @@ describe("DataForSEO research tool output schemas", () => { const tool = Object.values(tools).find((t) => t.name === toolName); if (!tool) throw new Error(`tool ${toolName} not found`); - const schema = normalizeObjectSchema(tool.config.outputSchema); - if (!schema) throw new Error("output schema did not normalize"); + const schema = objectSchema(tool.config.outputSchema); // Mirror the MCP server: validate structuredContent against the tool's // own output schema. Extra keys (e.g. get_ranked_keywords' totalCount) // are allowed by the passthrough schemas, so one payload covers all. - const result = await safeParseAsync(schema, { + const result = await schema.safeParseAsync({ [field]: [new ProviderRow("example.com", 1)], totalCount: 1, }); @@ -116,12 +110,9 @@ describe("DataForSEO research tool output schemas", () => { ); it("get_backlinks_profile accepts a paginated backlinks profile payload", async () => { - const schema = normalizeObjectSchema( - getBacklinksProfileTool.config.outputSchema, - ); - if (!schema) throw new Error("output schema did not normalize"); + const schema = objectSchema(getBacklinksProfileTool.config.outputSchema); - const result = await safeParseAsync(schema, { + const result = await schema.safeParseAsync({ backlinks: backlinkPage, meta: { organizationId: "org_123", @@ -155,7 +146,7 @@ describe("get_backlinks_profile MCP tool", () => { mode: "as_is", hideSpam: false, }, - authExtra, + toolContext, ); expect(mocks.profileBacklinksPage).toHaveBeenCalledWith( @@ -208,7 +199,7 @@ describe("get_backlinks_profile MCP tool", () => { mode: "one_per_domain", hideSpam: true, }, - authExtra, + toolContext, ); expect(result.structuredContent?.backlinks).toMatchObject({ @@ -240,7 +231,7 @@ describe("get_backlinks_profile MCP tool", () => { mode: "one_per_domain", hideSpam: true, }, - authExtra, + toolContext, ), ).rejects.toMatchObject({ code: "BACKLINKS_BILLING_ISSUE", diff --git a/src/server/mcp/tools/rank-tracking-management-tools.test.ts b/src/server/mcp/tools/rank-tracking-management-tools.test.ts index 7c870d0..0772b3d 100644 --- a/src/server/mcp/tools/rank-tracking-management-tools.test.ts +++ b/src/server/mcp/tools/rank-tracking-management-tools.test.ts @@ -5,7 +5,7 @@ import { createRankTrackerTool } from "./create-rank-tracker"; import { estimateRankTrackerCostTool } from "./estimate-rank-tracker-cost"; import { removeRankTrackingKeywordsTool } from "./remove-rank-tracking-keywords"; import { runRankTrackerTool } from "./run-rank-tracker"; -import { makeToolExtra, textContent } from "./tool-test-support"; +import { makeToolContext, textContent } from "./tool-test-support"; const mocks = vi.hoisted(() => ({ getProjectForOrganization: vi.fn(), @@ -46,7 +46,7 @@ const projectId = "11111111-1111-4111-8111-111111111111"; const trackerId = "22222222-2222-4222-8222-222222222222"; const keywordId = "33333333-3333-4333-8333-333333333333"; -const toolExtra = makeToolExtra(); +const toolContext = makeToolContext(); const createdConfig = { id: trackerId, @@ -78,7 +78,7 @@ describe("rank tracking management MCP tools", () => { const parsed = z.object(createRankTrackerTool.config.inputSchema).parse({ projectId, }); - const result = await createRankTrackerTool.handler(parsed, toolExtra); + const result = await createRankTrackerTool.handler(parsed, toolContext); expect(mocks.createConfig).toHaveBeenCalledWith({ projectId, @@ -130,7 +130,7 @@ describe("rank tracking management MCP tools", () => { }); await expect( - createRankTrackerTool.handler({ projectId }, toolExtra), + createRankTrackerTool.handler({ projectId }, toolContext), ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); expect(mocks.createConfig).not.toHaveBeenCalled(); }); @@ -153,7 +153,7 @@ describe("rank tracking management MCP tools", () => { const added = await addRankTrackingKeywordsTool.handler( { projectId, trackerId, keywords: ["seo", "SEO", "existing"] }, - toolExtra, + toolContext, ); expect(textContent(added)).toContain("Added 1 of 3 requested"); expect(added.structuredContent).toMatchObject({ requested: 3, added: 1 }); @@ -169,7 +169,7 @@ describe("rank tracking management MCP tools", () => { const removed = await removeRankTrackingKeywordsTool.handler( { projectId, trackerId, keywordIds: [keywordId, keywordId] }, - toolExtra, + toolContext, ); expect(textContent(removed)).toContain("Removed 1 of 2 requested"); expect(removed.structuredContent).toMatchObject({ @@ -200,7 +200,7 @@ describe("rank tracking management MCP tools", () => { }); const result = await estimateRankTrackerCostTool.handler( { projectId, trackerId, additionalKeywordCount: 3 }, - toolExtra, + toolContext, ); expect(textContent(result)).toContain( @@ -224,7 +224,7 @@ describe("rank tracking management MCP tools", () => { }); const result = await runRankTrackerTool.handler( { projectId, trackerId, maxCostCredits: 13 }, - toolExtra, + toolContext, ); expect(result.structuredContent).toMatchObject({ @@ -256,7 +256,7 @@ describe("rank tracking management MCP tools", () => { }); const result = await runRankTrackerTool.handler( { projectId, trackerId, maxCostCredits: 13 }, - toolExtra, + toolContext, ); expect(textContent(result)).toContain("no additional check was charged"); @@ -277,7 +277,7 @@ describe("rank tracking management MCP tools", () => { await expect( runRankTrackerTool.handler( { projectId, trackerId, maxCostCredits: 13 }, - toolExtra, + toolContext, ), ).resolves.toMatchObject({ structuredContent: { started: true, runId: "run_1" }, diff --git a/src/server/mcp/tools/saved-keywords-tools.test.ts b/src/server/mcp/tools/saved-keywords-tools.test.ts index 8983b69..5b6c5ac 100644 --- a/src/server/mcp/tools/saved-keywords-tools.test.ts +++ b/src/server/mcp/tools/saved-keywords-tools.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { listSavedKeywordsTool } from "./list-saved-keywords"; import { saveKeywordsTool } from "./save-keywords"; -import { makeToolExtra } from "./tool-test-support"; +import { makeToolContext } from "./tool-test-support"; const mocks = vi.hoisted(() => ({ getProjectForOrganization: vi.fn(), @@ -22,7 +22,7 @@ vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({ }, })); -const toolExtra = makeToolExtra(); +const toolContext = makeToolContext(); describe("saved keyword MCP tools", () => { beforeEach(() => { @@ -45,7 +45,7 @@ describe("saved keyword MCP tools", () => { keywords: ["technical seo"], tags: ["Content"], }, - toolExtra, + toolContext, ); expect(mocks.saveKeywords).toHaveBeenCalledWith({ @@ -76,7 +76,7 @@ describe("saved keyword MCP tools", () => { tags: ["cluster: affordable semrush alternatives"], tagMode: "replace", }, - toolExtra, + toolContext, ); expect(mocks.saveKeywords).toHaveBeenCalledWith({ @@ -102,7 +102,7 @@ describe("saved keyword MCP tools", () => { keywords: ["semrush alternative"], tagMode: "replace", }, - toolExtra, + toolContext, ), ).rejects.toThrow("Replacement tags are required"); expect(mocks.saveKeywords).not.toHaveBeenCalled(); @@ -138,7 +138,7 @@ describe("saved keyword MCP tools", () => { tags: ["Content"], limit: 50, }, - toolExtra, + toolContext, ); expect(mocks.getSavedKeywords).toHaveBeenCalledWith({ diff --git a/src/server/mcp/tools/search-console-tools.test.ts b/src/server/mcp/tools/search-console-tools.test.ts index a4569b1..1d5fc46 100644 --- a/src/server/mcp/tools/search-console-tools.test.ts +++ b/src/server/mcp/tools/search-console-tools.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { GscApiError, GscNotConnectedError } from "@/server/lib/gscErrors"; import * as searchConsoleTools from "./search-console-tools"; -import { makeToolExtra } from "./tool-test-support"; +import { makeToolContext } from "./tool-test-support"; const mocks = vi.hoisted(() => ({ getProjectForOrganization: vi.fn(), @@ -28,7 +28,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({ vi.mock("@/server/features/gsc/services/GscService", () => ({ GscService: mocks.GscService, })); -const toolExtra = makeToolExtra(); +const toolContext = makeToolContext(); describe("search console MCP tools", () => { beforeEach(() => { @@ -75,7 +75,7 @@ describe("search console MCP tools", () => { }, ], }, - toolExtra, + toolContext, ); expect(mocks.GscService.getPerformance).toHaveBeenCalledWith( @@ -111,7 +111,7 @@ describe("search console MCP tools", () => { const result = await getSearchConsolePerformanceTool.handler( { projectId: "project_1" }, - toolExtra, + toolContext, ); expect(result.structuredContent).toMatchObject({ @@ -133,7 +133,7 @@ describe("search console MCP tools", () => { const result = await getSearchConsolePerformanceTool.handler( { projectId: "project_1" }, - toolExtra, + toolContext, ); expect(result.structuredContent).toMatchObject({ @@ -151,7 +151,7 @@ describe("search console MCP tools", () => { const result = await getSearchConsolePerformanceTool.handler( { projectId: "project_1", dimensions: ["query", "searchAppearance"] }, - toolExtra, + toolContext, ); expect(result.structuredContent).toMatchObject({ @@ -165,7 +165,7 @@ describe("search console MCP tools", () => { const result = await getSearchConsolePerformanceTool.handler( { projectId: "project_1", startDate: "2026-01-01" }, - toolExtra, + toolContext, ); expect(result.structuredContent).toMatchObject({ @@ -181,7 +181,7 @@ describe("search console MCP tools", () => { const result = await getSearchConsolePerformanceTool.handler( { projectId: "project_1" }, - toolExtra, + toolContext, ); expect(result.structuredContent).toMatchObject({ @@ -208,7 +208,7 @@ describe("search console MCP tools", () => { const result = await getSearchConsolePerformanceTool.handler( { projectId: "project_1" }, - toolExtra, + toolContext, ); expect(mocks.GscService.getPerformance).toHaveBeenCalledWith( @@ -242,7 +242,7 @@ describe("search console MCP tools", () => { projectId: "project_1", urls: ["https://example.com/a", "https://example.com/bad"], }, - toolExtra, + toolContext, ); expect(mocks.GscService.inspectUrls).toHaveBeenCalledWith( @@ -268,7 +268,7 @@ describe("search console MCP tools", () => { const result = await inspectUrlsTool.handler( { projectId: "project_1", urls: ["https://example.com/a"] }, - toolExtra, + toolContext, ); expect(result.structuredContent).toMatchObject({ @@ -284,7 +284,7 @@ describe("search console MCP tools", () => { const result = await inspectUrlsTool.handler( { projectId: "project_1", urls: ["https://example.com/a"] }, - toolExtra, + toolContext, ); expect(result.structuredContent).toMatchObject({ diff --git a/src/server/mcp/tools/tool-test-support.ts b/src/server/mcp/tools/tool-test-support.ts index aade8c4..5ca5d98 100644 --- a/src/server/mcp/tools/tool-test-support.ts +++ b/src/server/mcp/tools/tool-test-support.ts @@ -1,44 +1,18 @@ -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -import { vi } from "vitest"; -import { - MCP_AUTH_CONTEXT_PROP, - type McpToolAuthContext, - type ToolExtra, -} from "@/server/mcp/context"; +import type { ToolAuthContext, ToolContext } from "@/server/mcp/context"; -export function makeMcpAuthContext( - overrides: Partial = {}, -): McpToolAuthContext { - const baseUrl = overrides.baseUrl ?? "https://open-seo.test"; +export function makeToolContext( + overrides: Partial = {}, +): ToolContext { return { - userId: "user_123", - userEmail: "alice@example.com", - organizationId: "org_123", - clientId: "client_123", - scopes: ["mcp"], - audience: `${baseUrl}/mcp`, - subject: "user_123", - baseUrl, - ...overrides, - }; -} - -export function makeToolExtra( - authContext: McpToolAuthContext = makeMcpAuthContext(), - requestId: ToolExtra["requestId"] = 1, -): ToolExtra { - return { - signal: new AbortController().signal, - requestId, - sendNotification: vi.fn(), - sendRequest: vi.fn(), - authInfo: { - token: "token", - clientId: authContext.clientId ?? "client_123", - scopes: authContext.scopes, - resource: new URL(`${authContext.baseUrl}/mcp`), - extra: { [MCP_AUTH_CONTEXT_PROP]: authContext }, - } satisfies AuthInfo, + auth: { + userId: "user_123", + userEmail: "alice@example.com", + organizationId: "org_123", + clientId: "client_123", + scopes: ["mcp"], + baseUrl: "https://open-seo.test", + ...overrides, + }, }; } diff --git a/src/server/mcp/tools/tool-text-output.test.ts b/src/server/mcp/tools/tool-text-output.test.ts index 5bf7254..74475b4 100644 --- a/src/server/mcp/tools/tool-text-output.test.ts +++ b/src/server/mcp/tools/tool-text-output.test.ts @@ -6,7 +6,7 @@ import { getDomainKeywordSuggestionsTool } from "./get-domain-keyword-suggestion import { getRankTrackerTool } from "./get-rank-tracker"; import { getSerpResultsTool } from "./get-serp-results"; import { researchKeywordsTool } from "./research-keywords"; -import { makeToolExtra, textContent } from "./tool-test-support"; +import { makeToolContext, textContent } from "./tool-test-support"; // Verifies that each tool renders its actual row data into the text content // block (not just a count), across the tools whose data comes from OpenSEO @@ -69,7 +69,7 @@ vi.mock("@/server/features/rank-tracking/services/RankTrackingService", () => ({ }, })); -const toolExtra = makeToolExtra(); +const toolContext = makeToolContext(); describe("MCP tool text output (service-backed tools)", () => { beforeEach(() => { @@ -108,7 +108,7 @@ describe("MCP tool text output (service-backed tools)", () => { const result = await researchKeywordsTool.handler( { projectId: "project_1", seeds: [{ seed: "seo tools" }] }, - toolExtra, + toolContext, ); const out = textContent(result); @@ -129,7 +129,7 @@ describe("MCP tool text output (service-backed tools)", () => { ]); const result = await getDomainKeywordSuggestionsTool.handler( { projectId: "project_1", domain: "example.com" }, - toolExtra, + toolContext, ); const out = textContent(result); @@ -160,7 +160,7 @@ describe("MCP tool text output (service-backed tools)", () => { }); const result = await getBacklinksOverviewTool.handler( { projectId: "project_1", target: "example.com" }, - toolExtra, + toolContext, ); const out = textContent(result); @@ -201,7 +201,7 @@ describe("MCP tool text output (service-backed tools)", () => { filters: {}, mode: "one_per_domain", }, - toolExtra, + toolContext, ); const out = textContent(result); @@ -236,7 +236,7 @@ describe("MCP tool text output (service-backed tools)", () => { const result = await getRankTrackerTool.handler( { projectId: "project_1", trackerId: "tracker_1" }, - toolExtra, + toolContext, ); const out = textContent(result); @@ -268,7 +268,7 @@ describe("MCP tool text output (service-backed tools)", () => { const result = await getRankTrackerTool.handler( { projectId: "project_1", trackerId: "tracker_1" }, - toolExtra, + toolContext, ); expect(textContent(result)).toContain( @@ -310,7 +310,7 @@ describe("MCP tool text output (service-backed tools)", () => { const result = await getRankedKeywordsTool.handler( { projectId: "project_1", target: "example.com" }, - toolExtra, + toolContext, ); const out = textContent(result); @@ -335,7 +335,7 @@ describe("MCP tool text output (service-backed tools)", () => { const result = await getSerpResultsTool.handler( { projectId: "project_1", queries: [{ keyword: "seo tools" }] }, - toolExtra, + toolContext, ); const out = textContent(result); diff --git a/src/server/mcp/tools/whoami.ts b/src/server/mcp/tools/whoami.ts index ea55644..ff410a9 100644 --- a/src/server/mcp/tools/whoami.ts +++ b/src/server/mcp/tools/whoami.ts @@ -4,7 +4,7 @@ import { AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, } from "@/shared/billing"; import { mcpResponse } from "@/server/mcp/formatters"; -import { getAuth, type ToolExtra } from "@/server/mcp/context"; +import { type ToolContext } from "@/server/mcp/context"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { z } from "zod"; @@ -40,8 +40,8 @@ export const whoamiTool = { destructiveHint: false, }, }, - handler: async (_args: Record, extra: ToolExtra) => { - const auth = getAuth(extra); + handler: async (_args: Record, context: ToolContext) => { + const auth = context.auth; const isHosted = await isHostedServerAuthMode(); let creditsRemaining: number | null = null; if (isHosted) { diff --git a/src/server/mcp/transport-v2.test.ts b/src/server/mcp/transport-v2.test.ts new file mode 100644 index 0000000..4aa22b0 --- /dev/null +++ b/src/server/mcp/transport-v2.test.ts @@ -0,0 +1,100 @@ +import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; +import { McpServer } from "@modelcontextprotocol/server"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +const ctx: ExecutionContext = { + waitUntil() {}, + passThroughOnException() {}, + props: {}, +}; + +function request(method: string, body?: unknown) { + return new Request("https://open-seo.test/mcp", { + method, + headers: { + Host: "open-seo.test", + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +describe("Agents SDK v2 MCP transport", () => { + it("rejects a standalone GET without constructing a server", async () => { + let serverCount = 0; + const handler = createMcpHandler( + () => { + serverCount += 1; + const server = new McpServer({ name: "test", version: "1.0.0" }); + server.registerTool("ping", {}, () => ({ + content: [{ type: "text", text: "pong" }], + })); + return server; + }, + { route: "/mcp" }, + ); + + const response = await handler(request("GET"), {}, ctx); + + expect(response.status).toBe(405); + expect(serverCount).toBe(0); + }); + + it("passes verified provider identity and application props to tools", async () => { + const props = { openSeoAuth: { organizationId: "org-1" } }; + const oauthContext = { + ...ctx, + props, + [Symbol.for("cloudflare.workers-oauth-provider.verified-context.v1")]: { + version: 1, + token: "access-token", + clientId: "client-1", + scopes: ["mcp"], + resource: "https://open-seo.test/mcp", + props, + }, + } as ExecutionContext; + const handler = createMcpHandler( + () => { + const server = new McpServer({ name: "test", version: "1.0.0" }); + server.registerTool( + "auth", + { inputSchema: z.object({}) }, + (_args, context) => ({ + content: [ + { + type: "text", + text: JSON.stringify({ + clientId: context.http?.authInfo?.clientId, + scopes: context.http?.authInfo?.scopes, + props: getMcpAuthContext()?.props, + }), + }, + ], + }), + ); + return server; + }, + { route: "/mcp" }, + ); + + const response = await handler( + request("POST", { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "auth", arguments: {} }, + }), + {}, + oauthContext, + ); + + expect(response.status).toBe(200); + const responseText = await response.text(); + expect(responseText).toContain('\\"clientId\\":\\"client-1\\"'); + expect(responseText).toContain('\\"scopes\\":[\\"mcp\\"]'); + expect(responseText).toContain('\\"organizationId\\":\\"org-1\\"'); + }); +}); diff --git a/src/server/mcp/transport.test.ts b/src/server/mcp/transport.test.ts index 0b4aedc..69b140d 100644 --- a/src/server/mcp/transport.test.ts +++ b/src/server/mcp/transport.test.ts @@ -1,22 +1,17 @@ -import type { CreateMcpHandlerOptions } from "agents/mcp"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { CreateMcpHandlerOptions } from "agents/mcp/server"; +import { McpServer } from "@modelcontextprotocol/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; +import { + createWorkersOAuthMcpProps, + MCP_AUTH_CONTEXT_PROP, +} from "@/server/mcp/context"; const selfHostedAuthMocks = vi.hoisted(() => ({ resolveCloudflareAccessContext: vi.fn(), resolveLocalNoAuthContext: vi.fn(), })); -const serverMocks = vi.hoisted(() => ({ - nextServerId: 0, - serverIds: new WeakMap(), - lastServer: undefined as McpServer | undefined, -})); - vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({ resolveCloudflareAccessContext: selfHostedAuthMocks.resolveCloudflareAccessContext, @@ -26,20 +21,38 @@ vi.mock("@/middleware/ensure-user/delegated", () => ({ resolveLocalNoAuthContext: selfHostedAuthMocks.resolveLocalNoAuthContext, })); -vi.mock("@/server/mcp/server", () => ({ - registerOpenSeoMcpTools: vi.fn(), +vi.mock("@/lib/auth", () => ({ + getHostedBaseUrl: () => "https://open-seo.test", })); -vi.mock("agents/mcp", () => ({ - createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => { - serverMocks.nextServerId += 1; - serverMocks.serverIds.set(_server, serverMocks.nextServerId); - serverMocks.lastServer = _server; +vi.mock("@/server/mcp/server", () => ({ + createOpenSeoMcpServer: () => + new McpServer({ + name: "OpenSEO MCP", + title: "OpenSEO", + version: "0.0.11", + description: "SEO research tools for AI agents", + websiteUrl: "https://openseo.so", + icons: [ + { + src: "https://openseo.so/android-chrome-512x512.png", + mimeType: "image/png", + sizes: ["512x512"], + }, + ], + }), +})); - return async () => - new Response( +vi.mock("agents/mcp/server", () => ({ + createMcpHandler: ( + createServer: () => McpServer, + options: CreateMcpHandlerOptions, + ) => { + return async (request: Request) => { + if (request.method !== "OPTIONS") createServer(); + + return new Response( JSON.stringify({ - serverId: serverMocks.serverIds.get(_server), options, }), { @@ -47,6 +60,7 @@ vi.mock("agents/mcp", () => ({ headers: { "Content-Type": "application/json" }, }, ); + }; }, })); @@ -57,10 +71,9 @@ const ctx: ExecutionContext = { }; const transportOptionsSchema = z.object({ - serverId: z.number().optional(), options: z.object({ route: z.string().optional(), - enableJsonResponse: z.boolean().optional(), + allowedOriginHostnames: z.array(z.string()).optional(), authContext: z .object({ props: z.record(z.string(), z.unknown()), @@ -87,9 +100,6 @@ function createMcpRequest() { describe("handleSelfHostedOpenSeoMcpRequest", () => { beforeEach(() => { vi.clearAllMocks(); - serverMocks.nextServerId = 0; - serverMocks.serverIds = new WeakMap(); - serverMocks.lastServer = undefined; selfHostedAuthMocks.resolveLocalNoAuthContext.mockResolvedValue({ userId: "local-admin", userEmail: "admin@localhost", @@ -122,12 +132,11 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => { userId: "local-admin", userEmail: "admin@localhost", organizationId: "delegated-local-admin", - clientId: null, - scopes: [], - audience: "https://open-seo.test/mcp", - subject: "local-admin", baseUrl: "https://open-seo.test", }); + // Self-hosted must not pin Origins to the request's own Host — the + // handler's localhost-class default is the rebinding-safe choice. + expect(body.options.allowedOriginHostnames).toBeUndefined(); }); it("accepts Cloudflare Access MCP requests through the existing Access resolver", async () => { @@ -152,36 +161,10 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => { userId: "cloudflare-user", userEmail: "person@example.com", organizationId: "delegated-cloudflare-user", - clientId: null, - scopes: [], - audience: "https://open-seo.test/mcp", - subject: "cloudflare-user", baseUrl: "https://open-seo.test", }); }); - // The OOM came from the GET SSE stream pinning a per-request McpServer, so - // GET must 405 without ever building one. - it("returns 405 for the standalone GET SSE stream without building a server", async () => { - const { handleSelfHostedOpenSeoMcpRequest } = - await import("@/server/mcp/transport"); - - const response = await handleSelfHostedOpenSeoMcpRequest( - new Request("https://open-seo.test/mcp", { - method: "GET", - headers: { Accept: "text/event-stream" }, - }), - "local_noauth", - {}, - ctx, - ); - - expect(response.status).toBe(405); - expect(response.headers.get("Allow")).toContain("POST"); - // nextServerId only advances when a server is built — a GET must not. - expect(serverMocks.nextServerId).toBe(0); - }); - it("lets the MCP transport handle OPTIONS without auth context", async () => { const { handleSelfHostedOpenSeoMcpRequest } = await import("@/server/mcp/transport"); @@ -200,46 +183,74 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => { ).not.toHaveBeenCalled(); expect(body.options.authContext).toBeUndefined(); }); +}); - // Directory scanners (e.g. Smithery) read server metadata from initialize. - it("serves directory metadata in the initialize response", async () => { - const { handleSelfHostedOpenSeoMcpRequest } = +describe("handleAuthenticatedOpenSeoMcpRequest", () => { + it("accepts the provider's encrypted identity and MCP scope fallback", async () => { + const { handleAuthenticatedOpenSeoMcpRequest } = await import("@/server/mcp/transport"); - - await handleSelfHostedOpenSeoMcpRequest( - createMcpRequest(), - "local_noauth", - {}, - ctx, - ); - const server = serverMocks.lastServer; - if (!server) throw new Error("MCP server was not created"); - - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - const client = new Client({ name: "test-client", version: "0.0.0" }); - await Promise.all([ - client.connect(clientTransport), - server.connect(serverTransport), - ]); - - const serverInfo = client.getServerVersion(); - expect(serverInfo).toMatchObject({ - name: "OpenSEO MCP", - title: "OpenSEO", - websiteUrl: "https://openseo.so", - icons: [ - { - src: "https://openseo.so/android-chrome-512x512.png", - mimeType: "image/png", - sizes: ["512x512"], - }, - ], + const props = createWorkersOAuthMcpProps({ + userId: "user-1", + userEmail: "user@example.com", + organizationId: "org-1", + baseUrl: "https://open-seo.test", + clientId: "client-1", + scopes: ["mcp"], }); - expect(serverInfo?.description).toContain( - "SEO research tools for AI agents", + + const response = await handleAuthenticatedOpenSeoMcpRequest( + createMcpRequest(), + props, + {}, + { ...ctx, props }, ); - await client.close(); + expect(response.status).toBe(200); + const body = transportOptionsSchema.parse(await response.json()); + expect(body.options.allowedOriginHostnames).toEqual(["open-seo.test"]); + }); + + it("rejects provider props missing the OAuth client identity", async () => { + const { handleAuthenticatedOpenSeoMcpRequest } = + await import("@/server/mcp/transport"); + // Hosted tokens always carry clientId/scopes; a token without them must + // fail closed rather than skip scope enforcement. + const props = createWorkersOAuthMcpProps({ + userId: "user-1", + userEmail: "user@example.com", + organizationId: "org-1", + baseUrl: "https://open-seo.test", + }); + + const response = await handleAuthenticatedOpenSeoMcpRequest( + createMcpRequest(), + props, + {}, + { ...ctx, props }, + ); + + expect(response.status).toBe(403); + }); + + it("rejects an OAuth client without the MCP scope", async () => { + const { handleAuthenticatedOpenSeoMcpRequest } = + await import("@/server/mcp/transport"); + const props = createWorkersOAuthMcpProps({ + userId: "user-1", + userEmail: "user@example.com", + organizationId: "org-1", + baseUrl: "https://open-seo.test", + clientId: "client-1", + scopes: ["offline_access"], + }); + + const response = await handleAuthenticatedOpenSeoMcpRequest( + createMcpRequest(), + props, + {}, + { ...ctx, props }, + ); + + expect(response.status).toBe(403); }); }); diff --git a/src/server/mcp/transport.ts b/src/server/mcp/transport.ts index 8c2a3d1..5e9fc33 100644 --- a/src/server/mcp/transport.ts +++ b/src/server/mcp/transport.ts @@ -1,44 +1,33 @@ -import { createMcpHandler } from "agents/mcp"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createMcpHandler } from "agents/mcp/server"; +import { getHostedBaseUrl } from "@/lib/auth"; import { MCP_SCOPE } from "@/lib/oauth-resource"; import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess"; import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated"; import { - buildFirstPartyMcpAuthContext, createWorkersOAuthMcpProps, + hostedWorkersOAuthMcpPropsSchema, MCP_AUTH_CONTEXT_PROP, MCP_ROUTE, - runWithMcpToolAuthContext, - workersOAuthMcpPropsSchema, } from "@/server/mcp/context"; import { getPublicOrigin } from "@/server/mcp/public-origin"; -import { registerOpenSeoMcpTools } from "@/server/mcp/server"; +import { createOpenSeoMcpServer } from "@/server/mcp/server"; -function createOpenSeoMcpServer() { - const server = new McpServer( - { - name: "OpenSEO MCP", - title: "OpenSEO", - version: "0.0.11", - description: - "SEO research tools for AI agents: keyword research and metrics, SERP and local SERP results, domain and backlink analysis, rank tracking, and Google Search Console performance.", - websiteUrl: "https://openseo.so", - icons: [ - { - src: "https://openseo.so/android-chrome-512x512.png", - mimeType: "image/png", - sizes: ["512x512"], - }, - ], - }, - { - instructions: - "OpenSEO research tools use credits. Proceed with normal focused research, but ask the user for confirmation before planned batches over 2,000 credits.", - }, - ); - registerOpenSeoMcpTools(server); +type McpProps = ReturnType; - return server; +// Hosted pins browser Origins to the configured base URL. Self-hosted leaves +// the option unset so the handler's localhost-class default applies — an +// allowlist derived from the request's own Host would accept a DNS-rebinding +// page trivially. Non-browser MCP clients send no Origin and are unaffected +// either way. +function createRequestHandler( + props: McpProps | undefined, + allowedOriginHostnames?: string[], +) { + return createMcpHandler(createOpenSeoMcpServer, { + route: MCP_ROUTE, + allowedOriginHostnames, + authContext: props ? { props } : undefined, + }); } export async function handleAuthenticatedOpenSeoMcpRequest( @@ -47,16 +36,20 @@ export async function handleAuthenticatedOpenSeoMcpRequest( env: unknown, ctx: ExecutionContext, ): Promise { - const result = workersOAuthMcpPropsSchema.safeParse(props); - const scopes = result.success - ? result.data[MCP_AUTH_CONTEXT_PROP].scopes - : []; - - if (!result.success || !scopes.includes(MCP_SCOPE)) { + const result = hostedWorkersOAuthMcpPropsSchema.safeParse(props); + if (!result.success) { return new Response("MCP auth context required", { status: 403 }); } + if (!result.data[MCP_AUTH_CONTEXT_PROP].scopes.includes(MCP_SCOPE)) { + return new Response("MCP scope required", { status: 403 }); + } - return handleOpenSeoMcpRequest(request, result.data, env, ctx); + // The handler would fall back to the provider-populated ctx.props on its + // own; passing authContext explicitly hands it the schema-validated copy and + // keeps this path symmetrical with self-hosted, which has no ctx.props. + return createRequestHandler(result.data, [ + new URL(getHostedBaseUrl()).hostname, + ])(request, env, ctx); } export async function handleSelfHostedOpenSeoMcpRequest( @@ -65,70 +58,21 @@ export async function handleSelfHostedOpenSeoMcpRequest( env: unknown, ctx: ExecutionContext, ): Promise { - // Self-hosted auth mirrors the app: local_noauth uses the local admin - // workspace, while cloudflare_access trusts Cloudflare's Access JWT. - // CORS/preflight still needs to reach the MCP transport before auth context - // exists, so OPTIONS intentionally bypasses context creation. + // Preflight does not carry an authenticated application context. if (request.method === "OPTIONS") { - return handleOpenSeoMcpRequest(request, undefined, env, ctx); + return createRequestHandler(undefined)(request, env, ctx); } - const baseUrl = getPublicOrigin(request); - const context = + const identity = authMode === "local_noauth" ? await resolveLocalNoAuthContext() : await resolveCloudflareAccessContext(request.headers); - const props = createWorkersOAuthMcpProps( - buildFirstPartyMcpAuthContext({ - userId: context.userId, - userEmail: context.userEmail, - organizationId: context.organizationId, - baseUrl, - }), - ); - - return handleOpenSeoMcpRequest(request, props, env, ctx); -} - -function handleOpenSeoMcpRequest( - request: Request, - props: ReturnType | undefined, - env: unknown, - ctx: ExecutionContext, -): Promise { - // Decline the optional standalone GET SSE stream: this server is stateless - // (POST returns JSON) and pushes no server-initiated messages, so the stream - // does nothing but leak memory — each GET is held open by a keepalive and - // pins a per-request McpServer (~5MB), so a few dozen concurrent clients OOM - // the 128MB isolate. 405 is the spec's "no stream" response; returning it - // before building the server means a GET allocates nothing. - if (request.method === "GET") { - return Promise.resolve( - new Response("Method Not Allowed", { - status: 405, - headers: { - Allow: "POST, DELETE, OPTIONS", - "Access-Control-Allow-Origin": "*", - }, - }), - ); - } - - const server = createOpenSeoMcpServer(); - const handler = createMcpHandler(server, { - route: MCP_ROUTE, - enableJsonResponse: true, - authContext: props ? { props } : undefined, - corsOptions: { - headers: - "Authorization, Content-Type, Last-Event-ID, mcp-protocol-version, mcp-session-id", - exposeHeaders: "mcp-protocol-version, mcp-session-id", - }, + const props = createWorkersOAuthMcpProps({ + userId: identity.userId, + userEmail: identity.userEmail, + organizationId: identity.organizationId, + baseUrl: getPublicOrigin(request), }); - if (!props) return handler(request, env, ctx); - - return runWithMcpToolAuthContext(props[MCP_AUTH_CONTEXT_PROP], () => - handler(request, env, ctx), - ); + return createRequestHandler(props)(request, env, ctx); } diff --git a/vitest.config.ts b/vitest.config.ts index 9857cb4..33de7e0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,5 +8,13 @@ export default defineConfig({ include: ["src/**/*.test.ts"], restoreMocks: true, clearMocks: true, + server: { + deps: { + // Processed by vitest (instead of loaded natively by node) so the + // oauth-refresh e2e test's cloudflare:workers mock reaches the real + // provider module. + inline: ["@cloudflare/workers-oauth-provider"], + }, + }, }, }); diff --git a/wrangler.jsonc b/wrangler.jsonc index 162e07f..6897419 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -72,7 +72,9 @@ }, ], "triggers": { - "crons": ["*/5 * * * *"], + // Every 5 min: rank checks + stale-audit reconcile. Daily: OAuth KV GC. + // Dispatched on controller.cron in src/server.ts `scheduled`. + "crons": ["*/5 * * * *", "17 3 * * *"], }, // This config serves local dev and Docker self-host only. All Cloudflare // deployments — previews, prod, self-host — go through Alchemy