From 600d148687986873e8cb47ae85a8885293f99adc Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 4 Sep 2026 14:38:47 -0700 Subject: [PATCH 01/33] feat: add scaffolding for TS Tasks SDK --- .github/workflows/ci.yml | 1 + .gitignore | 1 + README.md | 19 + package-lock.json | 1483 +++++++++++++++--- package.json | 13 +- packages/ext-tasks/LICENSE | 191 +++ packages/ext-tasks/README.md | 22 + packages/ext-tasks/package.json | 84 + packages/ext-tasks/scripts/check-exports.mjs | 36 + packages/ext-tasks/src/client/index.test.ts | 9 + packages/ext-tasks/src/client/index.ts | 4 + packages/ext-tasks/src/core/index.test.ts | 9 + packages/ext-tasks/src/core/index.ts | 5 + packages/ext-tasks/src/core/v1/index.test.ts | 9 + packages/ext-tasks/src/core/v1/index.ts | 4 + packages/ext-tasks/src/core/v2/index.test.ts | 9 + packages/ext-tasks/src/core/v2/index.ts | 4 + packages/ext-tasks/src/server/index.test.ts | 9 + packages/ext-tasks/src/server/index.ts | 4 + packages/ext-tasks/tsconfig.json | 14 + packages/ext-tasks/tsconfig.test.json | 13 + packages/ext-tasks/vitest.config.ts | 7 + tsconfig.package.json | 19 + 23 files changed, 1770 insertions(+), 199 deletions(-) create mode 100644 packages/ext-tasks/LICENSE create mode 100644 packages/ext-tasks/README.md create mode 100644 packages/ext-tasks/package.json create mode 100644 packages/ext-tasks/scripts/check-exports.mjs create mode 100644 packages/ext-tasks/src/client/index.test.ts create mode 100644 packages/ext-tasks/src/client/index.ts create mode 100644 packages/ext-tasks/src/core/index.test.ts create mode 100644 packages/ext-tasks/src/core/index.ts create mode 100644 packages/ext-tasks/src/core/v1/index.test.ts create mode 100644 packages/ext-tasks/src/core/v1/index.ts create mode 100644 packages/ext-tasks/src/core/v2/index.test.ts create mode 100644 packages/ext-tasks/src/core/v2/index.ts create mode 100644 packages/ext-tasks/src/server/index.test.ts create mode 100644 packages/ext-tasks/src/server/index.ts create mode 100644 packages/ext-tasks/tsconfig.json create mode 100644 packages/ext-tasks/tsconfig.test.json create mode 100644 packages/ext-tasks/vitest.config.ts create mode 100644 tsconfig.package.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6df5fb8..e2ae9f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,4 +17,5 @@ jobs: - run: npm ci - run: npm run check:schema:ts - run: npm run check:schema:json + - run: npm run check:package - run: npm run docs:build diff --git a/.gitignore b/.gitignore index 066bfe2..292adc3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ .claude/ .jj/ *.tsbuildinfo +*.tgz dist/ schema/**/generated/ .vitepress/cache diff --git a/README.md b/README.md index 3930420..223619f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,25 @@ npm run snapshot:schema -- YYYY-MM-DD ## Development +### SDK Package + +The redistributable TypeScript package lives in `packages/ext-tasks`. It is an +npm workspace that publishes as `@modelcontextprotocol/ext-tasks`. + +```bash +# Type-check and build the package +npm run check:package + +# Run the package tests in watch mode +npm run test:watch + +# Create the publishable tarball +npm run pack:package +``` + +The package intentionally has no root export. Consumers import `/core`, +`/core/v1`, `/core/v2`, `/client`, or the reserved `/server` subpath. + ### Schema Generation The draft JSON Schema is auto-generated from the TypeScript type definitions using [ts-to-zod](https://github.com/fabien0102/ts-to-zod) and Zod's `toJSONSchema()`. Do not hand-edit `schema.json` or `generated/schema.ts`. diff --git a/package-lock.json b/package-lock.json index 232a33c..b18a0cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { - "name": "@modelcontextprotocol/ext-tasks", + "name": "@modelcontextprotocol/ext-tasks-repository", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@modelcontextprotocol/ext-tasks", + "name": "@modelcontextprotocol/ext-tasks-repository", "version": "0.1.0", "license": "Apache-2.0", + "workspaces": [ + "packages/*" + ], "devDependencies": { "mermaid": "^11.17.0", "ts-to-zod": "^5.1.0", @@ -15,6 +18,7 @@ "typescript": "^5.0.0", "vitepress": "^1.6.4", "vitepress-plugin-mermaid": "^2.0.17", + "vitest": "^5.0.0", "zod": "^4.4.3" } }, @@ -899,6 +903,16 @@ "import-meta-resolve": "^4.2.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -906,6 +920,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@mermaid-js/mermaid-mindmap": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@mermaid-js/mermaid-mindmap/-/mermaid-mindmap-9.3.0.tgz", @@ -941,6 +966,30 @@ "@chevrotain/types": "~11.1.2" } }, + "node_modules/@modelcontextprotocol/ext-tasks": { + "resolved": "packages/ext-tasks", + "link": true + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@oclif/core": { "version": "4.11.2", "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.11.2.tgz", @@ -971,10 +1020,317 @@ "node": ">=18.0.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", - "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", "cpu": [ "arm" ], @@ -986,9 +1342,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", - "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", "cpu": [ "arm64" ], @@ -1000,9 +1356,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", - "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", "cpu": [ "arm64" ], @@ -1014,9 +1370,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", - "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", "cpu": [ "x64" ], @@ -1028,9 +1384,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", - "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", "cpu": [ "arm64" ], @@ -1042,9 +1398,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", - "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", "cpu": [ "x64" ], @@ -1056,13 +1412,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", - "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1070,13 +1429,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", - "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1084,13 +1446,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", - "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1098,13 +1463,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", - "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1112,13 +1480,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", - "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1126,13 +1497,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", - "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1140,13 +1514,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", - "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1154,13 +1531,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", - "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1168,13 +1548,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", - "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1182,13 +1565,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", - "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1196,13 +1582,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", - "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1210,13 +1599,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", - "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1224,13 +1616,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", - "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1238,9 +1633,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", - "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", "cpu": [ "x64" ], @@ -1252,9 +1647,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", - "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", "cpu": [ "arm64" ], @@ -1266,9 +1661,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", - "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", "cpu": [ "arm64" ], @@ -1280,9 +1675,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", - "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", "cpu": [ "ia32" ], @@ -1294,9 +1689,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", - "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", "cpu": [ "x64" ], @@ -1308,9 +1703,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", - "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", "cpu": [ "x64" ], @@ -1408,6 +1803,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -1692,6 +2098,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1804,18 +2217,62 @@ "d3-transition": "^3.0.1" } }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", "dev": true, "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/@vue/compiler-core": { @@ -2147,6 +2604,16 @@ "node": ">=14" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2198,6 +2665,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", @@ -2995,6 +3472,17 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -3075,6 +3563,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-toolkit": { "version": "1.47.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", @@ -3155,6 +3650,16 @@ "dev": true, "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fastdom": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", @@ -3504,6 +4009,291 @@ "dev": true, "license": "MIT" }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3935,6 +4725,20 @@ "license": "MIT", "optional": true }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -3992,9 +4796,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -4155,10 +4959,45 @@ "dev": true, "license": "Unlicense" }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, "node_modules/rollup": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", - "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", "dev": true, "license": "MIT", "dependencies": { @@ -4172,31 +5011,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.61.1", - "@rollup/rollup-android-arm64": "4.61.1", - "@rollup/rollup-darwin-arm64": "4.61.1", - "@rollup/rollup-darwin-x64": "4.61.1", - "@rollup/rollup-freebsd-arm64": "4.61.1", - "@rollup/rollup-freebsd-x64": "4.61.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", - "@rollup/rollup-linux-arm-musleabihf": "4.61.1", - "@rollup/rollup-linux-arm64-gnu": "4.61.1", - "@rollup/rollup-linux-arm64-musl": "4.61.1", - "@rollup/rollup-linux-loong64-gnu": "4.61.1", - "@rollup/rollup-linux-loong64-musl": "4.61.1", - "@rollup/rollup-linux-ppc64-gnu": "4.61.1", - "@rollup/rollup-linux-ppc64-musl": "4.61.1", - "@rollup/rollup-linux-riscv64-gnu": "4.61.1", - "@rollup/rollup-linux-riscv64-musl": "4.61.1", - "@rollup/rollup-linux-s390x-gnu": "4.61.1", - "@rollup/rollup-linux-x64-gnu": "4.61.1", - "@rollup/rollup-linux-x64-musl": "4.61.1", - "@rollup/rollup-openbsd-x64": "4.61.1", - "@rollup/rollup-openharmony-arm64": "4.61.1", - "@rollup/rollup-win32-arm64-msvc": "4.61.1", - "@rollup/rollup-win32-ia32-msvc": "4.61.1", - "@rollup/rollup-win32-x64-gnu": "4.61.1", - "@rollup/rollup-win32-x64-msvc": "4.61.1", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" } }, @@ -4265,6 +5105,13 @@ "@types/hast": "^3.0.4" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -4346,6 +5193,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/strictdom": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", @@ -4687,10 +5548,20 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -4698,9 +5569,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -4953,21 +5824,24 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -4976,23 +5850,33 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, - "less": { + "@vitejs/devtools": { "optional": true }, - "lightningcss": { + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -5009,10 +5893,72 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true } } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "node_modules/vitepress-plugin-mermaid": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/vitepress-plugin-mermaid/-/vitepress-plugin-mermaid-2.0.17.tgz", + "integrity": "sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@mermaid-js/mermaid-mindmap": "^9.3.0" + }, + "peerDependencies": { + "mermaid": "10 || 11", + "vitepress": "^1.0.0 || ^1.0.0-alpha" + } + }, + "node_modules/vitepress/node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", @@ -5029,7 +5975,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/android-arm": { + "node_modules/vitepress/node_modules/@esbuild/android-arm": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", @@ -5046,7 +5992,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { + "node_modules/vitepress/node_modules/@esbuild/android-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", @@ -5063,7 +6009,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/android-x64": { + "node_modules/vitepress/node_modules/@esbuild/android-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", @@ -5080,7 +6026,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "node_modules/vitepress/node_modules/@esbuild/darwin-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", @@ -5097,7 +6043,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "node_modules/vitepress/node_modules/@esbuild/darwin-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", @@ -5114,7 +6060,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "node_modules/vitepress/node_modules/@esbuild/freebsd-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", @@ -5131,7 +6077,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "node_modules/vitepress/node_modules/@esbuild/freebsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", @@ -5148,7 +6094,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { + "node_modules/vitepress/node_modules/@esbuild/linux-arm": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", @@ -5165,7 +6111,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "node_modules/vitepress/node_modules/@esbuild/linux-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", @@ -5182,7 +6128,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "node_modules/vitepress/node_modules/@esbuild/linux-ia32": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", @@ -5199,7 +6145,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "node_modules/vitepress/node_modules/@esbuild/linux-loong64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", @@ -5216,7 +6162,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "node_modules/vitepress/node_modules/@esbuild/linux-mips64el": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", @@ -5233,7 +6179,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "node_modules/vitepress/node_modules/@esbuild/linux-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", @@ -5250,7 +6196,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "node_modules/vitepress/node_modules/@esbuild/linux-riscv64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", @@ -5267,7 +6213,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "node_modules/vitepress/node_modules/@esbuild/linux-s390x": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", @@ -5284,7 +6230,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { + "node_modules/vitepress/node_modules/@esbuild/linux-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", @@ -5301,7 +6247,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "node_modules/vitepress/node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", @@ -5318,7 +6264,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "node_modules/vitepress/node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", @@ -5335,7 +6281,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "node_modules/vitepress/node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", @@ -5352,7 +6298,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "node_modules/vitepress/node_modules/@esbuild/win32-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", @@ -5369,7 +6315,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "node_modules/vitepress/node_modules/@esbuild/win32-ia32": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", @@ -5386,7 +6332,7 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { + "node_modules/vitepress/node_modules/@esbuild/win32-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", @@ -5403,7 +6349,21 @@ "node": ">=12" } }, - "node_modules/vite/node_modules/esbuild": { + "node_modules/vitepress/node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/vitepress/node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", @@ -5442,60 +6402,157 @@ "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/vitepress": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", - "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "node_modules/vitepress/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", "dependencies": { - "@docsearch/css": "3.8.2", - "@docsearch/js": "3.8.2", - "@iconify-json/simple-icons": "^1.2.21", - "@shikijs/core": "^2.1.0", - "@shikijs/transformers": "^2.1.0", - "@shikijs/types": "^2.1.0", - "@types/markdown-it": "^14.1.2", - "@vitejs/plugin-vue": "^5.2.1", - "@vue/devtools-api": "^7.7.0", - "@vue/shared": "^3.5.13", - "@vueuse/core": "^12.4.0", - "@vueuse/integrations": "^12.4.0", - "focus-trap": "^7.6.4", - "mark.js": "8.11.1", - "minisearch": "^7.1.1", - "shiki": "^2.1.0", - "vite": "^5.4.14", - "vue": "^3.5.13" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { - "vitepress": "bin/vitepress.js" + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "markdown-it-mathjax3": "^4", - "postcss": "^8" + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" }, "peerDependenciesMeta": { - "markdown-it-mathjax3": { + "@types/node": { "optional": true }, - "postcss": { + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { "optional": true } } }, - "node_modules/vitepress-plugin-mermaid": { - "version": "2.0.17", - "resolved": "https://registry.npmjs.org/vitepress-plugin-mermaid/-/vitepress-plugin-mermaid-2.0.17.tgz", - "integrity": "sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==", + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", "dev": true, "license": "MIT", - "optionalDependencies": { - "@mermaid-js/mermaid-mindmap": "^9.3.0" + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "mermaid": "10 || 11", - "vitepress": "^1.0.0 || ^1.0.0-alpha" + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/vue": { @@ -5520,6 +6577,23 @@ } } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", @@ -5617,6 +6691,19 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/ext-tasks": { + "name": "@modelcontextprotocol/ext-tasks", + "version": "0.1.0", + "license": "Apache-2.0", + "peerDependencies": { + "@modelcontextprotocol/client": "^2.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/client": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index f582d61..d2b87b4 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,21 @@ { - "name": "@modelcontextprotocol/ext-tasks", + "name": "@modelcontextprotocol/ext-tasks-repository", "private": true, "version": "0.1.0", "type": "module", "description": "MCP Tasks Extension - extension for task management in the Model Context Protocol", "license": "Apache-2.0", + "workspaces": [ + "packages/*" + ], "scripts": { + "build": "npm run build:package", + "build:package": "npm run build --workspace @modelcontextprotocol/ext-tasks", + "check": "npm run check:schema && npm run check:package", + "check:package": "npm run check --workspace @modelcontextprotocol/ext-tasks", + "pack:package": "npm pack --workspace @modelcontextprotocol/ext-tasks", + "test": "npm run test --workspace @modelcontextprotocol/ext-tasks", + "test:watch": "npm run test:watch --workspace @modelcontextprotocol/ext-tasks", "generate:schemas": "tsx scripts/generate-schemas.ts", "snapshot:schema": "tsx scripts/snapshot-schema.ts", "fetch:spec-schema": "tsx scripts/fetch-spec-schema.ts", @@ -21,6 +31,7 @@ "ts-to-zod": "^5.1.0", "tsx": "^4.21.0", "typescript": "^5.0.0", + "vitest": "^5.0.0", "vitepress": "^1.6.4", "vitepress-plugin-mermaid": "^2.0.17", "zod": "^4.4.3" diff --git a/packages/ext-tasks/LICENSE b/packages/ext-tasks/LICENSE new file mode 100644 index 0000000..8372134 --- /dev/null +++ b/packages/ext-tasks/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 The Model Context Protocol Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/ext-tasks/README.md b/packages/ext-tasks/README.md new file mode 100644 index 0000000..b8bca8f --- /dev/null +++ b/packages/ext-tasks/README.md @@ -0,0 +1,22 @@ +# `@modelcontextprotocol/ext-tasks` + +Protocol declarations and requester-side lifecycle support for MCP Tasks. + +This package intentionally has no root entry point. Import the role or protocol +generation needed by the application: + +```ts +import { withTasks } from "@modelcontextprotocol/ext-tasks/client"; +``` + +The public package subpaths are: + +- `@modelcontextprotocol/ext-tasks/core` +- `@modelcontextprotocol/ext-tasks/core/v1` +- `@modelcontextprotocol/ext-tasks/core/v2` +- `@modelcontextprotocol/ext-tasks/client` +- `@modelcontextprotocol/ext-tasks/server` (reserved for receiver-side support) + +The package is currently scaffolded for implementation. Source lives in `src/` +and is emitted as ESM JavaScript, TypeScript declarations, and source maps in +`dist/`. diff --git a/packages/ext-tasks/package.json b/packages/ext-tasks/package.json new file mode 100644 index 0000000..940d74b --- /dev/null +++ b/packages/ext-tasks/package.json @@ -0,0 +1,84 @@ +{ + "name": "@modelcontextprotocol/ext-tasks", + "version": "0.1.0", + "description": "Client and protocol support for MCP Tasks", + "license": "Apache-2.0", + "type": "module", + "sideEffects": false, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "exports": { + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js" + }, + "./core/v1": { + "types": "./dist/core/v1/index.d.ts", + "import": "./dist/core/v1/index.js" + }, + "./core/v2": { + "types": "./dist/core/v2/index.d.ts", + "import": "./dist/core/v2/index.js" + }, + "./client": { + "types": "./dist/client/index.d.ts", + "import": "./dist/client/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + } + }, + "typesVersions": { + "*": { + "core": [ + "dist/core/index.d.ts" + ], + "core/v1": [ + "dist/core/v1/index.d.ts" + ], + "core/v2": [ + "dist/core/v2/index.d.ts" + ], + "client": [ + "dist/client/index.d.ts" + ], + "server": [ + "dist/server/index.d.ts" + ] + } + }, + "peerDependencies": { + "@modelcontextprotocol/client": "^2.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/client": { + "optional": true + } + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/ext-tasks.git", + "directory": "packages/ext-tasks" + }, + "bugs": { + "url": "https://github.com/modelcontextprotocol/ext-tasks/issues" + }, + "homepage": "https://github.com/modelcontextprotocol/ext-tasks#readme", + "scripts": { + "clean": "tsc -b tsconfig.json --clean", + "build": "npm run clean && tsc -b tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "check:exports": "node scripts/check-exports.mjs", + "check": "npm run typecheck && npm run test && npm run build && npm run check:exports", + "prepack": "npm run check" + } +} diff --git a/packages/ext-tasks/scripts/check-exports.mjs b/packages/ext-tasks/scripts/check-exports.mjs new file mode 100644 index 0000000..7c4d568 --- /dev/null +++ b/packages/ext-tasks/scripts/check-exports.mjs @@ -0,0 +1,36 @@ +import { access, readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const packageDirectory = fileURLToPath(new URL("../", import.meta.url)); +const manifest = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), +); + +const expectedExports = new Set([ + "./core", + "./core/v1", + "./core/v2", + "./client", + "./server", +]); +const actualExports = new Set(Object.keys(manifest.exports ?? {})); + +if (actualExports.has(".")) { + throw new Error("The package must not expose a root entry point."); +} + +for (const subpath of expectedExports) { + if (!actualExports.delete(subpath)) { + throw new Error(`Missing package export: ${subpath}`); + } + + const conditions = manifest.exports[subpath]; + await access(new URL(conditions.import, `file://${packageDirectory}/`)); + await access(new URL(conditions.types, `file://${packageDirectory}/`)); +} + +if (actualExports.size > 0) { + throw new Error( + `Unexpected package exports: ${[...actualExports].sort().join(", ")}`, + ); +} diff --git a/packages/ext-tasks/src/client/index.test.ts b/packages/ext-tasks/src/client/index.test.ts new file mode 100644 index 0000000..ad79cfc --- /dev/null +++ b/packages/ext-tasks/src/client/index.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import * as client from "./index.js"; + +describe("client entry point", () => { + it("can be imported", () => { + expect(client).toBeTypeOf("object"); + }); +}); diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts new file mode 100644 index 0000000..f9bbd62 --- /dev/null +++ b/packages/ext-tasks/src/client/index.ts @@ -0,0 +1,4 @@ +/** + * Requester-side MCP Tasks session and execution support. + */ +export {}; diff --git a/packages/ext-tasks/src/core/index.test.ts b/packages/ext-tasks/src/core/index.test.ts new file mode 100644 index 0000000..1815aee --- /dev/null +++ b/packages/ext-tasks/src/core/index.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import * as core from "./index.js"; + +describe("core entry point", () => { + it("can be imported", () => { + expect(core).toBeTypeOf("object"); + }); +}); diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts new file mode 100644 index 0000000..058b6ab --- /dev/null +++ b/packages/ext-tasks/src/core/index.ts @@ -0,0 +1,5 @@ +/** + * Role-neutral MCP Tasks protocol material shared by client and server + * implementations. + */ +export {}; diff --git a/packages/ext-tasks/src/core/v1/index.test.ts b/packages/ext-tasks/src/core/v1/index.test.ts new file mode 100644 index 0000000..13a0abb --- /dev/null +++ b/packages/ext-tasks/src/core/v1/index.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import * as coreV1 from "./index.js"; + +describe("core/v1 entry point", () => { + it("can be imported", () => { + expect(coreV1).toBeTypeOf("object"); + }); +}); diff --git a/packages/ext-tasks/src/core/v1/index.ts b/packages/ext-tasks/src/core/v1/index.ts new file mode 100644 index 0000000..e06dd60 --- /dev/null +++ b/packages/ext-tasks/src/core/v1/index.ts @@ -0,0 +1,4 @@ +/** + * MCP Tasks V1 wire declarations, codecs, and generation-specific guards. + */ +export {}; diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts new file mode 100644 index 0000000..3203ed3 --- /dev/null +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import * as coreV2 from "./index.js"; + +describe("core/v2 entry point", () => { + it("can be imported", () => { + expect(coreV2).toBeTypeOf("object"); + }); +}); diff --git a/packages/ext-tasks/src/core/v2/index.ts b/packages/ext-tasks/src/core/v2/index.ts new file mode 100644 index 0000000..5afff63 --- /dev/null +++ b/packages/ext-tasks/src/core/v2/index.ts @@ -0,0 +1,4 @@ +/** + * MCP Tasks V2 wire declarations, codecs, guards, and request bindings. + */ +export {}; diff --git a/packages/ext-tasks/src/server/index.test.ts b/packages/ext-tasks/src/server/index.test.ts new file mode 100644 index 0000000..a2d80c1 --- /dev/null +++ b/packages/ext-tasks/src/server/index.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import * as server from "./index.js"; + +describe("server entry point", () => { + it("can be imported", () => { + expect(server).toBeTypeOf("object"); + }); +}); diff --git a/packages/ext-tasks/src/server/index.ts b/packages/ext-tasks/src/server/index.ts new file mode 100644 index 0000000..976d248 --- /dev/null +++ b/packages/ext-tasks/src/server/index.ts @@ -0,0 +1,4 @@ +/** + * Reserved entry point for future receiver-side MCP Tasks lifecycle support. + */ +export {}; diff --git a/packages/ext-tasks/tsconfig.json b/packages/ext-tasks/tsconfig.json new file mode 100644 index 0000000..c0f03d0 --- /dev/null +++ b/packages/ext-tasks/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.package.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "tsBuildInfoFile": "node_modules/.cache/ext-tasks.tsbuildinfo" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts" + ] +} diff --git a/packages/ext-tasks/tsconfig.test.json b/packages/ext-tasks/tsconfig.test.json new file mode 100644 index 0000000..dc5de34 --- /dev/null +++ b/packages/ext-tasks/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.package.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false, + "isolatedDeclarations": false, + "noEmit": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/ext-tasks/vitest.config.ts b/packages/ext-tasks/vitest.config.ts new file mode 100644 index 0000000..ae847ff --- /dev/null +++ b/packages/ext-tasks/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/tsconfig.package.json b/tsconfig.package.json new file mode 100644 index 0000000..fb14f70 --- /dev/null +++ b/tsconfig.package.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "forceConsistentCasingInFileNames": true, + "isolatedDeclarations": true, + "lib": ["ES2022", "DOM", "ESNext.Disposable"], + "module": "NodeNext", + "moduleDetection": "force", + "moduleResolution": "NodeNext", + "noEmitOnError": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ES2022", + "verbatimModuleSyntax": true + } +} From 75e56f9c11e5055d2ff76237cdf1985e98e214c9 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 4 Sep 2026 16:40:24 -0700 Subject: [PATCH 02/33] feat(core): implement task protocol wire layers - add provenance-checked V1 and V2 schema artifacts - implement generation-specific declarations and strict codecs - cover protocol invariants with property-based tests --- package-lock.json | 190 + packages/ext-tasks/package.json | 7 +- packages/ext-tasks/schema/v1/schema.json | 4055 +++++++++++++++++ packages/ext-tasks/schema/v1/schema.ts | 2578 +++++++++++ packages/ext-tasks/schema/v2/schema.json | 3145 +++++++++++++ packages/ext-tasks/schema/v2/schema.ts | 350 ++ .../scripts/check-schema-provenance.mjs | 24 + packages/ext-tasks/src/core/index.test.ts | 59 +- packages/ext-tasks/src/core/index.ts | 115 +- packages/ext-tasks/src/core/v1/index.test.ts | 140 +- packages/ext-tasks/src/core/v1/index.ts | 269 +- packages/ext-tasks/src/core/v2/index.test.ts | 142 +- packages/ext-tasks/src/core/v2/index.ts | 388 +- 13 files changed, 11436 insertions(+), 26 deletions(-) create mode 100644 packages/ext-tasks/schema/v1/schema.json create mode 100644 packages/ext-tasks/schema/v1/schema.ts create mode 100644 packages/ext-tasks/schema/v2/schema.json create mode 100644 packages/ext-tasks/schema/v2/schema.ts create mode 100644 packages/ext-tasks/scripts/check-schema-provenance.mjs diff --git a/package-lock.json b/package-lock.json index b18a0cc..f304878 100644 --- a/package-lock.json +++ b/package-lock.json @@ -966,6 +966,38 @@ "@chevrotain/types": "~11.1.2" } }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/ext-tasks": { "resolved": "packages/ext-tasks", "link": true @@ -2866,6 +2898,21 @@ "layout-base": "^1.0.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3650,6 +3697,29 @@ "dev": true, "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3660,6 +3730,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fastdom": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", @@ -3951,6 +4044,13 @@ "node": ">=8" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -3969,6 +4069,16 @@ "node": ">=10" } }, + "node_modules/jose": { + "version": "6.2.11", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.11.tgz", + "integrity": "sha512-A5NPn7g8EAzGU3IzRs+Yiq8K5n3ypYS75M5+KKiVHdUexfpWK1kP4ZMq7QnTGDoMj6TJ1dtcEJjW60yZDXS4hg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/katex": { "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", @@ -4781,6 +4891,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -4808,6 +4928,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -4877,6 +5007,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5088,6 +5235,29 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shiki": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", @@ -6577,6 +6747,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -6696,6 +6882,10 @@ "name": "@modelcontextprotocol/ext-tasks", "version": "0.1.0", "license": "Apache-2.0", + "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "fast-check": "^4.9.0" + }, "peerDependencies": { "@modelcontextprotocol/client": "^2.0.0" }, diff --git a/packages/ext-tasks/package.json b/packages/ext-tasks/package.json index 940d74b..14e96db 100644 --- a/packages/ext-tasks/package.json +++ b/packages/ext-tasks/package.json @@ -78,7 +78,12 @@ "test": "vitest run", "test:watch": "vitest", "check:exports": "node scripts/check-exports.mjs", - "check": "npm run typecheck && npm run test && npm run build && npm run check:exports", + "check:provenance": "node scripts/check-schema-provenance.mjs", + "check": "npm run check:provenance && npm run typecheck && npm run test && npm run build && npm run check:exports", "prepack": "npm run check" + }, + "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "fast-check": "^4.9.0" } } diff --git a/packages/ext-tasks/schema/v1/schema.json b/packages/ext-tasks/schema/v1/schema.json new file mode 100644 index 0000000..17cdb3d --- /dev/null +++ b/packages/ext-tasks/schema/v1/schema.json @@ -0,0 +1,4055 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended audience of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/$defs/Role" + }, + "type": "array" + }, + "lastModified": { + "description": "The moment the resource was last modified, as an ISO 8601 formatted string.\n\nShould be an ISO 8601 formatted string (e.g., \"2025-01-12T15:00:58Z\").\n\nExamples: last activity timestamp in an open file, timestamp when the resource\nwas attached, etc.", + "type": "string" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BaseMetadata": { + "description": "Base interface for metadata with name (identifier) and title (display name) properties.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "BooleanSchema": { + "properties": { + "default": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CallToolRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CallToolRequestParams": { + "description": "Parameters for a `tools/call` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "arguments": { + "additionalProperties": {}, + "description": "Arguments to use for the tool call.", + "type": "object" + }, + "name": { + "description": "The name of the tool.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The server's response to a tool call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "description": "A list of content objects that represent the unstructured result of the tool call.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "type": "boolean" + }, + "structuredContent": { + "additionalProperties": {}, + "description": "An optional JSON object that represents the structured result of the tool call.", + "type": "object" + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "CancelTaskRequest": { + "description": "A request to cancel a task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/cancel", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to cancel.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelTaskResult": { + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "The response to a tasks/cancel request." + }, + "CancelledNotification": { + "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.\n\nA client MUST NOT attempt to cancel its `initialize` request.\n\nFor task cancellation, use the `tasks/cancel` request instead of this notification.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelledNotificationParams": { + "description": "Parameters for a `notifications/cancelled` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/$defs/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction.\nThis MUST be provided for cancelling non-task requests.\nThis MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead)." + } + }, + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "elicitation": { + "description": "Present if the client supports elicitation from the server.", + "properties": { + "form": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "url": { + "additionalProperties": true, + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": { + "listChanged": { + "description": "Whether the client supports notifications for changes to the roots list.", + "type": "boolean" + } + }, + "type": "object" + }, + "sampling": { + "description": "Present if the client supports sampling from an LLM.", + "properties": { + "context": { + "additionalProperties": true, + "description": "Whether the client supports context inclusion via includeContext parameter.\nIf not declared, servers SHOULD only use `includeContext: \"none\"` (or omit it).", + "properties": {}, + "type": "object" + }, + "tools": { + "additionalProperties": true, + "description": "Whether the client supports tool use via tools and toolChoice parameters.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "tasks": { + "description": "Present if the client supports task-augmented requests.", + "properties": { + "cancel": { + "additionalProperties": true, + "description": "Whether this client supports tasks/cancel.", + "properties": {}, + "type": "object" + }, + "list": { + "additionalProperties": true, + "description": "Whether this client supports tasks/list.", + "properties": {}, + "type": "object" + }, + "requests": { + "description": "Specifies which request types can be augmented with tasks.", + "properties": { + "elicitation": { + "description": "Task support for elicitation-related requests.", + "properties": { + "create": { + "additionalProperties": true, + "description": "Whether the client supports task-augmented elicitation/create requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "sampling": { + "description": "Task support for sampling-related requests.", + "properties": { + "createMessage": { + "additionalProperties": true, + "description": "Whether the client supports task-augmented sampling/createMessage requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/InitializedNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/TaskStatusNotification" + }, + { + "$ref": "#/$defs/RootsListChangedNotification" + } + ] + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/$defs/InitializeRequest" + }, + { + "$ref": "#/$defs/PingRequest" + }, + { + "$ref": "#/$defs/ListResourcesRequest" + }, + { + "$ref": "#/$defs/ListResourceTemplatesRequest" + }, + { + "$ref": "#/$defs/ReadResourceRequest" + }, + { + "$ref": "#/$defs/SubscribeRequest" + }, + { + "$ref": "#/$defs/UnsubscribeRequest" + }, + { + "$ref": "#/$defs/ListPromptsRequest" + }, + { + "$ref": "#/$defs/GetPromptRequest" + }, + { + "$ref": "#/$defs/ListToolsRequest" + }, + { + "$ref": "#/$defs/CallToolRequest" + }, + { + "$ref": "#/$defs/GetTaskRequest" + }, + { + "$ref": "#/$defs/GetTaskPayloadRequest" + }, + { + "$ref": "#/$defs/CancelTaskRequest" + }, + { + "$ref": "#/$defs/ListTasksRequest" + }, + { + "$ref": "#/$defs/SetLevelRequest" + }, + { + "$ref": "#/$defs/CompleteRequest" + } + ] + }, + "ClientResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/GetTaskResult", + "description": "The response to a tasks/get request." + }, + { + "$ref": "#/$defs/GetTaskPayloadResult" + }, + { + "$ref": "#/$defs/CancelTaskResult", + "description": "The response to a tasks/cancel request." + }, + { + "$ref": "#/$defs/ListTasksResult" + }, + { + "$ref": "#/$defs/CreateMessageResult" + }, + { + "$ref": "#/$defs/ListRootsResult" + }, + { + "$ref": "#/$defs/ElicitResult" + } + ] + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CompleteRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CompleteRequestParams": { + "description": "Parameters for a `completion/complete` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "context": { + "description": "Additional, optional context for completions", + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Previously-resolved variables in a URI template or prompt.", + "type": "object" + } + }, + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/$defs/PromptReference" + }, + { + "$ref": "#/$defs/ResourceTemplateReference" + } + ] + } + }, + "required": [ + "argument", + "ref" + ], + "type": "object" + }, + "CompleteResult": { + "description": "The server's response to a completion/complete request", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "values" + ], + "type": "object" + } + }, + "required": [ + "completion" + ], + "type": "object" + }, + "ContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ResourceLink" + }, + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CreateMessageRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CreateMessageRequestParams": { + "description": "Parameters for a `sampling/createMessage` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt.\nThe client MAY ignore this request.\n\nDefault is \"none\". Values \"thisServer\" and \"allServers\" are soft-deprecated. Servers SHOULD only use these values if the client\ndeclares ClientCapabilities.sampling.context. These values may be removed in future spec releases.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The requested maximum number of tokens to sample (to prevent runaway completions).\n\nThe client MAY choose to sample fewer tokens than the requested maximum.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/$defs/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "additionalProperties": true, + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific.", + "properties": {}, + "type": "object" + }, + "modelPreferences": { + "$ref": "#/$defs/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + }, + "temperature": { + "type": "number" + }, + "toolChoice": { + "$ref": "#/$defs/ToolChoice", + "description": "Controls how the model uses tools.\nThe client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared.\nDefault is `{ mode: \"auto\" }`." + }, + "tools": { + "description": "Tools that the model may use during generation.\nThe client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared.", + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The client's response to a sampling/createMessage request from the server.\nThe client should inform the user before returning the sampled message, to allow them\nto inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/$defs/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.\n\nStandard values:\n- \"endTurn\": Natural end of the assistant's turn\n- \"stopSequence\": A stop sequence was encountered\n- \"maxTokens\": Maximum token limit was reached\n- \"toolUse\": The model wants to use one or more tools\n\nThis field is an open string to allow for provider-specific stop reasons.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "CreateTaskResult": { + "description": "A response to a task-augmented request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "task": { + "$ref": "#/$defs/Task" + } + }, + "required": [ + "task" + ], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "ElicitRequest": { + "description": "A request from the server to elicit additional information from the user via the client.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "elicitation/create", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ElicitRequestFormParams": { + "description": "The parameters for a request to elicit non-sensitive information from the user via a form in the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "message": { + "description": "The message to present to the user describing what information is being requested.", + "type": "string" + }, + "mode": { + "const": "form", + "description": "The elicitation mode.", + "type": "string" + }, + "requestedSchema": { + "description": "A restricted subset of JSON Schema.\nOnly top-level properties are allowed, without nesting.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "$ref": "#/$defs/PrimitiveSchemaDefinition" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "required": [ + "message", + "requestedSchema" + ], + "type": "object" + }, + "ElicitRequestParams": { + "anyOf": [ + { + "$ref": "#/$defs/ElicitRequestURLParams" + }, + { + "$ref": "#/$defs/ElicitRequestFormParams" + } + ], + "description": "The parameters for a request to elicit additional information from the user via the client." + }, + "ElicitRequestURLParams": { + "description": "The parameters for a request to elicit information from the user via a URL in the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "elicitationId": { + "description": "The ID of the elicitation, which must be unique within the context of the server.\nThe client MUST treat this ID as an opaque value.", + "type": "string" + }, + "message": { + "description": "The message to present to the user explaining why the interaction is needed.", + "type": "string" + }, + "mode": { + "const": "url", + "description": "The elicitation mode.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + }, + "url": { + "description": "The URL that the user should navigate to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + }, + "ElicitResult": { + "description": "The client's response to an elicitation request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "action": { + "description": "The user action in response to the elicitation.\n- \"accept\": User submitted the form/confirmed the action\n- \"decline\": User explicitly decline the action\n- \"cancel\": User dismissed without making an explicit choice", + "enum": [ + "accept", + "cancel", + "decline" + ], + "type": "string" + }, + "content": { + "additionalProperties": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "description": "The submitted form data, only present when action is \"accept\" and mode was \"form\".\nContains values matching the requested schema.\nOmitted for out-of-band mode responses.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "ElicitationCompleteNotification": { + "description": "An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/elicitation/complete", + "type": "string" + }, + "params": { + "properties": { + "elicitationId": { + "description": "The ID of the elicitation that completed.", + "type": "string" + } + }, + "required": [ + "elicitationId" + ], + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/$defs/Result" + }, + "EnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ] + }, + "Error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "$ref": "#/$defs/GetPromptRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetPromptRequestParams": { + "description": "Parameters for a `prompts/get` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "GetPromptResult": { + "description": "The server's response to a prompts/get request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/$defs/PromptMessage" + }, + "type": "array" + } + }, + "required": [ + "messages" + ], + "type": "object" + }, + "GetTaskPayloadRequest": { + "description": "A request to retrieve the result of a completed task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/result", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to retrieve results for.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetTaskPayloadResult": { + "additionalProperties": {}, + "description": "The response to a tasks/result request.\nThe structure matches the result type of the original request.\nFor example, a tools/call task would return the CallToolResult structure.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "GetTaskRequest": { + "description": "A request to retrieve the state of a task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/get", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to query.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetTaskResult": { + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "The response to a tasks/get request." + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD takes steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `light` indicates\nthe icon is designed to be used with a light background, and `dark` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "dark", + "light" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "Icons": { + "description": "Base interface to add `icons` property.", + "properties": { + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + } + }, + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the MCP implementation.", + "properties": { + "description": { + "description": "An optional human-readable description of what this implementation does.\n\nThis can be used by clients or servers to provide context about their purpose\nand capabilities. For example, a server might describe the types of resources\nor tools it provides, while a client might describe its intended use case.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "description": "An optional URL of the website for this implementation.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InitializeRequest": { + "description": "This request is sent from the client to the server when it first connects, asking it to begin initialization.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "initialize", + "type": "string" + }, + "params": { + "$ref": "#/$defs/InitializeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "InitializeRequestParams": { + "description": "Parameters for an `initialize` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "capabilities": { + "$ref": "#/$defs/ClientCapabilities" + }, + "clientInfo": { + "$ref": "#/$defs/Implementation" + }, + "protocolVersion": { + "description": "The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well.", + "type": "string" + } + }, + "required": [ + "capabilities", + "clientInfo", + "protocolVersion" + ], + "type": "object" + }, + "InitializeResult": { + "description": "After receiving an initialize request from the client, the server sends this response.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "capabilities": { + "$ref": "#/$defs/ServerCapabilities" + }, + "instructions": { + "description": "Instructions describing how to use the server and its features.\n\nThis can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a \"hint\" to the model. For example, this information MAY be added to the system prompt.", + "type": "string" + }, + "protocolVersion": { + "description": "The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect.", + "type": "string" + }, + "serverInfo": { + "$ref": "#/$defs/Implementation" + } + }, + "required": [ + "capabilities", + "protocolVersion", + "serverInfo" + ], + "type": "object" + }, + "InitializedNotification": { + "description": "This notification is sent from the client to the server after initialization has finished.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/initialized", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCErrorResponse": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "$ref": "#/$defs/JSONRPCNotification" + }, + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCResponse": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "A response to a request, containing either the result or error." + }, + "JSONRPCResultResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/Result" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "LegacyTitledEnumSchema": { + "description": "Use TitledSingleSelectEnumSchema instead.\nThis interface will be removed in a future version.", + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "description": "(Legacy) Display names for enum values.\nNon-standard according to JSON schema 2020-12.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListPromptsResult": { + "description": "The server's response to a prompts/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/$defs/Prompt" + }, + "type": "array" + } + }, + "required": [ + "prompts" + ], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The server's response to a resources/templates/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/$defs/ResourceTemplate" + }, + "type": "array" + } + }, + "required": [ + "resourceTemplates" + ], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListResourcesResult": { + "description": "The server's response to a resources/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/$defs/Resource" + }, + "type": "array" + } + }, + "required": [ + "resources" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The client's response to a roots/list request from the server.\nThis result contains an array of Root objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "roots": { + "items": { + "$ref": "#/$defs/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "ListTasksRequest": { + "description": "A request to retrieve a list of tasks.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListTasksResult": { + "description": "The response to a tasks/list request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tasks": { + "items": { + "$ref": "#/$defs/Task" + }, + "type": "array" + } + }, + "required": [ + "tasks" + ], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListToolsResult": { + "description": "The server's response to a tools/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "tools" + ], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "JSONRPCNotification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "$ref": "#/$defs/LoggingMessageNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "LoggingMessageNotificationParams": { + "description": "Parameters for a `notifications/message` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": [ + "data", + "level" + ], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/$defs/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "MultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "NotificationParams": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "NumberSchema": { + "properties": { + "default": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "maximum": { + "type": "integer" + }, + "minimum": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "enum": [ + "integer", + "number" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "PaginatedRequestParams": { + "description": "Common parameters for paginated requests.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + } + }, + "type": "object" + }, + "PingRequest": { + "description": "A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "ping", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "PrimitiveSchemaDefinition": { + "anyOf": [ + { + "$ref": "#/$defs/StringSchema" + }, + { + "$ref": "#/$defs/NumberSchema" + }, + { + "$ref": "#/$defs/BooleanSchema" + }, + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ], + "description": "Restricted schema definitions that only allow primitive types\nwithout nested objects or arrays." + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ProgressNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ProgressNotificationParams": { + "description": "Parameters for a `notifications/progress` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": [ + "progress", + "progressToken" + ], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": [ + "string", + "integer" + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/$defs/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to `SamplingMessage`, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "$ref": "#/$defs/ContentBlock" + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ReadResourceRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ReadResourceRequestParams": { + "description": "Parameters for a `resources/read` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ReadResourceResult": { + "description": "The server's response to a resources/read request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "type": "object" + }, + "RelatedTaskMetadata": { + "description": "Metadata for associating messages with a task.\nInclude this in the `_meta` field under the key `io.modelcontextprotocol/related-task`.", + "properties": { + "taskId": { + "description": "The task identifier this message is associated with.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "RequestParams": { + "description": "Common params for any request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "type", + "uri" + ], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ResourceRequestParams": { + "description": "Common parameters when working with resources.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResourceTemplateReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ResourceUpdatedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ResourceUpdatedNotificationParams": { + "description": "Parameters for a `notifications/resources/updated` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with file:// for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "RootsListChangedNotification": { + "description": "A notification from the client to the server, informing it that the list of roots has changed.\nThis notification should be sent whenever the client adds, removes, or modifies any root.\nThe server should then request an updated list of roots using the ListRootsRequest.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/roots/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "SamplingMessageContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + } + ] + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "additionalProperties": true, + "description": "Present if the server supports argument autocompletion suggestions.", + "properties": {}, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "logging": { + "additionalProperties": true, + "description": "Present if the server supports sending log messages to the client.", + "properties": {}, + "type": "object" + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tasks": { + "description": "Present if the server supports task-augmented requests.", + "properties": { + "cancel": { + "additionalProperties": true, + "description": "Whether this server supports tasks/cancel.", + "properties": {}, + "type": "object" + }, + "list": { + "additionalProperties": true, + "description": "Whether this server supports tasks/list.", + "properties": {}, + "type": "object" + }, + "requests": { + "description": "Specifies which request types can be augmented with tasks.", + "properties": { + "tools": { + "description": "Task support for tool-related requests.", + "properties": { + "call": { + "additionalProperties": true, + "description": "Whether the server supports task-augmented tools/call requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/ResourceListChangedNotification" + }, + { + "$ref": "#/$defs/ResourceUpdatedNotification" + }, + { + "$ref": "#/$defs/PromptListChangedNotification" + }, + { + "$ref": "#/$defs/ToolListChangedNotification" + }, + { + "$ref": "#/$defs/TaskStatusNotification" + }, + { + "$ref": "#/$defs/LoggingMessageNotification" + }, + { + "$ref": "#/$defs/ElicitationCompleteNotification" + } + ] + }, + "ServerRequest": { + "anyOf": [ + { + "$ref": "#/$defs/PingRequest" + }, + { + "$ref": "#/$defs/GetTaskRequest" + }, + { + "$ref": "#/$defs/GetTaskPayloadRequest" + }, + { + "$ref": "#/$defs/CancelTaskRequest" + }, + { + "$ref": "#/$defs/ListTasksRequest" + }, + { + "$ref": "#/$defs/CreateMessageRequest" + }, + { + "$ref": "#/$defs/ListRootsRequest" + }, + { + "$ref": "#/$defs/ElicitRequest" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/InitializeResult" + }, + { + "$ref": "#/$defs/ListResourcesResult" + }, + { + "$ref": "#/$defs/ListResourceTemplatesResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + }, + { + "$ref": "#/$defs/ListPromptsResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + }, + { + "$ref": "#/$defs/ListToolsResult" + }, + { + "$ref": "#/$defs/CallToolResult" + }, + { + "$ref": "#/$defs/GetTaskResult", + "description": "The response to a tasks/get request." + }, + { + "$ref": "#/$defs/GetTaskPayloadResult" + }, + { + "$ref": "#/$defs/CancelTaskResult", + "description": "The response to a tasks/cancel request." + }, + { + "$ref": "#/$defs/ListTasksResult" + }, + { + "$ref": "#/$defs/CompleteResult" + } + ] + }, + "SetLevelRequest": { + "description": "A request from the client to the server, to enable or adjust logging.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "logging/setLevel", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SetLevelRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SetLevelRequestParams": { + "description": "Parameters for a `logging/setLevel` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message." + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "SingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + } + ] + }, + "StringSchema": { + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "enum": [ + "date", + "date-time", + "email", + "uri" + ], + "type": "string" + }, + "maxLength": { + "type": "integer" + }, + "minLength": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "SubscribeRequest": { + "description": "Sent from the client to request resources/updated notifications from the server whenever a particular resource changes.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/subscribe", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscribeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscribeRequestParams": { + "description": "Parameters for a `resources/subscribe` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Task": { + "description": "Data associated with a task.", + "properties": { + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval in milliseconds.", + "type": "integer" + }, + "status": { + "$ref": "#/$defs/TaskStatus", + "description": "Current task state." + }, + "statusMessage": { + "description": "Optional human-readable message describing the current task state.\nThis can provide context for any status, including:\n- Reasons for \"cancelled\" status\n- Summaries for \"completed\" status\n- Diagnostic information for \"failed\" status (e.g., error details, what went wrong)", + "type": "string" + }, + "taskId": { + "description": "The task identifier.", + "type": "string" + }, + "ttl": { + "description": "Actual retention duration from creation in milliseconds, null for unlimited.", + "type": "integer" + } + }, + "required": [ + "createdAt", + "lastUpdatedAt", + "status", + "taskId", + "ttl" + ], + "type": "object" + }, + "TaskAugmentedRequestParams": { + "description": "Common params for any task-augmented request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "type": "object" + }, + "TaskMetadata": { + "description": "Metadata for augmenting a request with task execution.\nInclude this in the `task` field of the request parameters.", + "properties": { + "ttl": { + "description": "Requested duration in milliseconds to retain task from creation.", + "type": "integer" + } + }, + "type": "object" + }, + "TaskStatus": { + "description": "The status of a task.", + "enum": [ + "cancelled", + "completed", + "failed", + "input_required", + "working" + ], + "type": "string" + }, + "TaskStatusNotification": { + "description": "An optional notification from the receiver to the requestor, informing them that a task's status has changed. Receivers are not required to send these notifications.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tasks/status", + "type": "string" + }, + "params": { + "$ref": "#/$defs/TaskStatusNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "TaskStatusNotificationParams": { + "allOf": [ + { + "$ref": "#/$defs/NotificationParams" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "Parameters for a `notifications/tasks/status` notification." + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "TitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for array items with enum options and display labels.", + "properties": { + "anyOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The constant enum value.", + "type": "string" + }, + "title": { + "description": "Display title for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "TitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "oneOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The enum value.", + "type": "string" + }, + "title": { + "description": "Display label for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/ToolAnnotations", + "description": "Optional additional tool information.\n\nDisplay name precedence order is: title, annotations.title, then name." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "execution": { + "$ref": "#/$defs/ToolExecution", + "description": "Execution-related properties for this tool." + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "outputSchema": { + "description": "An optional JSON Schema object defining the structure of the tool's output returned in\nthe structuredContent field of a CallToolResult.\n\nDefaults to JSON Schema 2020-12 when no explicit $schema is provided.\nCurrently restricted to type: \"object\" at the root level.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolChoice": { + "description": "Controls tool selection behavior for sampling requests.", + "properties": { + "mode": { + "description": "Controls the tool use ability of the model:\n- \"auto\": Model decides whether to use tools (default)\n- \"required\": Model MUST use at least one tool before completing\n- \"none\": Model MUST NOT use any tools", + "enum": [ + "auto", + "none", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolExecution": { + "description": "Execution-related properties for a tool.", + "properties": { + "taskSupport": { + "description": "Indicates whether this tool supports task-augmented execution.\nThis allows clients to handle long-running operations through polling\nthe task system.\n\n- \"forbidden\": Tool does not support task-augmented execution (default when absent)\n- \"optional\": Tool may support task-augmented execution\n- \"required\": Tool requires task-augmented execution\n\nDefault: \"forbidden\"", + "enum": [ + "forbidden", + "optional", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ToolResultContent": { + "description": "The result of a tool use, provided by the user back to the assistant.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Optional metadata about the tool result. Clients SHOULD preserve this field when\nincluding tool results in subsequent sampling requests to enable caching optimizations.\n\nSee [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "description": "The unstructured result content of the tool use.\n\nThis has the same format as CallToolResult.content and can include text, images,\naudio, resource links, and embedded resources.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool use resulted in an error.\n\nIf true, the content typically describes the error that occurred.\nDefault: false", + "type": "boolean" + }, + "structuredContent": { + "additionalProperties": {}, + "description": "An optional structured result object.\n\nIf the tool defined an outputSchema, this SHOULD conform to that schema.", + "type": "object" + }, + "toolUseId": { + "description": "The ID of the tool use this result corresponds to.\n\nThis MUST match the ID from a previous ToolUseContent.", + "type": "string" + }, + "type": { + "const": "tool_result", + "type": "string" + } + }, + "required": [ + "content", + "toolUseId", + "type" + ], + "type": "object" + }, + "ToolUseContent": { + "description": "A request from the assistant to call a tool.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Optional metadata about the tool use. Clients SHOULD preserve this field when\nincluding tool uses in subsequent sampling requests to enable caching optimizations.\n\nSee [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "id": { + "description": "A unique identifier for this tool use.\n\nThis ID is used to match tool results to their corresponding tool uses.", + "type": "string" + }, + "input": { + "additionalProperties": {}, + "description": "The arguments to pass to the tool, conforming to the tool's input schema.", + "type": "object" + }, + "name": { + "description": "The name of the tool to call.", + "type": "string" + }, + "type": { + "const": "tool_use", + "type": "string" + } + }, + "required": [ + "id", + "input", + "name", + "type" + ], + "type": "object" + }, + "URLElicitationRequiredError": { + "description": "An error response that indicates that the server requires the client to provide additional information via an elicitation request.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32042, + "type": "integer" + }, + "data": { + "additionalProperties": {}, + "properties": { + "elicitations": { + "items": { + "$ref": "#/$defs/ElicitRequestURLParams" + }, + "type": "array" + } + }, + "required": [ + "elicitations" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "UnsubscribeRequest": { + "description": "Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/unsubscribe", + "type": "string" + }, + "params": { + "$ref": "#/$defs/UnsubscribeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "UnsubscribeRequestParams": { + "description": "Parameters for a `resources/unsubscribe` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "UntitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for the array items.", + "properties": { + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "UntitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + } +} + diff --git a/packages/ext-tasks/schema/v1/schema.ts b/packages/ext-tasks/schema/v1/schema.ts new file mode 100644 index 0000000..402150c --- /dev/null +++ b/packages/ext-tasks/schema/v1/schema.ts @@ -0,0 +1,2578 @@ +/* JSON-RPC types */ + +/** + * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + * + * @category JSON-RPC + */ +export type JSONRPCMessage = + | JSONRPCRequest + | JSONRPCNotification + | JSONRPCResponse; + +/** @internal */ +export const LATEST_PROTOCOL_VERSION = "DRAFT-2025-v3"; +/** @internal */ +export const JSONRPC_VERSION = "2.0"; + +/** + * A progress token, used to associate progress notifications with the original request. + * + * @category Common Types + */ +export type ProgressToken = string | number; + +/** + * An opaque token used to represent a cursor for pagination. + * + * @category Common Types + */ +export type Cursor = string; + +/** + * Common params for any task-augmented request. + * + * @internal + */ +export interface TaskAugmentedRequestParams extends RequestParams { + /** + * If specified, the caller is requesting task-augmented execution for this request. + * The request will return a CreateTaskResult immediately, and the actual result can be + * retrieved later via tasks/result. + * + * Task augmentation is subject to capability negotiation - receivers MUST declare support + * for task augmentation of specific request types in their capabilities. + */ + task?: TaskMetadata; +} +/** + * Common params for any request. + * + * @internal + */ +export interface RequestParams { + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken?: ProgressToken; + [key: string]: unknown; + }; +} + +/** @internal */ +export interface Request { + method: string; + // Allow unofficial extensions of `Request.params` without impacting `RequestParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: { [key: string]: any }; +} + +/** @internal */ +export interface NotificationParams { + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** @internal */ +export interface Notification { + method: string; + // Allow unofficial extensions of `Notification.params` without impacting `NotificationParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: { [key: string]: any }; +} + +/** + * @category Common Types + */ +export interface Result { + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; + [key: string]: unknown; +} + +/** + * @category Common Types + */ +export interface Error { + /** + * The error type that occurred. + */ + code: number; + /** + * A short description of the error. The message SHOULD be limited to a concise single sentence. + */ + message: string; + /** + * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + */ + data?: unknown; +} + +/** + * A uniquely identifying ID for a request in JSON-RPC. + * + * @category Common Types + */ +export type RequestId = string | number; + +/** + * A request that expects a response. + * + * @category JSON-RPC + */ +export interface JSONRPCRequest extends Request { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; +} + +/** + * A notification which does not expect a response. + * + * @category JSON-RPC + */ +export interface JSONRPCNotification extends Notification { + jsonrpc: typeof JSONRPC_VERSION; +} + +/** + * A successful (non-error) response to a request. + * + * @category JSON-RPC + */ +export interface JSONRPCResultResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + result: Result; +} + +/** + * A response to a request that indicates an error occurred. + * + * @category JSON-RPC + */ +export interface JSONRPCErrorResponse { + jsonrpc: typeof JSONRPC_VERSION; + id?: RequestId; + error: Error; +} + +/** + * A response to a request, containing either the result or error. + */ +export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; + +// Standard JSON-RPC error codes +export const PARSE_ERROR = -32700; +export const INVALID_REQUEST = -32600; +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; +export const INTERNAL_ERROR = -32603; + +// Implementation-specific JSON-RPC error codes [-32000, -32099] +/** @internal */ +export const URL_ELICITATION_REQUIRED = -32042; + +/** + * An error response that indicates that the server requires the client to provide additional information via an elicitation request. + * + * @internal + */ +export interface URLElicitationRequiredError + extends Omit { + error: Error & { + code: typeof URL_ELICITATION_REQUIRED; + data: { + elicitations: ElicitRequestURLParams[]; + [key: string]: unknown; + }; + }; +} + +/* Empty result */ +/** + * A response that indicates success but carries no data. + * + * @category Common Types + */ +export type EmptyResult = Result; + +/* Cancellation */ +/** + * Parameters for a `notifications/cancelled` notification. + * + * @category `notifications/cancelled` + */ +export interface CancelledNotificationParams extends NotificationParams { + /** + * The ID of the request to cancel. + * + * This MUST correspond to the ID of a request previously issued in the same direction. + * This MUST be provided for cancelling non-task requests. + * This MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead). + */ + requestId?: RequestId; + + /** + * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + */ + reason?: string; +} + +/** + * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * + * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * A client MUST NOT attempt to cancel its `initialize` request. + * + * For task cancellation, use the `tasks/cancel` request instead of this notification. + * + * @category `notifications/cancelled` + */ +export interface CancelledNotification extends JSONRPCNotification { + method: "notifications/cancelled"; + params: CancelledNotificationParams; +} + +/* Initialization */ +/** + * Parameters for an `initialize` request. + * + * @category `initialize` + */ +export interface InitializeRequestParams extends RequestParams { + /** + * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + */ + protocolVersion: string; + capabilities: ClientCapabilities; + clientInfo: Implementation; +} + +/** + * This request is sent from the client to the server when it first connects, asking it to begin initialization. + * + * @category `initialize` + */ +export interface InitializeRequest extends JSONRPCRequest { + method: "initialize"; + params: InitializeRequestParams; +} + +/** + * After receiving an initialize request from the client, the server sends this response. + * + * @category `initialize` + */ +export interface InitializeResult extends Result { + /** + * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. + */ + protocolVersion: string; + capabilities: ServerCapabilities; + serverInfo: Implementation; + + /** + * Instructions describing how to use the server and its features. + * + * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + */ + instructions?: string; +} + +/** + * This notification is sent from the client to the server after initialization has finished. + * + * @category `notifications/initialized` + */ +export interface InitializedNotification extends JSONRPCNotification { + method: "notifications/initialized"; + params?: NotificationParams; +} + +/** + * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + * + * @category `initialize` + */ +export interface ClientCapabilities { + /** + * Experimental, non-standard capabilities that the client supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the client supports listing roots. + */ + roots?: { + /** + * Whether the client supports notifications for changes to the roots list. + */ + listChanged?: boolean; + }; + /** + * Present if the client supports sampling from an LLM. + */ + sampling?: { + /** + * Whether the client supports context inclusion via includeContext parameter. + * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + */ + context?: object; + /** + * Whether the client supports tool use via tools and toolChoice parameters. + */ + tools?: object; + }; + /** + * Present if the client supports elicitation from the server. + */ + elicitation?: { form?: object; url?: object }; + + /** + * Present if the client supports task-augmented requests. + */ + tasks?: { + /** + * Whether this client supports tasks/list. + */ + list?: object; + /** + * Whether this client supports tasks/cancel. + */ + cancel?: object; + /** + * Specifies which request types can be augmented with tasks. + */ + requests?: { + /** + * Task support for sampling-related requests. + */ + sampling?: { + /** + * Whether the client supports task-augmented sampling/createMessage requests. + */ + createMessage?: object; + }; + /** + * Task support for elicitation-related requests. + */ + elicitation?: { + /** + * Whether the client supports task-augmented elicitation/create requests. + */ + create?: object; + }; + }; + }; +} + +/** + * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + * + * @category `initialize` + */ +export interface ServerCapabilities { + /** + * Experimental, non-standard capabilities that the server supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the server supports sending log messages to the client. + */ + logging?: object; + /** + * Present if the server supports argument autocompletion suggestions. + */ + completions?: object; + /** + * Present if the server offers any prompt templates. + */ + prompts?: { + /** + * Whether this server supports notifications for changes to the prompt list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any resources to read. + */ + resources?: { + /** + * Whether this server supports subscribing to resource updates. + */ + subscribe?: boolean; + /** + * Whether this server supports notifications for changes to the resource list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any tools to call. + */ + tools?: { + /** + * Whether this server supports notifications for changes to the tool list. + */ + listChanged?: boolean; + }; + /** + * Present if the server supports task-augmented requests. + */ + tasks?: { + /** + * Whether this server supports tasks/list. + */ + list?: object; + /** + * Whether this server supports tasks/cancel. + */ + cancel?: object; + /** + * Specifies which request types can be augmented with tasks. + */ + requests?: { + /** + * Task support for tool-related requests. + */ + tools?: { + /** + * Whether the server supports task-augmented tools/call requests. + */ + call?: object; + }; + }; + }; +} + +/** + * An optionally-sized icon that can be displayed in a user interface. + * + * @category Common Types + */ +export interface Icon { + /** + * A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a + * `data:` URI with Base64-encoded image data. + * + * Consumers SHOULD takes steps to ensure URLs serving icons are from the + * same domain as the client/server or a trusted domain. + * + * Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain + * executable JavaScript. + * + * @format uri + */ + src: string; + + /** + * Optional MIME type override if the source MIME type is missing or generic. + * For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`. + */ + mimeType?: string; + + /** + * Optional array of strings that specify sizes at which the icon can be used. + * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. + * + * If not provided, the client should assume that the icon can be used at any size. + */ + sizes?: string[]; + + /** + * Optional specifier for the theme this icon is designed for. `light` indicates + * the icon is designed to be used with a light background, and `dark` indicates + * the icon is designed to be used with a dark background. + * + * If not provided, the client should assume the icon can be used with any theme. + */ + theme?: "light" | "dark"; +} + +/** + * Base interface to add `icons` property. + * + * @internal + */ +export interface Icons { + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + */ + icons?: Icon[]; +} + +/** + * Base interface for metadata with name (identifier) and title (display name) properties. + * + * @internal + */ +export interface BaseMetadata { + /** + * Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + */ + name: string; + + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the name should be used for display (except for Tool, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ + title?: string; +} + +/** + * Describes the MCP implementation. + * + * @category `initialize` + */ +export interface Implementation extends BaseMetadata, Icons { + version: string; + + /** + * An optional human-readable description of what this implementation does. + * + * This can be used by clients or servers to provide context about their purpose + * and capabilities. For example, a server might describe the types of resources + * or tools it provides, while a client might describe its intended use case. + */ + description?: string; + + /** + * An optional URL of the website for this implementation. + * + * @format uri + */ + websiteUrl?: string; +} + +/* Ping */ +/** + * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. + * + * @category `ping` + */ +export interface PingRequest extends JSONRPCRequest { + method: "ping"; + params?: RequestParams; +} + +/* Progress notifications */ + +/** + * Parameters for a `notifications/progress` notification. + * + * @category `notifications/progress` + */ +export interface ProgressNotificationParams extends NotificationParams { + /** + * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + */ + progressToken: ProgressToken; + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + * + * @TJS-type number + */ + progress: number; + /** + * Total number of items to process (or total progress required), if known. + * + * @TJS-type number + */ + total?: number; + /** + * An optional message describing the current progress. + */ + message?: string; +} + +/** + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + * + * @category `notifications/progress` + */ +export interface ProgressNotification extends JSONRPCNotification { + method: "notifications/progress"; + params: ProgressNotificationParams; +} + +/* Pagination */ +/** + * Common parameters for paginated requests. + * + * @internal + */ +export interface PaginatedRequestParams extends RequestParams { + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor?: Cursor; +} + +/** @internal */ +export interface PaginatedRequest extends JSONRPCRequest { + params?: PaginatedRequestParams; +} + +/** @internal */ +export interface PaginatedResult extends Result { + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor?: Cursor; +} + +/* Resources */ +/** + * Sent from the client to request a list of resources the server has. + * + * @category `resources/list` + */ +export interface ListResourcesRequest extends PaginatedRequest { + method: "resources/list"; +} + +/** + * The server's response to a resources/list request from the client. + * + * @category `resources/list` + */ +export interface ListResourcesResult extends PaginatedResult { + resources: Resource[]; +} + +/** + * Sent from the client to request a list of resource templates the server has. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesRequest extends PaginatedRequest { + method: "resources/templates/list"; +} + +/** + * The server's response to a resources/templates/list request from the client. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesResult extends PaginatedResult { + resourceTemplates: ResourceTemplate[]; +} + +/** + * Common parameters when working with resources. + * + * @internal + */ +export interface ResourceRequestParams extends RequestParams { + /** + * The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string; +} + +/** + * Parameters for a `resources/read` request. + * + * @category `resources/read` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ReadResourceRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to the server, to read a specific resource URI. + * + * @category `resources/read` + */ +export interface ReadResourceRequest extends JSONRPCRequest { + method: "resources/read"; + params: ReadResourceRequestParams; +} + +/** + * The server's response to a resources/read request from the client. + * + * @category `resources/read` + */ +export interface ReadResourceResult extends Result { + contents: (TextResourceContents | BlobResourceContents)[]; +} + +/** + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/resources/list_changed` + */ +export interface ResourceListChangedNotification extends JSONRPCNotification { + method: "notifications/resources/list_changed"; + params?: NotificationParams; +} + +/** + * Parameters for a `resources/subscribe` request. + * + * @category `resources/subscribe` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface SubscribeRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + * + * @category `resources/subscribe` + */ +export interface SubscribeRequest extends JSONRPCRequest { + method: "resources/subscribe"; + params: SubscribeRequestParams; +} + +/** + * Parameters for a `resources/unsubscribe` request. + * + * @category `resources/unsubscribe` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UnsubscribeRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. + * + * @category `resources/unsubscribe` + */ +export interface UnsubscribeRequest extends JSONRPCRequest { + method: "resources/unsubscribe"; + params: UnsubscribeRequestParams; +} + +/** + * Parameters for a `notifications/resources/updated` notification. + * + * @category `notifications/resources/updated` + */ +export interface ResourceUpdatedNotificationParams extends NotificationParams { + /** + * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + * + * @format uri + */ + uri: string; +} + +/** + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. + * + * @category `notifications/resources/updated` + */ +export interface ResourceUpdatedNotification extends JSONRPCNotification { + method: "notifications/resources/updated"; + params: ResourceUpdatedNotificationParams; +} + +/** + * A known resource that the server is capable of reading. + * + * @category `resources/list` + */ +export interface Resource extends BaseMetadata, Icons { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size?: number; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A template description for resources available on the server. + * + * @category `resources/templates/list` + */ +export interface ResourceTemplate extends BaseMetadata, Icons { + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + * + * @format uri-template + */ + uriTemplate: string; + + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The contents of a specific resource or sub-resource. + * + * @internal + */ +export interface ResourceContents { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * @category Content + */ +export interface TextResourceContents extends ResourceContents { + /** + * The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + text: string; +} + +/** + * @category Content + */ +export interface BlobResourceContents extends ResourceContents { + /** + * A base64-encoded string representing the binary data of the item. + * + * @format byte + */ + blob: string; +} + +/* Prompts */ +/** + * Sent from the client to request a list of prompts and prompt templates the server has. + * + * @category `prompts/list` + */ +export interface ListPromptsRequest extends PaginatedRequest { + method: "prompts/list"; +} + +/** + * The server's response to a prompts/list request from the client. + * + * @category `prompts/list` + */ +export interface ListPromptsResult extends PaginatedResult { + prompts: Prompt[]; +} + +/** + * Parameters for a `prompts/get` request. + * + * @category `prompts/get` + */ +export interface GetPromptRequestParams extends RequestParams { + /** + * The name of the prompt or prompt template. + */ + name: string; + /** + * Arguments to use for templating the prompt. + */ + arguments?: { [key: string]: string }; +} + +/** + * Used by the client to get a prompt provided by the server. + * + * @category `prompts/get` + */ +export interface GetPromptRequest extends JSONRPCRequest { + method: "prompts/get"; + params: GetPromptRequestParams; +} + +/** + * The server's response to a prompts/get request from the client. + * + * @category `prompts/get` + */ +export interface GetPromptResult extends Result { + /** + * An optional description for the prompt. + */ + description?: string; + messages: PromptMessage[]; +} + +/** + * A prompt or prompt template that the server offers. + * + * @category `prompts/list` + */ +export interface Prompt extends BaseMetadata, Icons { + /** + * An optional description of what this prompt provides + */ + description?: string; + + /** + * A list of arguments to use for templating the prompt. + */ + arguments?: PromptArgument[]; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * Describes an argument that a prompt can accept. + * + * @category `prompts/list` + */ +export interface PromptArgument extends BaseMetadata { + /** + * A human-readable description of the argument. + */ + description?: string; + /** + * Whether this argument must be provided. + */ + required?: boolean; +} + +/** + * The sender or recipient of messages and data in a conversation. + * + * @category Common Types + */ +export type Role = "user" | "assistant"; + +/** + * Describes a message returned as part of a prompt. + * + * This is similar to `SamplingMessage`, but also supports the embedding of + * resources from the MCP server. + * + * @category `prompts/get` + */ +export interface PromptMessage { + role: Role; + content: ContentBlock; +} + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + * + * Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + * + * @category Content + */ +export interface ResourceLink extends Resource { + type: "resource_link"; +} + +/** + * The contents of a resource, embedded into a prompt or tool call result. + * + * It is up to the client how best to render embedded resources for the benefit + * of the LLM and/or the user. + * + * @category Content + */ +export interface EmbeddedResource { + type: "resource"; + resource: TextResourceContents | BlobResourceContents; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} +/** + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/prompts/list_changed` + */ +export interface PromptListChangedNotification extends JSONRPCNotification { + method: "notifications/prompts/list_changed"; + params?: NotificationParams; +} + +/* Tools */ +/** + * Sent from the client to request a list of tools the server has. + * + * @category `tools/list` + */ +export interface ListToolsRequest extends PaginatedRequest { + method: "tools/list"; +} + +/** + * The server's response to a tools/list request from the client. + * + * @category `tools/list` + */ +export interface ListToolsResult extends PaginatedResult { + tools: Tool[]; +} + +/** + * The server's response to a tool call. + * + * @category `tools/call` + */ +export interface CallToolResult extends Result { + /** + * A list of content objects that represent the unstructured result of the tool call. + */ + content: ContentBlock[]; + + /** + * An optional JSON object that represents the structured result of the tool call. + */ + structuredContent?: { [key: string]: unknown }; + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ + isError?: boolean; +} + +/** + * Parameters for a `tools/call` request. + * + * @category `tools/call` + */ +export interface CallToolRequestParams extends TaskAugmentedRequestParams { + /** + * The name of the tool. + */ + name: string; + /** + * Arguments to use for the tool call. + */ + arguments?: { [key: string]: unknown }; +} + +/** + * Used by the client to invoke a tool provided by the server. + * + * @category `tools/call` + */ +export interface CallToolRequest extends JSONRPCRequest { + method: "tools/call"; + params: CallToolRequestParams; +} + +/** + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/tools/list_changed` + */ +export interface ToolListChangedNotification extends JSONRPCNotification { + method: "notifications/tools/list_changed"; + params?: NotificationParams; +} + +/** + * Additional properties describing a Tool to clients. + * + * NOTE: all properties in ToolAnnotations are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on ToolAnnotations + * received from untrusted servers. + * + * @category `tools/list` + */ +export interface ToolAnnotations { + /** + * A human-readable title for the tool. + */ + title?: string; + + /** + * If true, the tool does not modify its environment. + * + * Default: false + */ + readOnlyHint?: boolean; + + /** + * If true, the tool may perform destructive updates to its environment. + * If false, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: true + */ + destructiveHint?: boolean; + + /** + * If true, calling the tool repeatedly with the same arguments + * will have no additional effect on its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: false + */ + idempotentHint?: boolean; + + /** + * If true, this tool may interact with an "open world" of external + * entities. If false, the tool's domain of interaction is closed. + * For example, the world of a web search tool is open, whereas that + * of a memory tool is not. + * + * Default: true + */ + openWorldHint?: boolean; +} + +/** + * Execution-related properties for a tool. + * + * @category `tools/list` + */ +export interface ToolExecution { + /** + * Indicates whether this tool supports task-augmented execution. + * This allows clients to handle long-running operations through polling + * the task system. + * + * - "forbidden": Tool does not support task-augmented execution (default when absent) + * - "optional": Tool may support task-augmented execution + * - "required": Tool requires task-augmented execution + * + * Default: "forbidden" + */ + taskSupport?: "forbidden" | "optional" | "required"; +} + +/** + * Definition for a tool the client can call. + * + * @category `tools/list` + */ +export interface Tool extends BaseMetadata, Icons { + /** + * A human-readable description of the tool. + * + * This can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * A JSON Schema object defining the expected parameters for the tool. + */ + inputSchema: { + $schema?: string; + type: "object"; + properties?: { [key: string]: object }; + required?: string[]; + }; + + /** + * Execution-related properties for this tool. + */ + execution?: ToolExecution; + + /** + * An optional JSON Schema object defining the structure of the tool's output returned in + * the structuredContent field of a CallToolResult. + * + * Defaults to JSON Schema 2020-12 when no explicit $schema is provided. + * Currently restricted to type: "object" at the root level. + */ + outputSchema?: { + $schema?: string; + type: "object"; + properties?: { [key: string]: object }; + required?: string[]; + }; + + /** + * Optional additional tool information. + * + * Display name precedence order is: title, annotations.title, then name. + */ + annotations?: ToolAnnotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/* Tasks */ + +/** + * The status of a task. + * + * @category `tasks` + */ +export type TaskStatus = + | "working" // The request is currently being processed + | "input_required" // The task is waiting for input (e.g., elicitation or sampling) + | "completed" // The request completed successfully and results are available + | "failed" // The associated request did not complete successfully. For tool calls specifically, this includes cases where the tool call result has `isError` set to true. + | "cancelled"; // The request was cancelled before completion + +/** + * Metadata for augmenting a request with task execution. + * Include this in the `task` field of the request parameters. + * + * @category `tasks` + */ +export interface TaskMetadata { + /** + * Requested duration in milliseconds to retain task from creation. + */ + ttl?: number; +} + +/** + * Metadata for associating messages with a task. + * Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. + * + * @category `tasks` + */ +export interface RelatedTaskMetadata { + /** + * The task identifier this message is associated with. + */ + taskId: string; +} + +/** + * Data associated with a task. + * + * @category `tasks` + */ +export interface Task { + /** + * The task identifier. + */ + taskId: string; + + /** + * Current task state. + */ + status: TaskStatus; + + /** + * Optional human-readable message describing the current task state. + * This can provide context for any status, including: + * - Reasons for "cancelled" status + * - Summaries for "completed" status + * - Diagnostic information for "failed" status (e.g., error details, what went wrong) + */ + statusMessage?: string; + + /** + * ISO 8601 timestamp when the task was created. + */ + createdAt: string; + + /** + * ISO 8601 timestamp when the task was last updated. + */ + lastUpdatedAt: string; + + /** + * Actual retention duration from creation in milliseconds, null for unlimited. + */ + ttl: number | null; + + /** + * Suggested polling interval in milliseconds. + */ + pollInterval?: number; +} + +/** + * A response to a task-augmented request. + * + * @category `tasks` + */ +export interface CreateTaskResult extends Result { + task: Task; +} + +/** + * A request to retrieve the state of a task. + * + * @category `tasks/get` + */ +export interface GetTaskRequest extends JSONRPCRequest { + method: "tasks/get"; + params: { + /** + * The task identifier to query. + */ + taskId: string; + }; +} + +/** + * The response to a tasks/get request. + * + * @category `tasks/get` + */ +export type GetTaskResult = Result & Task; + +/** + * A request to retrieve the result of a completed task. + * + * @category `tasks/result` + */ +export interface GetTaskPayloadRequest extends JSONRPCRequest { + method: "tasks/result"; + params: { + /** + * The task identifier to retrieve results for. + */ + taskId: string; + }; +} + +/** + * The response to a tasks/result request. + * The structure matches the result type of the original request. + * For example, a tools/call task would return the CallToolResult structure. + * + * @category `tasks/result` + */ +export interface GetTaskPayloadResult extends Result { + [key: string]: unknown; +} + +/** + * A request to cancel a task. + * + * @category `tasks/cancel` + */ +export interface CancelTaskRequest extends JSONRPCRequest { + method: "tasks/cancel"; + params: { + /** + * The task identifier to cancel. + */ + taskId: string; + }; +} + +/** + * The response to a tasks/cancel request. + * + * @category `tasks/cancel` + */ +export type CancelTaskResult = Result & Task; + +/** + * A request to retrieve a list of tasks. + * + * @category `tasks/list` + */ +export interface ListTasksRequest extends PaginatedRequest { + method: "tasks/list"; +} + +/** + * The response to a tasks/list request. + * + * @category `tasks/list` + */ +export interface ListTasksResult extends PaginatedResult { + tasks: Task[]; +} + +/** + * Parameters for a `notifications/tasks/status` notification. + * + * @category `notifications/tasks/status` + */ +export type TaskStatusNotificationParams = NotificationParams & Task; + +/** + * An optional notification from the receiver to the requestor, informing them that a task's status has changed. Receivers are not required to send these notifications. + * + * @category `notifications/tasks/status` + */ +export interface TaskStatusNotification extends JSONRPCNotification { + method: "notifications/tasks/status"; + params: TaskStatusNotificationParams; +} + +/* Logging */ + +/** + * Parameters for a `logging/setLevel` request. + * + * @category `logging/setLevel` + */ +export interface SetLevelRequestParams extends RequestParams { + /** + * The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message. + */ + level: LoggingLevel; +} + +/** + * A request from the client to the server, to enable or adjust logging. + * + * @category `logging/setLevel` + */ +export interface SetLevelRequest extends JSONRPCRequest { + method: "logging/setLevel"; + params: SetLevelRequestParams; +} + +/** + * Parameters for a `notifications/message` notification. + * + * @category `notifications/message` + */ +export interface LoggingMessageNotificationParams extends NotificationParams { + /** + * The severity of this log message. + */ + level: LoggingLevel; + /** + * An optional name of the logger issuing this message. + */ + logger?: string; + /** + * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + */ + data: unknown; +} + +/** + * JSONRPCNotification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. + * + * @category `notifications/message` + */ +export interface LoggingMessageNotification extends JSONRPCNotification { + method: "notifications/message"; + params: LoggingMessageNotificationParams; +} + +/** + * The severity of a log message. + * + * These map to syslog message severities, as specified in RFC-5424: + * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + * + * @category Common Types + */ +export type LoggingLevel = + | "debug" + | "info" + | "notice" + | "warning" + | "error" + | "critical" + | "alert" + | "emergency"; + +/* Sampling */ +/** + * Parameters for a `sampling/createMessage` request. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequestParams extends TaskAugmentedRequestParams { + messages: SamplingMessage[]; + /** + * The server's preferences for which model to select. The client MAY ignore these preferences. + */ + modelPreferences?: ModelPreferences; + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + */ + systemPrompt?: string; + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + * The client MAY ignore this request. + * + * Default is "none". Values "thisServer" and "allServers" are soft-deprecated. Servers SHOULD only use these values if the client + * declares ClientCapabilities.sampling.context. These values may be removed in future spec releases. + */ + includeContext?: "none" | "thisServer" | "allServers"; + /** + * @TJS-type number + */ + temperature?: number; + /** + * The requested maximum number of tokens to sample (to prevent runaway completions). + * + * The client MAY choose to sample fewer tokens than the requested maximum. + */ + maxTokens: number; + stopSequences?: string[]; + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + */ + metadata?: object; + /** + * Tools that the model may use during generation. + * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + */ + tools?: Tool[]; + /** + * Controls how the model uses tools. + * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + * Default is `{ mode: "auto" }`. + */ + toolChoice?: ToolChoice; +} + +/** + * Controls tool selection behavior for sampling requests. + * + * @category `sampling/createMessage` + */ +export interface ToolChoice { + /** + * Controls the tool use ability of the model: + * - "auto": Model decides whether to use tools (default) + * - "required": Model MUST use at least one tool before completing + * - "none": Model MUST NOT use any tools + */ + mode?: "auto" | "required" | "none"; +} + +/** + * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequest extends JSONRPCRequest { + method: "sampling/createMessage"; + params: CreateMessageRequestParams; +} + +/** + * The client's response to a sampling/createMessage request from the server. + * The client should inform the user before returning the sampled message, to allow them + * to inspect the response (human in the loop) and decide whether to allow the server to see it. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageResult extends Result, SamplingMessage { + /** + * The name of the model that generated the message. + */ + model: string; + + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - "endTurn": Natural end of the assistant's turn + * - "stopSequence": A stop sequence was encountered + * - "maxTokens": Maximum token limit was reached + * - "toolUse": The model wants to use one or more tools + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason?: "endTurn" | "stopSequence" | "maxTokens" | "toolUse" | string; +} + +/** + * Describes a message issued to or received from an LLM API. + * + * @category `sampling/createMessage` + */ +export interface SamplingMessage { + role: Role; + content: SamplingMessageContentBlock | SamplingMessageContentBlock[]; + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} +export type SamplingMessageContentBlock = + | TextContent + | ImageContent + | AudioContent + | ToolUseContent + | ToolResultContent; + +/** + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + * + * @category Common Types + */ +export interface Annotations { + /** + * Describes who the intended audience of this object or data is. + * + * It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + */ + audience?: Role[]; + + /** + * Describes how important this data is for operating the server. + * + * A value of 1 means "most important," and indicates that the data is + * effectively required, while 0 means "least important," and indicates that + * the data is entirely optional. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + priority?: number; + + /** + * The moment the resource was last modified, as an ISO 8601 formatted string. + * + * Should be an ISO 8601 formatted string (e.g., "2025-01-12T15:00:58Z"). + * + * Examples: last activity timestamp in an open file, timestamp when the resource + * was attached, etc. + */ + lastModified?: string; +} + +/** + * @category Content + */ +export type ContentBlock = + | TextContent + | ImageContent + | AudioContent + | ResourceLink + | EmbeddedResource; + +/** + * Text provided to or from an LLM. + * + * @category Content + */ +export interface TextContent { + type: "text"; + + /** + * The text content of the message. + */ + text: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * An image provided to or from an LLM. + * + * @category Content + */ +export interface ImageContent { + type: "image"; + + /** + * The base64-encoded image data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * Audio provided to or from an LLM. + * + * @category Content + */ +export interface AudioContent { + type: "audio"; + + /** + * The base64-encoded audio data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the audio. Different providers may support different audio types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A request from the assistant to call a tool. + * + * @category `sampling/createMessage` + */ +export interface ToolUseContent { + type: "tool_use"; + + /** + * A unique identifier for this tool use. + * + * This ID is used to match tool results to their corresponding tool uses. + */ + id: string; + + /** + * The name of the tool to call. + */ + name: string; + + /** + * The arguments to pass to the tool, conforming to the tool's input schema. + */ + input: { [key: string]: unknown }; + + /** + * Optional metadata about the tool use. Clients SHOULD preserve this field when + * including tool uses in subsequent sampling requests to enable caching optimizations. + * + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The result of a tool use, provided by the user back to the assistant. + * + * @category `sampling/createMessage` + */ +export interface ToolResultContent { + type: "tool_result"; + + /** + * The ID of the tool use this result corresponds to. + * + * This MUST match the ID from a previous ToolUseContent. + */ + toolUseId: string; + + /** + * The unstructured result content of the tool use. + * + * This has the same format as CallToolResult.content and can include text, images, + * audio, resource links, and embedded resources. + */ + content: ContentBlock[]; + + /** + * An optional structured result object. + * + * If the tool defined an outputSchema, this SHOULD conform to that schema. + */ + structuredContent?: { [key: string]: unknown }; + + /** + * Whether the tool use resulted in an error. + * + * If true, the content typically describes the error that occurred. + * Default: false + */ + isError?: boolean; + + /** + * Optional metadata about the tool result. Clients SHOULD preserve this field when + * including tool results in subsequent sampling requests to enable caching optimizations. + * + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The server's preferences for model selection, requested of the client during sampling. + * + * Because LLMs can vary along multiple dimensions, choosing the "best" model is + * rarely straightforward. Different models excel in different areas—some are + * faster but less capable, others are more capable but more expensive, and so + * on. This interface allows servers to express their priorities across multiple + * dimensions to help clients make an appropriate selection for their use case. + * + * These preferences are always advisory. The client MAY ignore them. It is also + * up to the client to decide how to interpret these preferences and how to + * balance them against other considerations. + * + * @category `sampling/createMessage` + */ +export interface ModelPreferences { + /** + * Optional hints to use for model selection. + * + * If multiple hints are specified, the client MUST evaluate them in order + * (such that the first match is taken). + * + * The client SHOULD prioritize these hints over the numeric priorities, but + * MAY still use the priorities to select from ambiguous matches. + */ + hints?: ModelHint[]; + + /** + * How much to prioritize cost when selecting a model. A value of 0 means cost + * is not important, while a value of 1 means cost is the most important + * factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + costPriority?: number; + + /** + * How much to prioritize sampling speed (latency) when selecting a model. A + * value of 0 means speed is not important, while a value of 1 means speed is + * the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + speedPriority?: number; + + /** + * How much to prioritize intelligence and capabilities when selecting a + * model. A value of 0 means intelligence is not important, while a value of 1 + * means intelligence is the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + intelligencePriority?: number; +} + +/** + * Hints to use for model selection. + * + * Keys not declared here are currently left unspecified by the spec and are up + * to the client to interpret. + * + * @category `sampling/createMessage` + */ +export interface ModelHint { + /** + * A hint for a model name. + * + * The client SHOULD treat this as a substring of a model name; for example: + * - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` + * - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. + * - `claude` should match any Claude model + * + * The client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example: + * - `gemini-1.5-flash` could match `claude-3-haiku-20240307` + */ + name?: string; +} + +/* Autocomplete */ +/** + * Parameters for a `completion/complete` request. + * + * @category `completion/complete` + */ +export interface CompleteRequestParams extends RequestParams { + ref: PromptReference | ResourceTemplateReference; + /** + * The argument's information + */ + argument: { + /** + * The name of the argument + */ + name: string; + /** + * The value of the argument to use for completion matching. + */ + value: string; + }; + + /** + * Additional, optional context for completions + */ + context?: { + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments?: { [key: string]: string }; + }; +} + +/** + * A request from the client to the server, to ask for completion options. + * + * @category `completion/complete` + */ +export interface CompleteRequest extends JSONRPCRequest { + method: "completion/complete"; + params: CompleteRequestParams; +} + +/** + * The server's response to a completion/complete request + * + * @category `completion/complete` + */ +export interface CompleteResult extends Result { + completion: { + /** + * An array of completion values. Must not exceed 100 items. + */ + values: string[]; + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total?: number; + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore?: boolean; + }; +} + +/** + * A reference to a resource or resource template definition. + * + * @category `completion/complete` + */ +export interface ResourceTemplateReference { + type: "ref/resource"; + /** + * The URI or URI template of the resource. + * + * @format uri-template + */ + uri: string; +} + +/** + * Identifies a prompt. + * + * @category `completion/complete` + */ +export interface PromptReference extends BaseMetadata { + type: "ref/prompt"; +} + +/* Roots */ +/** + * Sent from the server to request a list of root URIs from the client. Roots allow + * servers to ask for specific directories or files to operate on. A common example + * for roots is providing a set of repositories or directories a server should operate + * on. + * + * This request is typically used when the server needs to understand the file system + * structure or access specific locations that the client has permission to read from. + * + * @category `roots/list` + */ +export interface ListRootsRequest extends JSONRPCRequest { + method: "roots/list"; + params?: RequestParams; +} + +/** + * The client's response to a roots/list request from the server. + * This result contains an array of Root objects, each representing a root directory + * or file that the server can operate on. + * + * @category `roots/list` + */ +export interface ListRootsResult extends Result { + roots: Root[]; +} + +/** + * Represents a root directory or file that the server can operate on. + * + * @category `roots/list` + */ +export interface Root { + /** + * The URI identifying the root. This *must* start with file:// for now. + * This restriction may be relaxed in future versions of the protocol to allow + * other URI schemes. + * + * @format uri + */ + uri: string; + /** + * An optional name for the root. This can be used to provide a human-readable + * identifier for the root, which may be useful for display purposes or for + * referencing the root in other parts of the application. + */ + name?: string; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A notification from the client to the server, informing it that the list of roots has changed. + * This notification should be sent whenever the client adds, removes, or modifies any root. + * The server should then request an updated list of roots using the ListRootsRequest. + * + * @category `notifications/roots/list_changed` + */ +export interface RootsListChangedNotification extends JSONRPCNotification { + method: "notifications/roots/list_changed"; + params?: NotificationParams; +} + +/** + * The parameters for a request to elicit non-sensitive information from the user via a form in the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequestFormParams extends TaskAugmentedRequestParams { + /** + * The elicitation mode. + */ + mode?: "form"; + + /** + * The message to present to the user describing what information is being requested. + */ + message: string; + + /** + * A restricted subset of JSON Schema. + * Only top-level properties are allowed, without nesting. + */ + requestedSchema: { + $schema?: string; + type: "object"; + properties: { + [key: string]: PrimitiveSchemaDefinition; + }; + required?: string[]; + }; +} + +/** + * The parameters for a request to elicit information from the user via a URL in the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequestURLParams extends TaskAugmentedRequestParams { + /** + * The elicitation mode. + */ + mode: "url"; + + /** + * The message to present to the user explaining why the interaction is needed. + */ + message: string; + + /** + * The ID of the elicitation, which must be unique within the context of the server. + * The client MUST treat this ID as an opaque value. + */ + elicitationId: string; + + /** + * The URL that the user should navigate to. + * + * @format uri + */ + url: string; +} + +/** + * The parameters for a request to elicit additional information from the user via the client. + * + * @category `elicitation/create` + */ +export type ElicitRequestParams = + | ElicitRequestFormParams + | ElicitRequestURLParams; + +/** + * A request from the server to elicit additional information from the user via the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequest extends JSONRPCRequest { + method: "elicitation/create"; + params: ElicitRequestParams; +} + +/** + * Restricted schema definitions that only allow primitive types + * without nested objects or arrays. + * + * @category `elicitation/create` + */ +export type PrimitiveSchemaDefinition = + | StringSchema + | NumberSchema + | BooleanSchema + | EnumSchema; + +/** + * @category `elicitation/create` + */ +export interface StringSchema { + type: "string"; + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + format?: "email" | "uri" | "date" | "date-time"; + default?: string; +} + +/** + * @category `elicitation/create` + */ +export interface NumberSchema { + type: "number" | "integer"; + title?: string; + description?: string; + minimum?: number; + maximum?: number; + default?: number; +} + +/** + * @category `elicitation/create` + */ +export interface BooleanSchema { + type: "boolean"; + title?: string; + description?: string; + default?: boolean; +} + +/** + * Schema for single-selection enumeration without display titles for options. + * + * @category `elicitation/create` + */ +export interface UntitledSingleSelectEnumSchema { + type: "string"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Array of enum values to choose from. + */ + enum: string[]; + /** + * Optional default value. + */ + default?: string; +} + +/** + * Schema for single-selection enumeration with display titles for each option. + * + * @category `elicitation/create` + */ +export interface TitledSingleSelectEnumSchema { + type: "string"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Array of enum options with values and display labels. + */ + oneOf: Array<{ + /** + * The enum value. + */ + const: string; + /** + * Display label for this option. + */ + title: string; + }>; + /** + * Optional default value. + */ + default?: string; +} + +/** + * @category `elicitation/create` + */ +// Combined single selection enumeration +export type SingleSelectEnumSchema = + | UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema; + +/** + * Schema for multiple-selection enumeration without display titles for options. + * + * @category `elicitation/create` + */ +export interface UntitledMultiSelectEnumSchema { + type: "array"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for the array items. + */ + items: { + type: "string"; + /** + * Array of enum values to choose from. + */ + enum: string[]; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +/** + * Schema for multiple-selection enumeration with display titles for each option. + * + * @category `elicitation/create` + */ +export interface TitledMultiSelectEnumSchema { + type: "array"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for array items with enum options and display labels. + */ + items: { + /** + * Array of enum options with values and display labels. + */ + anyOf: Array<{ + /** + * The constant enum value. + */ + const: string; + /** + * Display title for this option. + */ + title: string; + }>; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +/** + * @category `elicitation/create` + */ +// Combined multiple selection enumeration +export type MultiSelectEnumSchema = + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema; + +/** + * Use TitledSingleSelectEnumSchema instead. + * This interface will be removed in a future version. + * + * @category `elicitation/create` + */ +export interface LegacyTitledEnumSchema { + type: "string"; + title?: string; + description?: string; + enum: string[]; + /** + * (Legacy) Display names for enum values. + * Non-standard according to JSON schema 2020-12. + */ + enumNames?: string[]; + default?: string; +} + +/** + * @category `elicitation/create` + */ +// Union type for all enum schemas +export type EnumSchema = + | SingleSelectEnumSchema + | MultiSelectEnumSchema + | LegacyTitledEnumSchema; + +/** + * The client's response to an elicitation request. + * + * @category `elicitation/create` + */ +export interface ElicitResult extends Result { + /** + * The user action in response to the elicitation. + * - "accept": User submitted the form/confirmed the action + * - "decline": User explicitly decline the action + * - "cancel": User dismissed without making an explicit choice + */ + action: "accept" | "decline" | "cancel"; + + /** + * The submitted form data, only present when action is "accept" and mode was "form". + * Contains values matching the requested schema. + * Omitted for out-of-band mode responses. + */ + content?: { [key: string]: string | number | boolean | string[] }; +} + +/** + * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. + * + * @category `notifications/elicitation/complete` + */ +export interface ElicitationCompleteNotification extends JSONRPCNotification { + method: "notifications/elicitation/complete"; + params: { + /** + * The ID of the elicitation that completed. + */ + elicitationId: string; + }; +} + +/* Client messages */ +/** @internal */ +export type ClientRequest = + | PingRequest + | InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; + +/** @internal */ +export type ClientNotification = + | CancelledNotification + | ProgressNotification + | InitializedNotification + | RootsListChangedNotification + | TaskStatusNotification; + +/** @internal */ +export type ClientResult = + | EmptyResult + | CreateMessageResult + | ListRootsResult + | ElicitResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult; + +/* Server messages */ +/** @internal */ +export type ServerRequest = + | PingRequest + | CreateMessageRequest + | ListRootsRequest + | ElicitRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; + +/** @internal */ +export type ServerNotification = + | CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification + | ElicitationCompleteNotification + | TaskStatusNotification; + +/** @internal */ +export type ServerResult = + | EmptyResult + | InitializeResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourceTemplatesResult + | ListResourcesResult + | ReadResourceResult + | CallToolResult + | ListToolsResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult; diff --git a/packages/ext-tasks/schema/v2/schema.json b/packages/ext-tasks/schema/v2/schema.json new file mode 100644 index 0000000..1d0ec25 --- /dev/null +++ b/packages/ext-tasks/schema/v2/schema.json @@ -0,0 +1,3145 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://modelcontextprotocol.io/ext-tasks/2026-07-28/schema.json", + "title": "MCP Tasks Extension", + "description": "JSON Schema for MCP Tasks extension protocol messages. Extension Identifier: io.modelcontextprotocol/tasks", + "$defs": { + "CancelTaskRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "tasks/cancel" + }, + "params": { + "type": "object", + "properties": { + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + } + }, + "required": [ + "method", + "params" + ] + } + ] + }, + "CancelTaskResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "type": "object", + "properties": { + "resultType": { + "type": "string", + "const": "complete" + } + }, + "required": [ + "resultType" + ] + } + ] + }, + "CancelledTask": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "cancelled" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + }, + "CompletedTask": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "completed" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "result": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "result" + ] + }, + "CreateTaskResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "const": "working" + }, + { + "type": "string", + "const": "input_required" + }, + { + "type": "string", + "const": "completed" + }, + { + "type": "string", + "const": "failed" + }, + { + "type": "string", + "const": "cancelled" + } + ] + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + }, + { + "type": "object", + "properties": { + "resultType": { + "type": "string", + "const": "task" + } + }, + "required": [ + "resultType" + ] + } + ] + }, + "DetailedTask": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "working" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "input_required" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "inputRequests": { + "$ref": "#/$defs/InputRequests" + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "inputRequests" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "completed" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "result": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "result" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "failed" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/$defs/Error" + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "error" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "cancelled" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + } + ] + }, + "FailedTask": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "failed" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/$defs/Error" + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "error" + ] + }, + "GetTaskRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "tasks/get" + }, + "params": { + "type": "object", + "properties": { + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + } + }, + "required": [ + "method", + "params" + ] + } + ] + }, + "GetTaskResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "anyOf": [ + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "working" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "input_required" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "inputRequests": { + "$ref": "#/$defs/InputRequests" + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "inputRequests" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "completed" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "result": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "result" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "failed" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/$defs/Error" + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "error" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "cancelled" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + } + ] + }, + { + "type": "object", + "properties": { + "resultType": { + "type": "string", + "const": "complete" + } + }, + "required": [ + "resultType" + ] + } + ] + }, + "InputRequiredTask": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "input_required" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "inputRequests": { + "$ref": "#/$defs/InputRequests" + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "inputRequests" + ] + }, + "Task": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "const": "working" + }, + { + "type": "string", + "const": "input_required" + }, + { + "type": "string", + "const": "completed" + }, + { + "type": "string", + "const": "failed" + }, + { + "type": "string", + "const": "cancelled" + } + ] + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + }, + "TaskStatusNotificationParams": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/$defs/NotificationParams" + }, + { + "anyOf": [ + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "working" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "input_required" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "inputRequests": { + "$ref": "#/$defs/InputRequests" + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "inputRequests" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "completed" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "result": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "result" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "failed" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/$defs/Error" + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "error" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "cancelled" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + } + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "TaskStatusNotification": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/$defs/JSONRPCNotification" + }, + { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "notifications/tasks" + }, + "params": { + "allOf": [ + { + "$ref": "#/$defs/NotificationParams" + }, + { + "anyOf": [ + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "working" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "input_required" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "inputRequests": { + "$ref": "#/$defs/InputRequests" + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "inputRequests" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "completed" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "result": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "result" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "failed" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/$defs/Error" + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + "error" + ] + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "cancelled" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + } + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + "required": [ + "method", + "params" + ] + } + ] + }, + "TaskStatus": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "string", + "const": "working" + }, + { + "type": "string", + "const": "input_required" + }, + { + "type": "string", + "const": "completed" + }, + { + "type": "string", + "const": "failed" + }, + { + "type": "string", + "const": "cancelled" + } + ] + }, + "TaskSubscriptionAcknowledgedNotifications": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "taskIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "TaskSubscriptionNotifications": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "taskIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "TasksExtensionCapability": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "not": {} + } + }, + "UpdateTaskRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "tasks/update" + }, + "params": { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + } + }, + "required": [ + "taskId", + "inputResponses" + ] + } + }, + "required": [ + "method", + "params" + ] + } + ] + }, + "UpdateTaskResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "type": "object", + "properties": { + "resultType": { + "type": "string", + "const": "complete" + } + }, + "required": [ + "resultType" + ] + } + ] + }, + "WorkingTask": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "status": { + "type": "string", + "const": "working" + }, + "statusMessage": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "string" + }, + "ttlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pollIntervalMs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs" + ] + }, + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended audience of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/$defs/Role" + }, + "type": "array" + }, + "lastModified": { + "description": "The moment the resource was last modified, as an ISO 8601 formatted string.\n\nShould be an ISO 8601 formatted string (e.g., \"2025-01-12T15:00:58Z\").\n\nExamples: last activity timestamp in an open file, timestamp when the resource\nwas attached, etc.", + "type": "string" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "BooleanSchema": { + "properties": { + "default": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "ContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ResourceLink" + }, + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CreateMessageRequestParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "CreateMessageRequestParams": { + "description": "Parameters for a `sampling/createMessage` request.", + "properties": { + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt.\nThe client MAY ignore this request.\n\nDefault is `\"none\"`. The values `\"thisServer\"` and `\"allServers\"` are deprecated (SEP-2596): servers SHOULD\nomit this field or use `\"none\"`, and SHOULD only use the deprecated values if the client declares\n{@link ClientCapabilities.sampling.context}.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The requested maximum number of tokens to sample (to prevent runaway completions).\n\nThe client MAY choose to sample fewer tokens than the requested maximum.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/$defs/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/$defs/JSONObject", + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific." + }, + "modelPreferences": { + "$ref": "#/$defs/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "temperature": { + "type": "number" + }, + "toolChoice": { + "$ref": "#/$defs/ToolChoice", + "description": "Controls how the model uses tools.\nThe client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared.\nDefault is `{ mode: \"auto\" }`." + }, + "tools": { + "description": "Tools that the model may use during generation.\nThe client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared.", + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The result returned by the client for a {@link CreateMessageRequestsampling/createMessage} request.\nThe client should inform the user before returning the sampled message, to allow them\nto inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/$defs/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.\n\nStandard values:\n- `\"endTurn\"`: Natural end of the assistant's turn\n- `\"stopSequence\"`: A stop sequence was encountered\n- `\"maxTokens\"`: Maximum token limit was reached\n- `\"toolUse\"`: The model wants to use one or more tools\n\nThis field is an open string to allow for provider-specific stop reasons.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "ElicitRequest": { + "description": "A request from the server to elicit additional information from the user via the client.", + "properties": { + "method": { + "const": "elicitation/create", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitRequestParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ElicitRequestFormParams": { + "description": "The parameters for a request to elicit non-sensitive information from the user via a form in the client.", + "properties": { + "message": { + "description": "The message to present to the user describing what information is being requested.", + "type": "string" + }, + "mode": { + "const": "form", + "description": "The elicitation mode.", + "type": "string" + }, + "requestedSchema": { + "description": "A restricted subset of JSON Schema.\nOnly top-level properties are allowed, without nesting.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "$ref": "#/$defs/PrimitiveSchemaDefinition" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + } + }, + "required": [ + "message", + "requestedSchema" + ], + "type": "object" + }, + "ElicitRequestParams": { + "anyOf": [ + { + "$ref": "#/$defs/ElicitRequestFormParams" + }, + { + "$ref": "#/$defs/ElicitRequestURLParams" + } + ], + "description": "The parameters for a request to elicit additional information from the user via the client." + }, + "ElicitRequestURLParams": { + "description": "The parameters for a request to elicit information from the user via a URL in the client.", + "properties": { + "message": { + "description": "The message to present to the user explaining why the interaction is needed.", + "type": "string" + }, + "mode": { + "const": "url", + "description": "The elicitation mode.", + "type": "string" + }, + "url": { + "description": "The URL that the user should navigate to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "message", + "mode", + "url" + ], + "type": "object" + }, + "ElicitResult": { + "description": "The result returned by the client for an {@link ElicitRequestelicitation/create} request.", + "properties": { + "action": { + "description": "The user action in response to the elicitation.\n- `\"accept\"`: User submitted the form/confirmed the action\n- `\"decline\"`: User explicitly declined the action\n- `\"cancel\"`: User dismissed without making an explicit choice", + "enum": [ + "accept", + "cancel", + "decline" + ], + "type": "string" + }, + "content": { + "additionalProperties": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "description": "The submitted form data, only present when action is `\"accept\"` and mode was `\"form\"`.\nContains values matching the requested schema.\nOmitted for out-of-band mode responses.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "Error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD take steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `\"light\"` indicates\nthe icon is designed to be used with a light background, and `\"dark\"` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "dark", + "light" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the MCP implementation.", + "properties": { + "description": { + "description": "An optional human-readable description of what this implementation does.\n\nThis can be used by clients or servers to provide context about their purpose\nand capabilities. For example, a server might describe the types of resources\nor tools it provides, while a client might describe its intended use case.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "version": { + "description": "The version of this implementation.", + "type": "string" + }, + "websiteUrl": { + "description": "An optional URL of the website for this implementation.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InputRequest": { + "anyOf": [ + { + "$ref": "#/$defs/CreateMessageRequest" + }, + { + "$ref": "#/$defs/ListRootsRequest" + }, + { + "$ref": "#/$defs/ElicitRequest" + } + ] + }, + "InputRequests": { + "additionalProperties": { + "$ref": "#/$defs/InputRequest" + }, + "description": "A map of server-initiated requests that the client must fulfill.\nKeys are server-assigned identifiers; values are the request objects.", + "type": "object" + }, + "InputResponse": { + "anyOf": [ + { + "$ref": "#/$defs/CreateMessageResult" + }, + { + "$ref": "#/$defs/ListRootsResult" + }, + { + "$ref": "#/$defs/ElicitResult" + } + ] + }, + "InputResponses": { + "additionalProperties": { + "$ref": "#/$defs/InputResponse" + }, + "description": "A map of client responses to server-initiated requests.\nKeys correspond to the keys in the {@link InputRequests} map;\nvalues are the client's result for each request.", + "type": "object" + }, + "JSONObject": { + "additionalProperties": { + "$ref": "#/$defs/JSONValue" + }, + "type": "object" + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONValue": { + "anyOf": [ + { + "$ref": "#/$defs/JSONObject" + }, + { + "items": { + "$ref": "#/$defs/JSONValue" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "LegacyTitledEnumSchema": { + "description": "Use {@link TitledSingleSelectEnumSchema} instead.\nThis interface will be removed in a future version.", + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "description": "(Legacy) Display names for enum values.\nNon-standard according to JSON schema 2020-12.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The result returned by the client for a {@link ListRootsRequestroots/list} request.\nThis result contains an array of {@link Root} objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "roots": { + "items": { + "$ref": "#/$defs/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "MetaObject": { + "description": "Represents the contents of a `_meta` field, which clients and servers use to attach additional metadata to their interactions.\n\nCertain key names are reserved by MCP for protocol-level metadata; implementations MUST NOT make assumptions about values at these keys. Additionally, specific schema definitions may reserve particular names for purpose-specific metadata, as declared in those definitions.\n\nValid keys have two segments:\n\n**Prefix:**\n- Optional — if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`).\n- Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`).\n- Implementations SHOULD use reverse DNS notation (e.g., `com.example/` rather than `example.com/`).\n- Any prefix where the second label is `modelcontextprotocol` or `mcp` is **reserved** for MCP use. For example: `io.modelcontextprotocol/`, `dev.mcp/`, `org.modelcontextprotocol.api/`, and `com.mcp.tools/` are all reserved. However, `com.example.mcp/` is NOT reserved, as the second label is `example`.\n\n**Name:**\n- Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`).\n- Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`).", + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/$defs/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "NotificationMetaObject": { + "description": "Extends {@link MetaObject} with additional notification-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/$defs/RequestId", + "description": "Identifies the subscription stream a notification was delivered on. The\nserver MUST include this key on every notification delivered via a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream, so the\nclient can correlate the notification with the originating subscription.\nThe key is absent on notifications not delivered via a subscription\nstream (e.g. progress notifications for an in-flight request), which is\nwhy it is optional here.\n\nThe value is the JSON-RPC ID of the `subscriptions/listen` request that\nopened the stream." + } + }, + "type": "object" + }, + "NotificationParams": { + "description": "Common params for any notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + } + }, + "type": "object" + }, + "NumberSchema": { + "properties": { + "default": { + "type": "number" + }, + "description": { + "type": "string" + }, + "maximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "title": { + "type": "string" + }, + "type": { + "enum": [ + "integer", + "number" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PrimitiveSchemaDefinition": { + "anyOf": [ + { + "$ref": "#/$defs/StringSchema" + }, + { + "$ref": "#/$defs/NumberSchema" + }, + { + "$ref": "#/$defs/BooleanSchema" + }, + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ], + "description": "Restricted schema definitions that only allow primitive types\nwithout nested objects or arrays." + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of {@link ListResourcesRequestresources/list} requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "type", + "uri" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "description": "Common result fields.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "ResultMetaObject": { + "description": "Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the server software producing the response. Servers SHOULD\ninclude this field on every response unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the server and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Clients\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." + } + }, + "type": "object" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with `file://` for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "SamplingMessageContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + } + ] + }, + "StringSchema": { + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "enum": [ + "date", + "date-time", + "email", + "uri" + ], + "type": "string" + }, + "maxLength": { + "type": "integer" + }, + "minLength": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "TitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for array items with enum options and display labels.", + "properties": { + "anyOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The constant enum value.", + "type": "string" + }, + "title": { + "description": "Display title for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "TitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "oneOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The enum value.", + "type": "string" + }, + "title": { + "description": "Display label for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/ToolAnnotations", + "description": "Optional additional tool information.\n\nDisplay name precedence order is: `title`, `annotations.title`, then `name`." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "inputSchema": { + "additionalProperties": {}, + "description": "A JSON Schema object defining the expected parameters for the tool.\n\nTool arguments are always JSON objects, so `type: \"object\"` is required at the root.\nBeyond that, any JSON Schema 2020-12 keyword may appear alongside `type` — including\ncomposition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords\n(`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other\nstandard validation or annotation keywords.\n\nProperty schemas may carry an `x-mcp-header` annotation to mirror the\nargument value into an HTTP header on the Streamable HTTP transport. See\nthe Streamable HTTP transport specification for the validity and\nextraction rules.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", + "properties": { + "$schema": { + "type": "string" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "outputSchema": { + "additionalProperties": {}, + "description": "An optional JSON Schema object defining the structure of the tool's output returned in\nthe structuredContent field of a {@link CallToolResult}. This can be any valid JSON Schema 2020-12.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", + "properties": { + "$schema": { + "type": "string" + } + }, + "type": "object" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a {@link Tool} to clients.\n\nNOTE: all properties in `ToolAnnotations` are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on `ToolAnnotations`\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolChoice": { + "description": "Controls tool selection behavior for sampling requests.", + "properties": { + "mode": { + "description": "Controls the tool use ability of the model:\n- `\"auto\"`: Model decides whether to use tools (default)\n- `\"required\"`: Model MUST use at least one tool before completing\n- `\"none\"`: Model MUST NOT use any tools", + "enum": [ + "auto", + "none", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolResultContent": { + "description": "The result of a tool use, provided by the user back to the assistant.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Optional metadata about the tool result. Clients SHOULD preserve this field when\nincluding tool results in subsequent sampling requests to enable caching optimizations." + }, + "content": { + "description": "The unstructured result content of the tool use.\n\nThis has the same format as {@link CallToolResult.content} and can include text, images,\naudio, resource links, and embedded resources.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool use resulted in an error.\n\nIf true, the content typically describes the error that occurred.\nDefault: false", + "type": "boolean" + }, + "structuredContent": { + "description": "An optional structured result value.\n\nThis can be any JSON value (object, array, string, number, boolean, or null).\nIf the tool defined an {@link Tool.outputSchema}, this SHOULD conform to that schema." + }, + "toolUseId": { + "description": "The ID of the tool use this result corresponds to.\n\nThis MUST match the ID from a previous {@link ToolUseContent}.", + "type": "string" + }, + "type": { + "const": "tool_result", + "type": "string" + } + }, + "required": [ + "content", + "toolUseId", + "type" + ], + "type": "object" + }, + "ToolUseContent": { + "description": "A request from the assistant to call a tool.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Optional metadata about the tool use. Clients SHOULD preserve this field when\nincluding tool uses in subsequent sampling requests to enable caching optimizations." + }, + "id": { + "description": "A unique identifier for this tool use.\n\nThis ID is used to match tool results to their corresponding tool uses.", + "type": "string" + }, + "input": { + "additionalProperties": {}, + "description": "The arguments to pass to the tool, conforming to the tool's input schema.", + "type": "object" + }, + "name": { + "description": "The name of the tool to call.", + "type": "string" + }, + "type": { + "const": "tool_use", + "type": "string" + } + }, + "required": [ + "id", + "input", + "name", + "type" + ], + "type": "object" + }, + "UntitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for the array items.", + "properties": { + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "UntitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + } +} diff --git a/packages/ext-tasks/schema/v2/schema.ts b/packages/ext-tasks/schema/v2/schema.ts new file mode 100644 index 0000000..b6f6bff --- /dev/null +++ b/packages/ext-tasks/schema/v2/schema.ts @@ -0,0 +1,350 @@ +/** + * MCP Tasks Extension Schema (spec.types.ts) + * Extension Identifier: io.modelcontextprotocol/tasks + * + * This file contains pure TypeScript interface definitions for the MCP Tasks extension. + * These types are the source of truth and are used to generate Zod schemas via `ts-to-zod`. + * + * - Use `@description` JSDoc tags to generate `.describe()` calls on schemas + * - This released snapshot is immutable; make changes in schema/draft/ instead + * + * @see https://modelcontextprotocol.io/seps/2663-tasks-extension + */ + +import type { + Error as JSONRPCErrorObject, + InputRequests, + InputResponses, + JSONRPCNotification, + JSONRPCRequest, + NotificationParams, + Result, +} from "./spec.types.js"; + +/* Tasks */ + +/** + * The status of a task. + * + * @category `tasks` + */ +export type TaskStatus = + | "working" // The request is currently being processed + | "input_required" // The task is waiting for input (e.g., elicitation or sampling) + | "completed" // The request completed successfully and results are available + | "failed" // The associated request failed due to a JSON-RPC error during execution + | "cancelled"; // The request was cancelled before completion + +/** + * Data associated with a task. + * + * @category `tasks` + */ +export interface Task { + /** + * The task identifier. + */ + taskId: string; + + /** + * Current task status. + */ + status: TaskStatus; + + /** + * Optional human-readable message describing the current task state. + * This can provide context for any status, including: + * - Progress descriptions for "working" + * - Work blocked on "input_required" + * - Reasons for "cancelled" status + * - Summaries for "completed" status + * - Diagnostic information for "failed" status (e.g., error details, what went wrong) + */ + statusMessage?: string; + + /** + * ISO 8601 timestamp when the task was created. + */ + createdAt: string; + + /** + * ISO 8601 timestamp when the task was last updated. + */ + lastUpdatedAt: string; + + /** + * Time-to-live duration from creation in integer milliseconds, null for unlimited. + * The server may discard the task after the TTL elapses. This value MAY change + * over the lifetime of a task. + * @format int + * @nullable + */ + ttlMs: number | null; + + /** + * Suggested polling interval in integer milliseconds. Clients SHOULD honor + * this value to avoid overwhelming the server. This value MAY change over + * the lifetime of a task. + * @format int + */ + pollIntervalMs?: number; +} + +/* Detailed Task Variants */ + +/** + * A task that is in a normal working state. + * Used by tasks/get and notifications/tasks. + * + * @category `tasks` + */ +export interface WorkingTask extends Task { + status: "working"; +} + +/** + * A task that is waiting for input from the client. + * Used by tasks/get and notifications/tasks. + * + * @category `tasks` + */ +export interface InputRequiredTask extends Task { + status: "input_required"; + + /** + * Server-to-client requests that need to be fulfilled during task execution. + * Keys are arbitrary identifiers for matching requests to responses. + */ + inputRequests: InputRequests; +} + +/** + * A task that has completed successfully. + * Used by tasks/get and notifications/tasks. + * + * @category `tasks` + */ +export interface CompletedTask extends Task { + status: "completed"; + + /** + * The final result of the task. + * The structure matches the result type of the original request. + * For example, a CallToolRequest task would return the CallToolResult structure. + */ + result: { [key: string]: unknown }; +} + +/** + * A task that has failed due to a JSON-RPC error during execution. + * Used by tasks/get and notifications/tasks. + * + * @category `tasks` + */ +export interface FailedTask extends Task { + status: "failed"; + + /** + * The JSON-RPC error that caused the task to fail. + */ + error: JSONRPCErrorObject; +} + +/** + * A task that has been cancelled. + * Used by tasks/get and notifications/tasks. + * + * @category `tasks` + */ +export interface CancelledTask extends Task { + status: "cancelled"; +} + +/** + * A union type representing a task with status-specific fields inlined. + * This type is used by tasks/get responses and notifications/tasks + * notifications to provide complete task state including terminal results + * or pending input requests. + * + * @category `tasks` + */ +export type DetailedTask = + | WorkingTask + | InputRequiredTask + | CompletedTask + | FailedTask + | CancelledTask; + +/* Task Creation */ + +/** + * The result returned by a server in lieu of a standard result shape when + * it elects to process a request asynchronously. The resultType field MUST + * be set to "task". This type is Result & Task (flat). + * + * @category `tasks` + */ +export type CreateTaskResult = Result & + Task & { + /** + * Discriminator distinguishing a task handle from a standard result. + */ + resultType: "task"; + }; + +/* Task Operations */ + +/** + * A request to retrieve the state of a task. + * + * @category `tasks/get` + */ +export type GetTaskRequest = JSONRPCRequest & { + method: "tasks/get"; + params: { + /** + * The task identifier to query. + */ + taskId: string; + }; +}; + +/** + * The response to a tasks/get request. Carries the appropriate DetailedTask + * variant for the task's current status. The resultType field MUST be set + * to "complete". + * + * @category `tasks/get` + */ +export type GetTaskResult = Result & + DetailedTask & { + /** + * Discriminator marking this as the standard result shape for tasks/get. + */ + resultType: "complete"; + }; + +/** + * A request to provide input responses to a task in the input_required state. + * + * @category `tasks/update` + */ +export type UpdateTaskRequest = JSONRPCRequest & { + method: "tasks/update"; + params: { + /** + * The task identifier to update. + */ + taskId: string; + + /** + * Responses to outstanding inputRequests previously surfaced by the server. + * Each key MUST correspond to a currently-outstanding inputRequest key. + */ + inputResponses: InputResponses; + }; +}; + +/** + * The response to a tasks/update request. An empty acknowledgement. + * The resultType field MUST be set to "complete". + * + * @category `tasks/update` + */ +export type UpdateTaskResult = Result & { + /** + * Discriminator marking this as the standard result shape for tasks/update. + */ + resultType: "complete"; +}; + +/** + * A request to cancel a task. + * + * @category `tasks/cancel` + */ +export type CancelTaskRequest = JSONRPCRequest & { + method: "tasks/cancel"; + params: { + /** + * The task identifier to cancel. + */ + taskId: string; + }; +}; + +/** + * The response to a tasks/cancel request. An empty acknowledgement. + * Cancellation is cooperative and eventually consistent. + * The resultType field MUST be set to "complete". + * + * @category `tasks/cancel` + */ +export type CancelTaskResult = Result & { + /** + * Discriminator marking this as the standard result shape for tasks/cancel. + */ + resultType: "complete"; +}; + +/* Task Notifications */ + +/** + * Parameters for a `notifications/tasks` notification. + * Carries a complete DetailedTask for the current status. + * + * @category `notifications/tasks` + */ +export type TaskStatusNotificationParams = NotificationParams & + DetailedTask & { [key: string]: unknown }; + +/** + * An optional notification from the server to the client, informing it that + * a task's status has changed. Servers are not required to send these notifications. + * Clients subscribe via subscriptions/listen. + * + * @category `notifications/tasks` + */ +export type TaskStatusNotification = JSONRPCNotification & { + method: "notifications/tasks"; + params: TaskStatusNotificationParams; +}; + +/* Subscription Additions */ + +/** + * Task-specific fields for the subscriptions/listen request. + * Clients include tasksStatus to subscribe to notifications/tasks + * for specific task IDs. + * + * @category `subscriptions` + */ +export interface TaskSubscriptionNotifications { + /** + * Subscribe to notifications/tasks for specific task IDs. + */ + taskIds?: string[]; +} + +/** + * Task-specific fields for the notifications/subscriptions/acknowledged notification. + * The server includes the list of task IDs it has agreed to send status notifications for. + * + * @category `subscriptions` + */ +export interface TaskSubscriptionAcknowledgedNotifications { + /** + * Task IDs the server has agreed to send status notifications for. + */ + taskIds?: string[]; +} + +/* Extension Capability */ + +/** + * The extension capability declaration for the tasks extension. + * An empty object indicates support; no extension-specific settings are currently defined. + * + * @category `tasks` + */ +export type TasksExtensionCapability = Record; diff --git a/packages/ext-tasks/scripts/check-schema-provenance.mjs b/packages/ext-tasks/scripts/check-schema-provenance.mjs new file mode 100644 index 0000000..a5d6302 --- /dev/null +++ b/packages/ext-tasks/scripts/check-schema-provenance.mjs @@ -0,0 +1,24 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +const artifacts = [ + ["../schema/v1/schema.json", "17cdb3dbcc577ce6cca0781e4ecc0dca84cc2c67"], + ["../schema/v1/schema.ts", "402150cd1e6b3369f10f897125f56ec5a1af0c9f"], + ["../schema/v2/schema.json", "1d0ec255bbcc5744264be53bba0e09e7eb8a5615"], + ["../schema/v2/schema.ts", "b6f6bffc1c19698d75a2ce3b69525ae0c3bfb8b8"], +]; + +function gitBlobId(bytes) { + const header = Buffer.from(`blob ${bytes.byteLength}\0`); + return createHash("sha1").update(header).update(bytes).digest("hex"); +} + +for (const [path, expected] of artifacts) { + const bytes = await readFile(new URL(path, import.meta.url)); + const actual = gitBlobId(bytes); + if (actual !== expected) { + throw new Error(`${path} provenance mismatch: expected ${expected}, received ${actual}`); + } +} + +console.log("Schema provenance verified."); diff --git a/packages/ext-tasks/src/core/index.test.ts b/packages/ext-tasks/src/core/index.test.ts index 1815aee..64ef4bb 100644 --- a/packages/ext-tasks/src/core/index.test.ts +++ b/packages/ext-tasks/src/core/index.test.ts @@ -1,9 +1,60 @@ +import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import * as core from "./index.js"; +import { + ProtocolDecodeError, + isJsonValue, + taskId, + type JsonValue, +} from "./index.js"; -describe("core entry point", () => { - it("can be imported", () => { - expect(core).toBeTypeOf("object"); +const jsonValue = fc.letrec((tie) => ({ + value: fc.oneof( + fc.constant(null), + fc.boolean(), + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.string(), + fc.array(tie("value"), { maxLength: 4 }), + fc.dictionary(fc.string(), tie("value"), { maxKeys: 4 }), + ), +})).value as fc.Arbitrary; + +describe("core runtime contracts", () => { + it("recognizes exactly JSON-compatible generated values", () => { + fc.assert(fc.property(jsonValue, (value) => { + expect(isJsonValue(value)).toBe(true); + })); + fc.assert( + fc.property( + fc.oneof(fc.constant(undefined), fc.bigInt(), fc.constant(Symbol("x"))), + (value) => { + expect(isJsonValue(value)).toBe(false); + }, + ), + ); + expect(isJsonValue(new Date())).toBe(false); + expect(isJsonValue(new Map())).toBe(false); + expect(isJsonValue(Object.assign(Object.create(null), { ok: true }))).toBe(true); + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(isJsonValue(cyclic)).toBe(false); + const sparse: unknown[] = []; + sparse.length = 1; + expect(isJsonValue(sparse)).toBe(false); + }); + + it("brands task identifiers without changing their wire value", () => { + fc.assert( + fc.property(fc.string(), (value) => { + expect(taskId(value)).toBe(value); + }), + ); + }); + + it("exposes decode failures as Error values with stable paths", () => { + const error = new ProtocolDecodeError("expected string", ["task", "taskId"]); + expect(error).toBeInstanceOf(Error); + expect(error.path).toEqual(["task", "taskId"]); + expect(error.message).toContain("task.taskId"); }); }); diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts index 058b6ab..3707e1b 100644 --- a/packages/ext-tasks/src/core/index.ts +++ b/packages/ext-tasks/src/core/index.ts @@ -1,5 +1,110 @@ -/** - * Role-neutral MCP Tasks protocol material shared by client and server - * implementations. - */ -export {}; +import type { TaskV1 } from "./v1/index.js"; +import type { DetailedTaskV2, TaskV2 } from "./v2/index.js"; + +export type TaskId = string & { readonly __taskId: unique symbol }; +export type TaskGeneration = "v1" | "v2"; + +export type JsonValue = + | null + | boolean + | number + | string + | readonly JsonValue[] + | { readonly [key: string]: JsonValue }; + +export type DecodePath = readonly (string | number)[]; + +export class ProtocolDecodeError extends Error { + readonly path: DecodePath; + + constructor(message: string, path: DecodePath = []) { + const location = path.length === 0 ? "$" : path.join("."); + super(`${location}: ${message}`); + this.name = "ProtocolDecodeError"; + this.path = path; + } +} + +export interface RuntimeCodec { + parse(value: JsonValue): + | { readonly success: true; readonly value: T } + | { readonly success: false; readonly error: ProtocolDecodeError }; +} + +export type TaskSnapshot = + | { readonly generation: "v1"; readonly task: TaskV1 } + | { readonly generation: "v2"; readonly task: TaskV2 | DetailedTaskV2 }; + +export function taskId(value: string): TaskId { + return value as TaskId; +} + +export function isJsonValue(value: unknown): value is JsonValue { + const visiting = new WeakSet(); + const visit = (candidate: unknown): boolean => { + if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return true; + if (typeof candidate === "number") return Number.isFinite(candidate); + if (typeof candidate !== "object") return false; + if (visiting.has(candidate)) return false; + visiting.add(candidate); + let valid: boolean; + if (Array.isArray(candidate)) { + valid = candidate.length === Object.keys(candidate).length && candidate.every(visit); + } else { + const prototype = Object.getPrototypeOf(candidate); + valid = (prototype === Object.prototype || prototype === null) && Object.values(candidate).every(visit); + } + visiting.delete(candidate); + return valid; + }; + return visit(value); +} + +export function createRuntimeCodec( + decode: (value: JsonValue, path: DecodePath) => T, + ): RuntimeCodec { + return { + parse(value) { + try { + return { success: true, value: decode(value, []) }; + } catch (error) { + return { + success: false, + error: error instanceof ProtocolDecodeError + ? error + : new ProtocolDecodeError("invalid protocol value"), + }; + } + }, + }; +} + +export function expectRecord(value: JsonValue, path: DecodePath = []): Record { + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new ProtocolDecodeError("expected object", path); + } + return value as Record; +} + +export function expectString(value: JsonValue | undefined, path: DecodePath): string { + if (typeof value !== "string") throw new ProtocolDecodeError("expected string", path); + return value; +} + +export function expectNumber(value: JsonValue | undefined, path: DecodePath): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new ProtocolDecodeError("expected finite number", path); + } + return value; +} + +export function expectEnum( + value: JsonValue | undefined, + values: readonly T[], + path: DecodePath, + ): T { + if (typeof value !== "string" || !values.includes(value as T)) { + throw new ProtocolDecodeError(`expected one of ${values.join(", ")}`, path); + } + return value as T; +} diff --git a/packages/ext-tasks/src/core/v1/index.test.ts b/packages/ext-tasks/src/core/v1/index.test.ts index 13a0abb..0bca0e0 100644 --- a/packages/ext-tasks/src/core/v1/index.test.ts +++ b/packages/ext-tasks/src/core/v1/index.test.ts @@ -1,9 +1,141 @@ +import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import * as coreV1 from "./index.js"; +import { + CallToolRequestV1Codec, CallToolResultV1Codec, CancelTaskRequestV1Codec, CancelTaskResultV1Codec, + CreateTaskResultV1Codec, GetTaskRequestV1Codec, GetTaskResultRequestV1Codec, GetTaskResultV1Codec, + ListTasksRequestV1Codec, ListTasksResultV1Codec, ServerTaskCapabilitiesV1Codec, TaskResultV1Codec, + TaskStatusNotificationV1Codec, TaskStatusV1Codec, TaskV1Codec, ToolV1Codec, + callToolAsTaskV1, hasTaskCancelCapabilityV1, hasTaskListCapabilityV1, hasTaskToolCallCapabilityV1, + isTaskEligibleMethodV1, shouldCallToolAsTaskV1, + type ServerTaskCapabilitiesV1, type TaskStatusV1, type ToolV1, +} from "./index.js"; -describe("core/v1 entry point", () => { - it("can be imported", () => { - expect(coreV1).toBeTypeOf("object"); +const statuses: readonly TaskStatusV1[] = ["working", "input_required", "completed", "failed", "cancelled"]; +const taskArb = fc.record({ + taskId: fc.string(), status: fc.constantFrom(...statuses), + statusMessage: fc.option(fc.string(), { nil: undefined }), createdAt: fc.string(), lastUpdatedAt: fc.string(), + ttl: fc.oneof(fc.integer(), fc.constant(null)), pollInterval: fc.option(fc.integer(), { nil: undefined }), +}); +const idArb = fc.oneof(fc.string(), fc.integer()); +const taskRequestArb = (method: "tasks/get" | "tasks/result" | "tasks/cancel") => + fc.record({ jsonrpc: fc.constant("2.0" as const), id: idArb, method: fc.constant(method), params: fc.record({ taskId: fc.string() }) }); + +function expectRoundTrip(codec: { parse(value: never): { success: boolean; value?: unknown } }, value: unknown): void { + const decoded = codec.parse(value as never); + expect(decoded.success).toBe(true); + if (decoded.success) expect(decoded.value).toEqual(value); +} + +describe("V1 generated wire contracts", () => { + it("round-trips Tasks and rejects missing fields, fractions, and unknown statuses", () => { + fc.assert(fc.property(taskArb, (task) => { expectRoundTrip(TaskV1Codec, task); })); + fc.assert(fc.property(fc.string().filter((v) => !statuses.includes(v as TaskStatusV1)), (value) => { + expect(TaskStatusV1Codec.parse(value).success).toBe(false); + })); + fc.assert(fc.property(taskArb, fc.constantFrom("taskId", "status", "createdAt", "lastUpdatedAt", "ttl"), (task, key) => { + const { [key]: ignored, ...incomplete } = task; void ignored; + expect(TaskV1Codec.parse(incomplete as never).success).toBe(false); + })); + fc.assert(fc.property(taskArb, fc.double({ noNaN: true, noDefaultInfinity: true }).filter((n) => !Number.isInteger(n)), (task, fraction) => { + expect(TaskV1Codec.parse({ ...task, ttl: fraction } as never).success).toBe(false); + expect(TaskV1Codec.parse({ ...task, pollInterval: fraction } as never).success).toBe(false); + })); + }); + + it("round-trips strict task operation requests and rejects discriminator changes", () => { + const cases = [ + [GetTaskRequestV1Codec, taskRequestArb("tasks/get")], + [GetTaskResultRequestV1Codec, taskRequestArb("tasks/result")], + [CancelTaskRequestV1Codec, taskRequestArb("tasks/cancel")], + ] as const; + for (const [codec, arbitrary] of cases) fc.assert(fc.property(arbitrary, (request) => { + expectRoundTrip(codec as never, request); + expect(codec.parse({ ...request, method: "tasks/nope" } as never).success).toBe(false); + expect(codec.parse({ ...request, jsonrpc: "1.0" } as never).success).toBe(false); + const { params: ignored, ...withoutParams } = request; void ignored; + expect(codec.parse(withoutParams as never).success).toBe(false); + })); + }); + + it("round-trips get/cancel/list/create results and notifications", () => { + fc.assert(fc.property(taskArb, (task) => { + expectRoundTrip(GetTaskResultV1Codec, task); expectRoundTrip(CancelTaskResultV1Codec, task); + expectRoundTrip(CreateTaskResultV1Codec, { task }); + expectRoundTrip(TaskStatusNotificationV1Codec, { jsonrpc: "2.0", method: "notifications/tasks/status", params: task }); + })); + fc.assert(fc.property(fc.array(taskArb), fc.option(fc.string(), { nil: undefined }), (tasks, nextCursor) => { + expectRoundTrip(ListTasksResultV1Codec, { tasks, ...(nextCursor === undefined ? {} : { nextCursor }) }); + })); + fc.assert(fc.property(idArb, fc.option(fc.string(), { nil: undefined }), (id, cursor) => { + expectRoundTrip(ListTasksRequestV1Codec, { jsonrpc: "2.0", id, method: "tasks/list", ...(cursor === undefined ? {} : { params: { cursor } }) }); + })); + expect(TaskStatusNotificationV1Codec.parse({ jsonrpc: "2.0", method: "notifications/tasks/nope", params: {} }).success).toBe(false); + }); + + it("decodes arbitrary task results and strict tool call content discriminators", () => { + fc.assert(fc.property(fc.dictionary(fc.string(), fc.jsonValue()), (result) => { expectRoundTrip(TaskResultV1Codec, result); })); + const content = [ + { type: "text", text: "hello" }, { type: "image", data: "x", mimeType: "image/png" }, + { type: "audio", data: "x", mimeType: "audio/wav" }, { type: "resource_link", name: "n", uri: "https://x" }, + { type: "resource", resource: { uri: "https://x", text: "body" } }, + ]; + expectRoundTrip(CallToolResultV1Codec, { content, structuredContent: { ok: true }, isError: false }); + fc.assert(fc.property(fc.string().filter((type) => !["text", "image", "audio", "resource_link", "resource"].includes(type)), (type) => { + expect(CallToolResultV1Codec.parse({ content: [{ type }] }).success).toBe(false); + })); + expect(CallToolResultV1Codec.parse({ content: [{ type: "text" }] }).success).toBe(false); + }); + + it("decodes tools, task-augmented calls, and nested capabilities strictly", () => { + fc.assert(fc.property( + fc.string(), + fc.option(fc.constantFrom("forbidden", "optional", "required"), { nil: undefined }), + fc.dictionary(fc.string(), fc.jsonValue()), + fc.array(fc.dictionary(fc.string(), fc.jsonValue())), + (name, taskSupport, metadata, icons) => { + expectRoundTrip(ToolV1Codec, { + name, + title: "title", + description: "description", + inputSchema: { type: "object" }, + outputSchema: { type: "object", properties: {} }, + execution: { ...(taskSupport === undefined ? {} : { taskSupport }) }, + annotations: metadata, + icons, + _meta: metadata, + }); + }, + )); + expect(ToolV1Codec.parse({ name: "x", inputSchema: {} }).success).toBe(false); + expect(ToolV1Codec.parse({ name: "x", inputSchema: { type: "object" }, execution: { taskSupport: "sometimes" } }).success).toBe(false); + for (const [field, invalid] of [["outputSchema", true], ["annotations", true], ["icons", true], ["_meta", true]] as const) { + expect(ToolV1Codec.parse({ name: "x", inputSchema: { type: "object" }, [field]: invalid }).success).toBe(false); + } + expect(ToolV1Codec.parse({ name: "x", inputSchema: { type: "object" }, icons: [true] }).success).toBe(false); + fc.assert(fc.property(idArb, fc.string(), fc.dictionary(fc.string(), fc.jsonValue()), (id, name, args) => { + expectRoundTrip(CallToolRequestV1Codec, { jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args, task: {} } }); + })); + expect(CallToolRequestV1Codec.parse({ jsonrpc: "2.0", id: 1, method: "tools/nope", params: { name: "x" } }).success).toBe(false); + expectRoundTrip(ServerTaskCapabilitiesV1Codec, { list: {}, cancel: {}, requests: { tools: { call: {} } } }); + expect(ServerTaskCapabilitiesV1Codec.parse({ requests: { tools: { call: true } } }).success).toBe(false); + }); + + it("follows every capability-first negotiation row and narrow guard", () => { + const support = fc.option(fc.constantFrom("forbidden", "optional", "required"), { nil: undefined }); + fc.assert(fc.property(fc.boolean(), support, fc.boolean(), (present, taskSupport, preferTask) => { + const capabilities: ServerTaskCapabilitiesV1 = present ? { requests: { tools: { call: {} } } } : {}; + const tool: ToolV1 = { name: "tool", inputSchema: { type: "object" }, execution: { taskSupport } }; + expect(shouldCallToolAsTaskV1(capabilities, tool, preferTask)).toBe(present && (taskSupport === "required" || (taskSupport === "optional" && preferTask))); + expect(hasTaskToolCallCapabilityV1(capabilities)).toBe(present); + })); + expect(hasTaskListCapabilityV1({ list: {} })).toBe(true); expect(hasTaskListCapabilityV1({})).toBe(false); + expect(hasTaskCancelCapabilityV1({ cancel: {} })).toBe(true); expect(hasTaskCancelCapabilityV1({})).toBe(false); + fc.assert(fc.property(fc.string(), (method) => { expect(isTaskEligibleMethodV1(method)).toBe(method === "tools/call"); })); + }); + + it("constructs exact call augmentation", () => { + expect(callToolAsTaskV1("tool", { x: 1 })).toEqual({ method: "tools/call", params: { name: "tool", arguments: { x: 1 }, task: {} } }); + expect(callToolAsTaskV1("tool")).toEqual({ method: "tools/call", params: { name: "tool", task: {} } }); }); }); diff --git a/packages/ext-tasks/src/core/v1/index.ts b/packages/ext-tasks/src/core/v1/index.ts index e06dd60..11c7cb7 100644 --- a/packages/ext-tasks/src/core/v1/index.ts +++ b/packages/ext-tasks/src/core/v1/index.ts @@ -1,4 +1,265 @@ -/** - * MCP Tasks V1 wire declarations, codecs, and generation-specific guards. - */ -export {}; +/** MCP Tasks V1 wire declarations, codecs, and generation-specific guards. */ +import { + ProtocolDecodeError, + createRuntimeCodec, + expectEnum, + expectNumber, + expectRecord, + expectString, + type DecodePath, + type JsonValue, + type RuntimeCodec, +} from "../index.js"; + +export const TaskStatusesV1 = [ + "working", + "input_required", + "completed", + "failed", + "cancelled", +] as const; +export type TaskStatusV1 = (typeof TaskStatusesV1)[number]; +export type TaskSupportV1 = "forbidden" | "optional" | "required"; +export type TaskEligibleMethodV1 = "tools/call"; +export type JsonRpcRequestIdV1 = string | number; + +export interface TaskMetadataV1 { readonly ttl?: number } +export interface TaskV1 { + readonly taskId: string; + readonly status: TaskStatusV1; + readonly statusMessage?: string; + readonly createdAt: string; + readonly lastUpdatedAt: string; + /** Normative V1 source permits null for unlimited retention; the pinned JSON Schema omitted this union. */ + readonly ttl: number | null; + readonly pollInterval?: number; +} + +export interface CreateTaskResultV1 { + readonly task: TaskV1; + readonly _meta?: Readonly>; +} + +export interface ToolExecutionV1 { readonly taskSupport?: TaskSupportV1 } +export interface ToolV1 { + readonly name: string; + readonly title?: string; + readonly description?: string; + readonly inputSchema: Readonly> & { readonly type: "object" }; + readonly outputSchema?: Readonly> & { readonly type: "object" }; + readonly execution?: ToolExecutionV1; + readonly annotations?: Readonly>; + readonly icons?: readonly Readonly>[]; + readonly _meta?: Readonly>; +} + +export type ContentBlockV1 = + | { readonly type: "text"; readonly text: string; readonly [key: string]: JsonValue } + | { readonly type: "image" | "audio"; readonly data: string; readonly mimeType: string; readonly [key: string]: JsonValue } + | { readonly type: "resource_link"; readonly name: string; readonly uri: string; readonly [key: string]: JsonValue } + | { readonly type: "resource"; readonly resource: Readonly>; readonly [key: string]: JsonValue }; +export interface CallToolRequestV1 { + readonly jsonrpc: "2.0"; + readonly id: JsonRpcRequestIdV1; + readonly method: "tools/call"; + readonly params: { readonly name: string; readonly arguments?: Readonly>; readonly task?: TaskMetadataV1 }; +} + +export interface CallToolResultV1 { + readonly content: readonly ContentBlockV1[]; + readonly structuredContent?: Readonly>; + readonly isError?: boolean; + readonly _meta?: Readonly>; +} + +export interface ServerTaskCapabilitiesV1 { + readonly list?: Readonly>; + readonly cancel?: Readonly>; + readonly requests?: { readonly tools?: { readonly call?: Readonly> } }; +} +export interface ServerCapabilitiesV1 { readonly tasks?: ServerTaskCapabilitiesV1 } + +interface JsonRpcRequestV1 { + readonly jsonrpc: "2.0"; + readonly id: JsonRpcRequestIdV1; + readonly method: M; + readonly params: P; +} +export type GetTaskRequestV1 = JsonRpcRequestV1<"tasks/get", { readonly taskId: string }>; +export type GetTaskResultV1 = TaskV1 & { readonly _meta?: Readonly> }; +export type GetTaskResultRequestV1 = JsonRpcRequestV1<"tasks/result", { readonly taskId: string }>; +export type TaskResultV1 = Readonly>; +export interface ListTasksRequestV1 { + readonly jsonrpc: "2.0"; + readonly id: JsonRpcRequestIdV1; + readonly method: "tasks/list"; + readonly params?: { readonly cursor?: string }; +} +export interface ListTasksResultV1 { + readonly tasks: readonly TaskV1[]; + readonly nextCursor?: string; + readonly _meta?: Readonly>; +} +export type CancelTaskRequestV1 = JsonRpcRequestV1<"tasks/cancel", { readonly taskId: string }>; +export type CancelTaskResultV1 = TaskV1 & { readonly _meta?: Readonly> }; +export interface TaskStatusNotificationV1 { + readonly jsonrpc: "2.0"; + readonly method: "notifications/tasks/status"; + readonly params: TaskV1 & { readonly _meta?: Readonly> }; +} + +export interface CallToolAsTaskRequestV1 { + readonly method: "tools/call"; + readonly params: { + readonly name: string; + readonly arguments?: Readonly>; + readonly task: Record; + }; +} + +function at(path: DecodePath, key: string | number): DecodePath { return [...path, key] } +function optionalBoolean(record: Record, key: string, path: DecodePath): boolean | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== "boolean") throw new ProtocolDecodeError("expected boolean", at(path, key)); + return value; +} +function jsonRecord(value: JsonValue | undefined, path: DecodePath): Readonly> { + if (value === undefined) throw new ProtocolDecodeError("expected object", path); + return expectRecord(value, path); +} +function optionalJsonRecord(record: Record, key: string, path: DecodePath) { + return record[key] === undefined ? undefined : jsonRecord(record[key], at(path, key)); +} +function literal(record: Record, key: string, expected: string, path: DecodePath): void { + if (record[key] !== expected) throw new ProtocolDecodeError(`expected ${expected}`, at(path, key)); +} +function decodeId(value: JsonValue | undefined, path: DecodePath): JsonRpcRequestIdV1 { + if (typeof value !== "string" && typeof value !== "number") throw new ProtocolDecodeError("expected request id", path); + return value; +} +function expectInteger(value: JsonValue | undefined, path: DecodePath): number { + const number = expectNumber(value, path); + if (!Number.isInteger(number)) throw new ProtocolDecodeError("expected integer", path); + return number; +} +function decodeContentBlock(value: JsonValue, path: DecodePath): ContentBlockV1 { + const record = expectRecord(value, path); + const type = expectEnum(record.type, ["text", "image", "audio", "resource_link", "resource"] as const, at(path, "type")); + if (type === "text") { expectString(record.text, at(path, "text")); } + else if (type === "image" || type === "audio") { expectString(record.data, at(path, "data")); expectString(record.mimeType, at(path, "mimeType")); } + else if (type === "resource_link") { expectString(record.name, at(path, "name")); expectString(record.uri, at(path, "uri")); } + else { jsonRecord(record.resource, at(path, "resource")); } + return record as ContentBlockV1; +} +function decodeTask(value: JsonValue, path: DecodePath): TaskV1 { + const record = expectRecord(value, path); + const ttlValue = record.ttl; + const ttl = ttlValue === null ? null : expectInteger(ttlValue, at(path, "ttl")); + return { + taskId: expectString(record.taskId, at(path, "taskId")), + status: expectEnum(record.status, TaskStatusesV1, at(path, "status")), + ...(record.statusMessage === undefined ? {} : { statusMessage: expectString(record.statusMessage, at(path, "statusMessage")) }), + createdAt: expectString(record.createdAt, at(path, "createdAt")), + lastUpdatedAt: expectString(record.lastUpdatedAt, at(path, "lastUpdatedAt")), + ttl, + ...(record.pollInterval === undefined ? {} : { pollInterval: expectInteger(record.pollInterval, at(path, "pollInterval")) }), + }; +} +function decodeTaskRequest(value: JsonValue, path: DecodePath, method: M): JsonRpcRequestV1 { + const record = expectRecord(value, path); + literal(record, "jsonrpc", "2.0", path); literal(record, "method", method, path); + const params = jsonRecord(record.params, at(path, "params")); + return { jsonrpc: "2.0", id: decodeId(record.id, at(path, "id")), method, params: { taskId: expectString(params.taskId, at(at(path, "params"), "taskId")) } }; +} + +export const ToolV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + const inputSchema = jsonRecord(record.inputSchema, at(path, "inputSchema")); + literal(inputSchema as Record, "type", "object", at(path, "inputSchema")); + let outputSchema: ToolV1["outputSchema"]; + if (record.outputSchema !== undefined) { + const decoded = jsonRecord(record.outputSchema, at(path, "outputSchema")); + literal(decoded as Record, "type", "object", at(path, "outputSchema")); + outputSchema = decoded as ToolV1["outputSchema"]; + } + let execution: ToolExecutionV1 | undefined; + if (record.execution !== undefined) { + const decoded = jsonRecord(record.execution, at(path, "execution")); + execution = decoded.taskSupport === undefined ? {} : { taskSupport: expectEnum(decoded.taskSupport, ["forbidden", "optional", "required"] as const, at(at(path, "execution"), "taskSupport")) }; + } + let icons: ToolV1["icons"]; + if (record.icons !== undefined) { + if (!Array.isArray(record.icons)) throw new ProtocolDecodeError("expected array", at(path, "icons")); + icons = record.icons.map((icon, index) => jsonRecord(icon, at(at(path, "icons"), index))); + } + return { + name: expectString(record.name, at(path, "name")), + inputSchema: inputSchema as ToolV1["inputSchema"], + ...(record.title === undefined ? {} : { title: expectString(record.title, at(path, "title")) }), + ...(record.description === undefined ? {} : { description: expectString(record.description, at(path, "description")) }), + ...(outputSchema === undefined ? {} : { outputSchema }), + ...(execution === undefined ? {} : { execution }), + ...(record.annotations === undefined ? {} : { annotations: jsonRecord(record.annotations, at(path, "annotations")) }), + ...(icons === undefined ? {} : { icons }), + ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), + }; +}); +export const ServerTaskCapabilitiesV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + const list = optionalJsonRecord(record, "list", path); const cancel = optionalJsonRecord(record, "cancel", path); + let requests: ServerTaskCapabilitiesV1["requests"]; + if (record.requests !== undefined) { const r = jsonRecord(record.requests, at(path, "requests")); let tools: NonNullable["tools"]; if (r.tools !== undefined) { const t = jsonRecord(r.tools, at(at(path, "requests"), "tools")); tools = t.call === undefined ? {} : { call: jsonRecord(t.call, at(at(at(path, "requests"), "tools"), "call")) }; } requests = tools === undefined ? {} : { tools }; } + return { ...(list === undefined ? {} : { list }), ...(cancel === undefined ? {} : { cancel }), ...(requests === undefined ? {} : { requests }) }; +}); +export const CallToolRequestV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); literal(record, "jsonrpc", "2.0", path); literal(record, "method", "tools/call", path); const params = jsonRecord(record.params, at(path, "params")); + return { jsonrpc: "2.0", id: decodeId(record.id, at(path, "id")), method: "tools/call", params: { name: expectString(params.name, at(at(path, "params"), "name")), ...(params.arguments === undefined ? {} : { arguments: jsonRecord(params.arguments, at(at(path, "params"), "arguments")) }), ...(params.task === undefined ? {} : { task: (() => { const t = jsonRecord(params.task, at(at(path, "params"), "task")); return t.ttl === undefined ? {} : { ttl: expectInteger(t.ttl, at(at(at(path, "params"), "task"), "ttl")) }; })() }) } }; +}); +export const TaskStatusV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => expectEnum(value, TaskStatusesV1, path)); +export const TaskV1Codec: RuntimeCodec = createRuntimeCodec(decodeTask); +export const CreateTaskResultV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + return { task: decodeTask(record.task as JsonValue, at(path, "task")), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(path, "_meta")) }) }; +}); +export const CallToolResultV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + if (!Array.isArray(record.content)) throw new ProtocolDecodeError("expected array", at(path, "content")); + const content = record.content.map((item, index) => decodeContentBlock(item, at(at(path, "content"), index))); + return { content, ...(record.structuredContent === undefined ? {} : { structuredContent: jsonRecord(record.structuredContent, at(path, "structuredContent")) }), ...(record.isError === undefined ? {} : { isError: optionalBoolean(record, "isError", path) }), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(path, "_meta")) }) }; +}); +export const GetTaskRequestV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => decodeTaskRequest(v, p, "tasks/get")); +export const GetTaskResultV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => { const record = expectRecord(v, p); return { ...decodeTask(v, p), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(p, "_meta")) }) }; }); +export const GetTaskResultRequestV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => decodeTaskRequest(v, p, "tasks/result")); +export const TaskResultV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => expectRecord(v, p)); +export const CancelTaskRequestV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => decodeTaskRequest(v, p, "tasks/cancel")); +export const CancelTaskResultV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => { const record = expectRecord(v, p); return { ...decodeTask(v, p), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(p, "_meta")) }) }; }); +export const ListTasksRequestV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); literal(record, "jsonrpc", "2.0", path); literal(record, "method", "tasks/list", path); + const result: ListTasksRequestV1 = { jsonrpc: "2.0", id: decodeId(record.id, at(path, "id")), method: "tasks/list" }; + if (record.params === undefined) return result; + const params = jsonRecord(record.params, at(path, "params")); + return { ...result, params: { ...(params.cursor === undefined ? {} : { cursor: expectString(params.cursor, at(at(path, "params"), "cursor")) }) } }; +}); +export const ListTasksResultV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + if (!Array.isArray(record.tasks)) throw new ProtocolDecodeError("expected array", at(path, "tasks")); + return { tasks: record.tasks.map((task, index) => decodeTask(task, at(at(path, "tasks"), index))), ...(record.nextCursor === undefined ? {} : { nextCursor: expectString(record.nextCursor, at(path, "nextCursor")) }), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(path, "_meta")) }) }; +}); +export const TaskStatusNotificationV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); literal(record, "jsonrpc", "2.0", path); literal(record, "method", "notifications/tasks/status", path); + const paramsRecord = jsonRecord(record.params, at(path, "params")); + return { jsonrpc: "2.0", method: "notifications/tasks/status", params: { ...decodeTask(record.params as JsonValue, at(path, "params")), ...(paramsRecord._meta === undefined ? {} : { _meta: jsonRecord(paramsRecord._meta, at(at(path, "params"), "_meta")) }) } }; +}); + +export function hasTaskListCapabilityV1(capabilities: ServerTaskCapabilitiesV1): boolean { return capabilities.list !== undefined } +export function hasTaskCancelCapabilityV1(capabilities: ServerTaskCapabilitiesV1): boolean { return capabilities.cancel !== undefined } +export function hasTaskToolCallCapabilityV1(capabilities: ServerTaskCapabilitiesV1): boolean { return capabilities.requests?.tools?.call !== undefined } +export function isTaskEligibleMethodV1(method: string): method is TaskEligibleMethodV1 { return method === "tools/call" } +export function shouldCallToolAsTaskV1(capabilities: ServerTaskCapabilitiesV1, tool: ToolV1, preferTask = false): boolean { + if (!hasTaskToolCallCapabilityV1(capabilities)) return false; + return tool.execution?.taskSupport === "required" || (tool.execution?.taskSupport === "optional" && preferTask); +} +export function callToolAsTaskV1(name: string, arguments_?: Readonly>): CallToolAsTaskRequestV1 { + return { method: "tools/call", params: { name, ...(arguments_ === undefined ? {} : { arguments: arguments_ }), task: {} } }; +} diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts index 3203ed3..d5b4f3b 100644 --- a/packages/ext-tasks/src/core/v2/index.test.ts +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -1,9 +1,143 @@ +import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import * as coreV2 from "./index.js"; +import type { JsonValue } from "../index.js"; -describe("core/v2 entry point", () => { - it("can be imported", () => { - expect(coreV2).toBeTypeOf("object"); +import { + CancelTaskRequestV2Codec, CancelTaskResultV2Codec, CreateTaskResultV2Codec, + DetailedTaskV2Codec, ErrorV2Codec, GetTaskRequestV2Codec, GetTaskResultV2Codec, + InputRequestsV2Codec, InputResponsesV2Codec, TaskStatusNotificationV2Codec, + TaskV2Codec, UpdateTaskRequestV2Codec, UpdateTaskResultV2Codec, + contributeTaskFilterV2, hasTaskClientCapabilityV2, hasTaskServerCapabilityV2, + isEligibleTaskResultV2, readAcceptedTaskIdsV2, withTaskCapabilityV2, + type TaskStatusV2, +} from "./index.js"; + +const statuses: readonly TaskStatusV2[] = ["working", "input_required", "completed", "failed", "cancelled"]; +const baseTask = fc.record({ + taskId: fc.string(), + status: fc.constantFrom(...statuses), + statusMessage: fc.option(fc.string(), { nil: undefined }), + createdAt: fc.string(), + lastUpdatedAt: fc.string(), + ttlMs: fc.oneof(fc.integer(), fc.constant(null)), + pollIntervalMs: fc.option(fc.integer(), { nil: undefined }), +}); +const taskFor = (status: TaskStatusV2) => baseTask.map((task) => ({ ...task, status })); +const asJson = (value: unknown): JsonValue => JSON.parse(JSON.stringify(value)) as JsonValue; + +describe("V2 generated wire contracts", () => { + it("accepts every valid base Task and rejects missing required fields, invalid integers, and statuses", () => { + fc.assert(fc.property(baseTask, (task) => { expect(TaskV2Codec.parse(asJson(task)).success).toBe(true); })); + fc.assert(fc.property(baseTask, fc.constantFrom("taskId", "status", "createdAt", "lastUpdatedAt", "ttlMs"), (task, key) => { + const invalid = { ...task }; delete invalid[key]; + expect(TaskV2Codec.parse(asJson(invalid)).success).toBe(false); + })); + fc.assert(fc.property(baseTask, fc.string().filter((status) => !statuses.includes(status as TaskStatusV2)), (task, status) => { + expect(TaskV2Codec.parse(asJson({ ...task, status })).success).toBe(false); + })); + fc.assert(fc.property(baseTask, fc.double({ noNaN: true, noDefaultInfinity: true }).filter((n) => !Number.isInteger(n)), (task, ttlMs) => { + expect(TaskV2Codec.parse(asJson({ ...task, ttlMs })).success).toBe(false); + })); + }); + + it("enforces status-owned DetailedTask payloads", () => { + fc.assert(fc.property(taskFor("working"), (task) => { expect(DetailedTaskV2Codec.parse(asJson(task)).success).toBe(true); })); + fc.assert(fc.property(taskFor("cancelled"), (task) => { expect(DetailedTaskV2Codec.parse(asJson(task)).success).toBe(true); })); + fc.assert(fc.property(taskFor("input_required"), fc.dictionary(fc.string(), fc.constant({ method: "roots/list" as const })), (task, inputRequests) => { + expect(DetailedTaskV2Codec.parse(asJson({ ...task, inputRequests })).success).toBe(true); + })); + fc.assert(fc.property(taskFor("completed"), fc.dictionary(fc.string(), fc.jsonValue()), (task, result) => { + expect(DetailedTaskV2Codec.parse(asJson({ ...task, result })).success).toBe(true); + })); + fc.assert(fc.property(taskFor("failed"), fc.integer(), fc.string(), (task, code, message) => { + expect(DetailedTaskV2Codec.parse(asJson({ ...task, error: { code, message } })).success).toBe(true); + })); + fc.assert(fc.property(fc.constantFrom("input_required", "completed", "failed"), (status) => { + expect(DetailedTaskV2Codec.parse({ taskId: "id", status, createdAt: "a", lastUpdatedAt: "b", ttlMs: null }).success).toBe(false); + })); + }); + + it("strictly decodes input request and response maps", () => { + fc.assert(fc.property(fc.dictionary(fc.string(), fc.oneof( + fc.record({ method: fc.constant("roots/list" as const) }), + fc.record({ method: fc.constant("sampling/createMessage" as const), params: fc.dictionary(fc.string(), fc.jsonValue()) }), + fc.record({ method: fc.constant("elicitation/create" as const), params: fc.dictionary(fc.string(), fc.jsonValue()) }), + )), (requests) => { expect(InputRequestsV2Codec.parse(asJson(requests)).success).toBe(true); })); + expect(InputRequestsV2Codec.parse({ key: { method: "unknown", params: {} } }).success).toBe(false); + fc.assert(fc.property(fc.dictionary(fc.string(), fc.oneof( + fc.record({ action: fc.constantFrom("accept" as const, "decline" as const, "cancel" as const) }), + fc.record({ roots: fc.array(fc.jsonValue()) }), + fc.record({ content: fc.jsonValue(), model: fc.string(), role: fc.constantFrom("user" as const, "assistant" as const) }), + )), (responses) => { expect(InputResponsesV2Codec.parse(asJson(responses)).success).toBe(true); })); + expect(InputResponsesV2Codec.parse({ key: {} }).success).toBe(false); + }); + + it("decodes complete JSON-RPC errors", () => { + fc.assert(fc.property(fc.integer(), fc.string(), fc.option(fc.jsonValue(), { nil: undefined }), (code, message, data) => { + expect(ErrorV2Codec.parse(asJson({ code, message, ...(data === undefined ? {} : { data }) })).success).toBe(true); + })); + expect(ErrorV2Codec.parse({ code: 1 }).success).toBe(false); + expect(ErrorV2Codec.parse({ code: 1.5, message: "bad" }).success).toBe(false); + }); + + it("binds strict get, update, and cancel request/result discriminators", () => { + fc.assert(fc.property(fc.oneof(fc.string(), fc.integer()), fc.string(), (id, taskId) => { + expect(GetTaskRequestV2Codec.parse({ jsonrpc: "2.0", id, method: "tasks/get", params: { taskId } }).success).toBe(true); + expect(CancelTaskRequestV2Codec.parse({ jsonrpc: "2.0", id, method: "tasks/cancel", params: { taskId } }).success).toBe(true); + expect(UpdateTaskRequestV2Codec.parse({ jsonrpc: "2.0", id, method: "tasks/update", params: { taskId, inputResponses: {} } }).success).toBe(true); + })); + for (const codec of [GetTaskRequestV2Codec, UpdateTaskRequestV2Codec, CancelTaskRequestV2Codec]) { + expect(codec.parse({ jsonrpc: "2.0", id: 1, method: "wrong", params: {} }).success).toBe(false); + } + expect(UpdateTaskResultV2Codec.parse({ resultType: "complete" }).success).toBe(true); + expect(CancelTaskResultV2Codec.parse({ resultType: "complete" }).success).toBe(true); + expect(UpdateTaskResultV2Codec.parse({}).success).toBe(false); + fc.assert(fc.property(taskFor("completed"), fc.dictionary(fc.string(), fc.jsonValue()), (task, result) => { + expect(GetTaskResultV2Codec.parse(asJson({ ...task, result, resultType: "complete" })).success).toBe(true); + })); + }); + + it("discriminates Task creation only for eligible tools/call results", () => { + fc.assert(fc.property(baseTask, (task) => { + const result = { ...task, resultType: "task" }; + expect(CreateTaskResultV2Codec.parse(asJson(result)).success).toBe(true); + expect(isEligibleTaskResultV2("tools/call", result)).toBe(true); + expect(isEligibleTaskResultV2("prompts/get", result)).toBe(false); + })); + expect(CreateTaskResultV2Codec.parse({ resultType: "complete" }).success).toBe(false); + }); + + it("decodes detailed task notifications with exact envelope discriminators", () => { + fc.assert(fc.property(taskFor("working"), (task) => { + expect(TaskStatusNotificationV2Codec.parse(asJson({ jsonrpc: "2.0", method: "notifications/tasks", params: task })).success).toBe(true); + })); + expect(TaskStatusNotificationV2Codec.parse({ jsonrpc: "2.0", method: "notifications/wrong", params: {} }).success).toBe(false); + }); + + it("contributes task IDs without changing unrelated filters or prior notification fields", () => { + fc.assert(fc.property(fc.dictionary(fc.string(), fc.jsonValue()), fc.dictionary(fc.string(), fc.jsonValue()), fc.array(fc.string()), (filter, notifications, ids) => { + const source = asJson({ ...filter, notifications }) as Readonly>; + const result = contributeTaskFilterV2(source, ids); + for (const [key, value] of Object.entries(source)) if (key !== "notifications") expect(result[key]).toEqual(value); + const normalizedNotifications = source.notifications as Readonly>; + for (const [key, value] of Object.entries(normalizedNotifications)) if (key !== "taskIds") expect(result.notifications[key]).toEqual(value); + expect(result.notifications.taskIds).toEqual([...new Set(ids)]); + })); + }); + + it("reads only fully valid acknowledged task IDs", () => { + fc.assert(fc.property(fc.array(fc.string()), (ids) => { expect(readAcceptedTaskIdsV2({ notifications: { taskIds: ids } })).toEqual(ids); })); + fc.assert(fc.property(fc.array(fc.oneof(fc.string(), fc.integer())).filter((ids) => ids.some((id) => typeof id !== "string")), (ids) => { + expect(readAcceptedTaskIdsV2({ notifications: { taskIds: ids } })).toEqual([]); + })); + }); + + it("uses exact client and server capability envelopes", () => { + const wire = withTaskCapabilityV2({ _meta: { trace: "x" } }); + expect(wire).toEqual({ _meta: { trace: "x", "io.modelcontextprotocol/clientCapabilities": { extensions: { "io.modelcontextprotocol/tasks": {} } } } }); + expect(hasTaskClientCapabilityV2(wire)).toBe(true); + expect(hasTaskServerCapabilityV2({ extensions: { "io.modelcontextprotocol/tasks": {} } })).toBe(true); + expect(hasTaskServerCapabilityV2({ extensions: {} })).toBe(false); }); }); diff --git a/packages/ext-tasks/src/core/v2/index.ts b/packages/ext-tasks/src/core/v2/index.ts index 5afff63..a484ebc 100644 --- a/packages/ext-tasks/src/core/v2/index.ts +++ b/packages/ext-tasks/src/core/v2/index.ts @@ -1,4 +1,384 @@ -/** - * MCP Tasks V2 wire declarations, codecs, guards, and request bindings. - */ -export {}; +/** MCP Tasks V2 wire declarations, codecs, guards, and request bindings. */ +import { + ProtocolDecodeError, + createRuntimeCodec, + expectEnum, + expectNumber, + expectRecord, + expectString, + type DecodePath, + type JsonValue, + type RuntimeCodec, +} from "../index.js"; + +export const TASKS_EXTENSION_ID_V2 = "io.modelcontextprotocol/tasks" as const; +export const CLIENT_CAPABILITIES_META_KEY_V2 = + "io.modelcontextprotocol/clientCapabilities" as const; + +export type RequestIdV2 = string | number; +export type TaskStatusV2 = + | "working" + | "input_required" + | "completed" + | "failed" + | "cancelled"; + +export interface TaskV2 { + readonly taskId: string; + readonly status: TaskStatusV2; + readonly statusMessage?: string; + readonly createdAt: string; + readonly lastUpdatedAt: string; + readonly ttlMs: number | null; + readonly pollIntervalMs?: number; +} + +export interface WorkingTaskV2 extends TaskV2 { readonly status: "working"; } +export interface InputRequiredTaskV2 extends TaskV2 { + readonly status: "input_required"; + readonly inputRequests: InputRequestsV2; +} +export interface CompletedTaskV2 extends TaskV2 { + readonly status: "completed"; + readonly result: Readonly>; +} +export interface FailedTaskV2 extends TaskV2 { + readonly status: "failed"; + readonly error: ErrorV2; +} +export interface CancelledTaskV2 extends TaskV2 { readonly status: "cancelled"; } +export type DetailedTaskV2 = + | WorkingTaskV2 | InputRequiredTaskV2 | CompletedTaskV2 | FailedTaskV2 | CancelledTaskV2; + +export interface ErrorV2 { + readonly code: number; + readonly message: string; + readonly data?: JsonValue; +} + +export interface CreateMessageRequestV2 { + readonly method: "sampling/createMessage"; + readonly params: Readonly>; +} +export interface ListRootsRequestV2 { + readonly method: "roots/list"; + readonly params?: Readonly>; +} +export interface ElicitRequestV2 { + readonly method: "elicitation/create"; + readonly params: Readonly>; +} +export type InputRequestV2 = CreateMessageRequestV2 | ListRootsRequestV2 | ElicitRequestV2; +export type InputRequestsV2 = Readonly>; + +export interface CreateMessageResultV2 extends Readonly> { + readonly content: JsonValue; + readonly model: string; + readonly role: "user" | "assistant"; +} +export interface ListRootsResultV2 extends Readonly> { + readonly roots: readonly JsonValue[]; +} +export interface ElicitResultV2 extends Readonly> { + readonly action: "accept" | "decline" | "cancel"; +} +export type InputResponseV2 = CreateMessageResultV2 | ListRootsResultV2 | ElicitResultV2; +export type InputResponsesV2 = Readonly>; + +export interface CreateTaskResultV2 extends TaskV2 { + readonly resultType: "task"; + readonly _meta?: Readonly>; +} +export type ToolCallResultV2 = Readonly> & { readonly resultType: string }; +export type EligibleTaskResultV2 = ToolCallResultV2 | CreateTaskResultV2; + +interface JsonRpcRequestV2 { + readonly jsonrpc: "2.0"; + readonly id: RequestIdV2; +} +export interface GetTaskRequestV2 extends JsonRpcRequestV2 { + readonly method: "tasks/get"; + readonly params: { readonly taskId: string }; +} +export interface UpdateTaskRequestV2 extends JsonRpcRequestV2 { + readonly method: "tasks/update"; + readonly params: { readonly taskId: string; readonly inputResponses: InputResponsesV2 }; +} +export interface CancelTaskRequestV2 extends JsonRpcRequestV2 { + readonly method: "tasks/cancel"; + readonly params: { readonly taskId: string }; +} +export type GetTaskResultV2 = DetailedTaskV2 & { + readonly resultType: "complete"; + readonly _meta?: Readonly>; +}; +export interface UpdateTaskResultV2 extends Readonly> { + readonly resultType: "complete"; +} +export interface CancelTaskResultV2 extends Readonly> { + readonly resultType: "complete"; +} + +export type TaskStatusNotificationParamsV2 = DetailedTaskV2 & { + readonly _meta?: Readonly>; +}; +export interface TaskStatusNotificationV2 { + readonly jsonrpc: "2.0"; + readonly method: "notifications/tasks"; + readonly params: TaskStatusNotificationParamsV2; +} +export interface TaskSubscriptionNotificationsV2 { readonly taskIds?: readonly string[]; } +export interface TaskSubscriptionAcknowledgedNotificationsV2 { readonly taskIds?: readonly string[]; } +export type TaskExtensionCapabilitiesV2 = Readonly>; +export type TasksExtensionCapabilityV2 = TaskExtensionCapabilitiesV2; + +export interface ClientTaskCapabilityEnvelopeV2 { + readonly extensions: { readonly [TASKS_EXTENSION_ID_V2]: TaskExtensionCapabilitiesV2 }; +} +export interface ServerTaskCapabilityEnvelopeV2 { + readonly extensions?: Readonly>; +} + +const statuses = ["working", "input_required", "completed", "failed", "cancelled"] as const; +const inputMethods = ["sampling/createMessage", "roots/list", "elicitation/create"] as const; + +function has(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} +function expectInteger(value: JsonValue | undefined, path: DecodePath): number { + const number = expectNumber(value, path); + if (!Number.isInteger(number)) throw new ProtocolDecodeError("expected integer", path); + return number; +} +function expectRequestId(value: JsonValue | undefined, path: DecodePath): RequestIdV2 { + if (typeof value === "string") return value; + return expectInteger(value, path); +} +function expectConst(value: JsonValue | undefined, expected: string, path: DecodePath): void { + if (value !== expected) throw new ProtocolDecodeError(`expected ${expected}`, path); +} +function optionalRecord(value: JsonValue | undefined, path: DecodePath) { + return value === undefined ? undefined : expectRecord(value, path); +} + +function decodeTask(value: JsonValue, path: DecodePath): TaskV2 { + const object = expectRecord(value, path); + const ttl = object.ttlMs; + if (ttl !== null && ttl === undefined) throw new ProtocolDecodeError("required field", [...path, "ttlMs"]); + const task: TaskV2 = { + taskId: expectString(object.taskId, [...path, "taskId"]), + status: expectEnum(object.status, statuses, [...path, "status"]), + createdAt: expectString(object.createdAt, [...path, "createdAt"]), + lastUpdatedAt: expectString(object.lastUpdatedAt, [...path, "lastUpdatedAt"]), + ttlMs: ttl === null ? null : expectInteger(ttl, [...path, "ttlMs"]), + ...(object.statusMessage === undefined ? {} : { statusMessage: expectString(object.statusMessage, [...path, "statusMessage"]) }), + ...(object.pollIntervalMs === undefined ? {} : { pollIntervalMs: expectInteger(object.pollIntervalMs, [...path, "pollIntervalMs"]) }), + }; + return task; +} + +function decodeError(value: JsonValue, path: DecodePath): ErrorV2 { + const object = expectRecord(value, path); + return { + code: expectInteger(object.code, [...path, "code"]), + message: expectString(object.message, [...path, "message"]), + ...(has(object, "data") ? { data: object.data as JsonValue } : {}), + }; +} + +function decodeInputRequest(value: JsonValue, path: DecodePath): InputRequestV2 { + const object = expectRecord(value, path); + const method = expectEnum(object.method, inputMethods, [...path, "method"]); + if (method === "roots/list") { + return { method, ...(object.params === undefined ? {} : { params: expectRecord(object.params, [...path, "params"]) }) }; + } + return { method, params: expectRecord(object.params as JsonValue, [...path, "params"]) }; +} +function decodeInputRequests(value: JsonValue, path: DecodePath): InputRequestsV2 { + const object = expectRecord(value, path); + return Object.fromEntries(Object.entries(object).map(([key, request]) => [key, decodeInputRequest(request, [...path, key])])); +} + +function decodeInputResponse(value: JsonValue, path: DecodePath): InputResponseV2 { + const object = expectRecord(value, path); + if (has(object, "action")) { + expectEnum(object.action, ["accept", "decline", "cancel"], [...path, "action"]); + } else if (has(object, "roots")) { + if (!Array.isArray(object.roots)) throw new ProtocolDecodeError("expected array", [...path, "roots"]); + } else { + if (!has(object, "content")) throw new ProtocolDecodeError("required field", [...path, "content"]); + expectString(object.model, [...path, "model"]); + expectEnum(object.role, ["user", "assistant"], [...path, "role"]); + } + return object as InputResponseV2; +} +function decodeInputResponses(value: JsonValue, path: DecodePath): InputResponsesV2 { + const object = expectRecord(value, path); + return Object.fromEntries(Object.entries(object).map(([key, response]) => [key, decodeInputResponse(response, [...path, key])])); +} + +function decodeDetailedTask(value: JsonValue, path: DecodePath): DetailedTaskV2 { + const object = expectRecord(value, path); + const task = decodeTask(value, path); + switch (task.status) { + case "input_required": return { ...task, status: task.status, inputRequests: decodeInputRequests(object.inputRequests as JsonValue, [...path, "inputRequests"]) }; + case "completed": return { ...task, status: task.status, result: expectRecord(object.result as JsonValue, [...path, "result"]) }; + case "failed": return { ...task, status: task.status, error: decodeError(object.error as JsonValue, [...path, "error"]) }; + case "working": return { ...task, status: task.status }; + case "cancelled": return { ...task, status: task.status }; + } +} + +function decodeRpcRequest(value: JsonValue, path: DecodePath, method: string) { + const object = expectRecord(value, path); + expectConst(object.jsonrpc, "2.0", [...path, "jsonrpc"]); + expectConst(object.method, method, [...path, "method"]); + return { object, id: expectRequestId(object.id, [...path, "id"]), params: expectRecord(object.params as JsonValue, [...path, "params"]) }; +} +function decodeCompleteResult(value: JsonValue, path: DecodePath) { + const object = expectRecord(value, path); + expectConst(object.resultType, "complete", [...path, "resultType"]); + optionalRecord(object._meta, [...path, "_meta"]); + return object; +} + +export const TaskV2Codec: RuntimeCodec = createRuntimeCodec(decodeTask); +export const DetailedTaskV2Codec: RuntimeCodec = createRuntimeCodec(decodeDetailedTask); +export const ErrorV2Codec: RuntimeCodec = createRuntimeCodec(decodeError); +export const InputRequestV2Codec: RuntimeCodec = createRuntimeCodec(decodeInputRequest); +export const InputRequestsV2Codec: RuntimeCodec = createRuntimeCodec(decodeInputRequests); +export const InputResponseV2Codec: RuntimeCodec = createRuntimeCodec(decodeInputResponse); +export const InputResponsesV2Codec: RuntimeCodec = createRuntimeCodec(decodeInputResponses); +export const CreateMessageRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const request = decodeInputRequest(value, path); + if (request.method !== "sampling/createMessage") throw new ProtocolDecodeError("expected sampling/createMessage", [...path, "method"]); + return request; +}); +export const ListRootsRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const request = decodeInputRequest(value, path); + if (request.method !== "roots/list") throw new ProtocolDecodeError("expected roots/list", [...path, "method"]); + return request; +}); +export const ElicitRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const request = decodeInputRequest(value, path); + if (request.method !== "elicitation/create") throw new ProtocolDecodeError("expected elicitation/create", [...path, "method"]); + return request; +}); +export const CreateMessageResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const response = decodeInputResponse(value, path); + if (!("content" in response) || !("model" in response) || !("role" in response)) throw new ProtocolDecodeError("expected sampling result", path); + return response as CreateMessageResultV2; +}); +export const ListRootsResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const response = decodeInputResponse(value, path); + if (!("roots" in response)) throw new ProtocolDecodeError("expected roots result", path); + return response as ListRootsResultV2; +}); +export const ElicitResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const response = decodeInputResponse(value, path); + if (!("action" in response)) throw new ProtocolDecodeError("expected elicitation result", path); + return response as ElicitResultV2; +}); +export const CreateTaskResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + expectConst(object.resultType, "task", [...path, "resultType"]); + optionalRecord(object._meta, [...path, "_meta"]); + return { ...decodeTask(value, path), resultType: "task", ...(object._meta === undefined ? {} : { _meta: expectRecord(object._meta, [...path, "_meta"]) }) }; +}); +export const GetTaskRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const { id, params } = decodeRpcRequest(value, path, "tasks/get"); + return { jsonrpc: "2.0", id, method: "tasks/get", params: { taskId: expectString(params.taskId, [...path, "params", "taskId"]) } }; +}); +export const UpdateTaskRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const { id, params } = decodeRpcRequest(value, path, "tasks/update"); + return { jsonrpc: "2.0", id, method: "tasks/update", params: { taskId: expectString(params.taskId, [...path, "params", "taskId"]), inputResponses: decodeInputResponses(params.inputResponses as JsonValue, [...path, "params", "inputResponses"]) } }; +}); +export const CancelTaskRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const { id, params } = decodeRpcRequest(value, path, "tasks/cancel"); + return { jsonrpc: "2.0", id, method: "tasks/cancel", params: { taskId: expectString(params.taskId, [...path, "params", "taskId"]) } }; +}); +export const GetTaskResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const object = decodeCompleteResult(value, path); + return { ...decodeDetailedTask(value, path), resultType: "complete", ...(object._meta === undefined ? {} : { _meta: expectRecord(object._meta, [...path, "_meta"]) }) }; +}); +export const UpdateTaskResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => decodeCompleteResult(value, path) as UpdateTaskResultV2); +export const CancelTaskResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => decodeCompleteResult(value, path) as CancelTaskResultV2); +export const WorkingTaskV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const task = decodeDetailedTask(value, path); if (task.status !== "working") throw new ProtocolDecodeError("expected working", [...path, "status"]); return task; }); +export const InputRequiredTaskV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const task = decodeDetailedTask(value, path); if (task.status !== "input_required") throw new ProtocolDecodeError("expected input_required", [...path, "status"]); return task; }); +export const CompletedTaskV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const task = decodeDetailedTask(value, path); if (task.status !== "completed") throw new ProtocolDecodeError("expected completed", [...path, "status"]); return task; }); +export const FailedTaskV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const task = decodeDetailedTask(value, path); if (task.status !== "failed") throw new ProtocolDecodeError("expected failed", [...path, "status"]); return task; }); +export const CancelledTaskV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const task = decodeDetailedTask(value, path); if (task.status !== "cancelled") throw new ProtocolDecodeError("expected cancelled", [...path, "status"]); return task; }); +export const TaskStatusNotificationParamsV2Codec: RuntimeCodec = createRuntimeCodec(decodeDetailedTask); +export const TaskSubscriptionNotificationsV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + if (object.taskIds === undefined) return {}; + if (!Array.isArray(object.taskIds) || !object.taskIds.every((id) => typeof id === "string")) throw new ProtocolDecodeError("expected string array", [...path, "taskIds"]); + return { taskIds: object.taskIds }; +}); +export const TaskSubscriptionAcknowledgedNotificationsV2Codec: RuntimeCodec = TaskSubscriptionNotificationsV2Codec; +export const TaskExtensionCapabilitiesV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + if (Object.keys(object).length !== 0) throw new ProtocolDecodeError("expected empty object", path); + return {}; +}); +export const TasksExtensionCapabilityV2Codec: RuntimeCodec = TaskExtensionCapabilitiesV2Codec; +export const TaskStatusNotificationV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + expectConst(object.jsonrpc, "2.0", [...path, "jsonrpc"]); + expectConst(object.method, "notifications/tasks", [...path, "method"]); + return { jsonrpc: "2.0", method: "notifications/tasks", params: decodeDetailedTask(object.params as JsonValue, [...path, "params"]) }; +}); + +function parsed(codec: { parse(value: JsonValue): { success: boolean } }, value: unknown): value is T { + return value !== undefined && codec.parse(value as JsonValue).success; +} +export const isTaskV2: (value: unknown) => value is TaskV2 = (value: unknown): value is TaskV2 => parsed(TaskV2Codec, value); +export const isDetailedTaskV2: (value: unknown) => value is DetailedTaskV2 = (value: unknown): value is DetailedTaskV2 => parsed(DetailedTaskV2Codec, value); +export const isCreateTaskResultV2: (value: unknown) => value is CreateTaskResultV2 = (value: unknown): value is CreateTaskResultV2 => parsed(CreateTaskResultV2Codec, value); +export const isGetTaskRequestV2: (value: unknown) => value is GetTaskRequestV2 = (value: unknown): value is GetTaskRequestV2 => parsed(GetTaskRequestV2Codec, value); +export const isUpdateTaskRequestV2: (value: unknown) => value is UpdateTaskRequestV2 = (value: unknown): value is UpdateTaskRequestV2 => parsed(UpdateTaskRequestV2Codec, value); +export const isCancelTaskRequestV2: (value: unknown) => value is CancelTaskRequestV2 = (value: unknown): value is CancelTaskRequestV2 => parsed(CancelTaskRequestV2Codec, value); +export const isTaskStatusNotificationV2: (value: unknown) => value is TaskStatusNotificationV2 = (value: unknown): value is TaskStatusNotificationV2 => parsed(TaskStatusNotificationV2Codec, value); + +export function isToolCallTaskResultV2(method: string, value: unknown): value is CreateTaskResultV2 { + return method === "tools/call" && isCreateTaskResultV2(value); +} +export const isEligibleTaskResultV2: typeof isToolCallTaskResultV2 = isToolCallTaskResultV2; + +export function hasTaskClientCapabilityV2(value: unknown): boolean { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const meta = (value as { _meta?: unknown })._meta; + if (meta === null || typeof meta !== "object" || Array.isArray(meta)) return false; + const capabilities = (meta as Record)[CLIENT_CAPABILITIES_META_KEY_V2]; + if (capabilities === null || typeof capabilities !== "object" || Array.isArray(capabilities)) return false; + const extensions = (capabilities as { extensions?: unknown }).extensions; + return extensions !== null && typeof extensions === "object" && !Array.isArray(extensions) && + Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2); +} +export function hasTaskServerCapabilityV2(value: unknown): value is ServerTaskCapabilityEnvelopeV2 { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const extensions = (value as { extensions?: unknown }).extensions; + return extensions !== null && typeof extensions === "object" && !Array.isArray(extensions) && + Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2); +} +export const supportsTasksExtensionV2: typeof hasTaskServerCapabilityV2 = hasTaskServerCapabilityV2; + +export function withTaskCapabilityV2>>(params: T): T & Readonly> { + const wireMeta = params._meta; + const base = wireMeta !== null && typeof wireMeta === "object" && !Array.isArray(wireMeta) ? wireMeta : {}; + const capability = { extensions: { [TASKS_EXTENSION_ID_V2]: {} } }; + return { ...params, _meta: { ...base, [CLIENT_CAPABILITIES_META_KEY_V2]: capability } }; +} + +export function contributeTaskFilterV2>>(filter: T, taskIds: readonly string[]): T & { readonly notifications: Readonly> & { readonly taskIds: readonly string[] } } { + const notifications = filter.notifications; + const prior: Readonly> = notifications !== null && typeof notifications === "object" && !Array.isArray(notifications) ? notifications as Readonly> : {}; + return { ...filter, notifications: { ...prior, taskIds: [...new Set(taskIds)] } }; +} +export function readAcceptedTaskIdsV2(value: unknown): readonly string[] { + if (value === null || typeof value !== "object" || Array.isArray(value)) return []; + const notifications = (value as { notifications?: unknown }).notifications; + if (notifications === null || typeof notifications !== "object" || Array.isArray(notifications)) return []; + const ids = (notifications as { taskIds?: unknown }).taskIds; + return Array.isArray(ids) && ids.every((id) => typeof id === "string") ? [...ids] : []; +} From 088d7aad8716408a5fe9f312067d64df70a64a64 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 4 Sep 2026 17:21:16 -0700 Subject: [PATCH 03/33] feat(client): add task-enabled session foundation - implement the connected session port and immediate executions - manage tool declarations with abortable ordered refreshes - add input routing, lifecycle guards, and V2 tool codecs --- packages/ext-tasks/README.md | 5 +- packages/ext-tasks/src/client/index.test.ts | 434 ++++++++++++- packages/ext-tasks/src/client/index.ts | 617 ++++++++++++++++++- packages/ext-tasks/src/core/v1/index.ts | 7 +- packages/ext-tasks/src/core/v2/index.test.ts | 64 +- packages/ext-tasks/src/core/v2/index.ts | 169 ++++- 6 files changed, 1277 insertions(+), 19 deletions(-) diff --git a/packages/ext-tasks/README.md b/packages/ext-tasks/README.md index b8bca8f..fc80822 100644 --- a/packages/ext-tasks/README.md +++ b/packages/ext-tasks/README.md @@ -17,6 +17,5 @@ The public package subpaths are: - `@modelcontextprotocol/ext-tasks/client` - `@modelcontextprotocol/ext-tasks/server` (reserved for receiver-side support) -The package is currently scaffolded for implementation. Source lives in `src/` -and is emitted as ESM JavaScript, TypeScript declarations, and source maps in -`dist/`. +Source is emitted as ESM JavaScript, TypeScript declarations, and source maps +in `dist/`. diff --git a/packages/ext-tasks/src/client/index.test.ts b/packages/ext-tasks/src/client/index.test.ts index ad79cfc..53f06f7 100644 --- a/packages/ext-tasks/src/client/index.test.ts +++ b/packages/ext-tasks/src/client/index.test.ts @@ -1,9 +1,435 @@ +import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import * as client from "./index.js"; +import { createRuntimeCodec, expectRecord, type JsonValue } from "../core/index.js"; +import type { ServerTaskCapabilitiesV1, ToolV1 } from "../core/v1/index.js"; -describe("client entry point", () => { - it("can be imported", () => { - expect(client).toBeTypeOf("object"); +import { + DispatchError, + InputCorrelationError, + JsonRpcResponseError, + withTasks, + type ConnectedMcpSessionPort, + type IncomingServerRequest, + type JsonRpcResponse, + type SessionTaskCapabilities, +} from "./index.js"; + +const asJson = (value: unknown): JsonValue => JSON.parse(JSON.stringify(value)) as JsonValue; + +class FakePort implements ConnectedMcpSessionPort { + readonly requests: JsonValue[] = []; + readonly taskCapabilities: SessionTaskCapabilities; + invalidated = false; + response: JsonRpcResponse = { kind: "result", result: { content: [] } }; + dispatchHandler?: (request: JsonValue, options?: { readonly signal?: AbortSignal }) => Promise; + private requestHandler?: (incoming: IncomingServerRequest) => Promise; + private notificationListener?: (notification: JsonValue) => void; + private invalidationListener?: (reason: unknown) => void; + listenerDisposals = 0; + + constructor(taskCapabilities: SessionTaskCapabilities = { generation: "none" }) { + this.taskCapabilities = taskCapabilities; + } + + async dispatch(request: JsonValue, options?: { readonly signal?: AbortSignal }): Promise { + this.requests.push(request); + return this.dispatchHandler === undefined ? this.response : this.dispatchHandler(request, options); + } + + onServerRequest(handler: (incoming: IncomingServerRequest) => Promise): () => void { + this.requestHandler = handler; + return () => { this.requestHandler = undefined; this.listenerDisposals += 1; }; + } + + onNotification(listener: (notification: JsonValue) => void): () => void { + this.notificationListener = listener; + return () => { this.notificationListener = undefined; this.listenerDisposals += 1; }; + } + + onInvalidated(listener: (reason: unknown) => void): () => void { + this.invalidationListener = listener; + return () => { this.invalidationListener = undefined; this.listenerDisposals += 1; }; + } + + invalidate(reason: unknown): void { + this.invalidated = true; + this.invalidationListener?.(reason); + } + + async serve(request: JsonValue): Promise { + if (this.requestHandler === undefined) throw new Error("request handler is not installed"); + return this.requestHandler({ request, requestContext: {} }); + } + + notify(notification: JsonValue): void { + this.notificationListener?.(notification); + } +} + +describe("client tool executions", () => { + it("dispatches a non-task call and caches the decoded result", async () => { + await fc.assert(fc.asyncProperty( + fc.string(), + fc.dictionary(fc.string(), fc.jsonValue()), + async (name, args) => { + const port = new FakePort(); + const result = { content: [{ type: "text", text: name }] }; + port.response = { kind: "result", result: asJson(result) }; + const session = withTasks(port, { tools: { currentTool: () => undefined } }); + const normalizedArgs = asJson(args) as Readonly>; + const execution = await session.callTool(name, normalizedArgs); + expect(execution.kind).toBe("immediate"); + expect(port.requests).toEqual([{ method: "tools/call", params: { name, arguments: normalizedArgs } }]); + const first = execution.result(); + const second = execution.result(); + expect(first).toBe(second); + await expect(first).resolves.toEqual(result); + const updates: unknown[] = []; + for await (const update of execution.updates()) updates.push(update); + expect(updates).toEqual([]); + await execution.cancel(); + expect(port.requests).toHaveLength(1); + await session.close(); + }, + )); + }); + + it("uses an application result codec at the dispatch boundary", async () => { + const port = new FakePort(); + port.response = { kind: "result", result: { answer: 42 } }; + const codec = createRuntimeCodec((value) => { + const record = expectRecord(value); + if (typeof record.answer !== "number") throw new Error("answer required"); + return record.answer; + }); + const session = withTasks(port, { tools: { currentTool: () => undefined } }); + const execution = await session.callTool("answer", undefined, { resultCodec: codec, applicationContext: "ctx" }); + expect(execution.applicationContext).toBe("ctx"); + await expect(execution.result()).resolves.toBe(42); + await session.close(); + }); + + it("preserves complete JSON-RPC errors and dispatch failures", async () => { + const port = new FakePort(); + port.response = { kind: "error", error: { code: -32001, message: "denied", data: { retry: false } } }; + const session = withTasks(port, { tools: { currentTool: () => undefined } }); + await expect(session.callTool("denied")).rejects.toMatchObject({ + name: "JsonRpcResponseError", + code: -32001, + message: "denied", + data: { retry: false }, + }); + const error = new DispatchError("offline", true); + expect(error.retryable).toBe(true); + expect(new JsonRpcResponseError({ code: 1, message: "x" })).toBeInstanceOf(Error); + await session.close(); + }); + + it("closes executions and sessions idempotently without closing the borrowed port", async () => { + const port = new FakePort(); + const session = withTasks(port, { tools: { currentTool: () => undefined } }); + const execution = await session.callTool("x"); + await execution.close(); + await execution.close(); + await session.close(); + await session.close(); + expect(port.listenerDisposals).toBe(3); + expect(port.invalidated).toBe(false); + }); + + it("rejects new and pending work after session invalidation", async () => { + const port = new FakePort(); + const session = withTasks(port, { tools: { currentTool: () => undefined } }); + port.invalidate(new Error("replaced")); + await expect(session.callTool("x")).rejects.toThrow("replaced"); + await session.close(); + }); + + it("aborts pending discovery when the port is invalidated", async () => { + const port = new FakePort(); + port.dispatchHandler = (_request, options) => new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("invalidated", "AbortError")), { once: true }); + }); + const session = withTasks(port); + const pending = session.callTool("x"); + port.invalidate(new Error("connection replaced")); + await expect(pending).rejects.toThrow(/connection replaced|invalidated/); + expect(port.requests).toHaveLength(1); + await session.close(); + }); + + + it("settles default V1 input declines with method-specific protocol values", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + const session = withTasks(port, { tools: { currentTool: () => undefined } }); + await expect(port.serve({ method: "elicitation/create", params: {} })).resolves.toEqual({ + kind: "result", + result: { action: "cancel" }, + }); + for (const method of ["sampling/createMessage", "roots/list"]) { + await expect(port.serve({ method, params: {} })).resolves.toEqual({ + kind: "error", + error: { code: -32603, message: "Internal error" }, + }); + } + await session.close(); + }); + + + it("routes ordinary input requests with the execution context", async () => { + const cases = [ + { method: "elicitation/create", result: { action: "accept", content: { value: "ok" } } }, + { method: "sampling/createMessage", result: { model: "m", role: "assistant", content: { type: "text", text: "ok" } } }, + { method: "roots/list", result: { roots: [{ uri: "file:///tmp" }] } }, + ] as const; + for (const input of cases) { + const port = new FakePort({ generation: "v1", capabilities: {} }); + const observed: unknown[] = []; + port.dispatchHandler = async () => { + observed.push(await port.serve({ method: input.method, params: { prompt: "p" } })); + return { kind: "result", result: { content: [] } }; + }; + const session = withTasks<{ readonly marker: string }>(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async (request, context) => { + observed.push({ request, context }); + return input.result as never; + }, + }); + await session.callTool("x", undefined, { applicationContext: { marker: "ctx" } }); + expect(observed[0]).toMatchObject({ + request: { params: { prompt: "p" } }, + context: { lifetime: "basic", applicationContext: { marker: "ctx" } }, + }); + expect((observed[0] as { context: { executionId: string } }).context.executionId).toMatch(/^execution-/); + expect(observed[1]).toEqual({ kind: "result", result: input.result }); + await session.close(); + } + }); + + it("settles configured input-handler rejection fail-closed", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + let settlement: JsonRpcResponse | undefined; + port.dispatchHandler = async () => { + settlement = await port.serve({ method: "elicitation/create", params: {} }); + return { kind: "result", result: { content: [] } }; + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async () => { throw new Error("declined"); }, + }); + await session.callTool("x"); + expect(settlement).toEqual({ kind: "result", result: { action: "cancel" } }); + await session.close(); + }); + + it("reports ambiguous ordinary input correlation before declining", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + const completions: ((response: JsonRpcResponse) => void)[] = []; + port.dispatchHandler = () => new Promise((resolve) => completions.push(resolve)); + const errors: Error[] = []; + let handlerCalls = 0; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async () => { handlerCalls += 1; return { action: "accept" } as never; }, + onError: (error) => errors.push(error), + }); + const first = session.callTool("first", undefined, { applicationContext: "one" }); + const second = session.callTool("second", undefined, { applicationContext: "two" }); + await Promise.resolve(); + await expect(port.serve({ method: "elicitation/create", params: {} })).resolves.toEqual({ + kind: "result", + result: { action: "cancel" }, + }); + expect(handlerCalls).toBe(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(InputCorrelationError); + expect(errors[0]).toMatchObject({ reason: "ambiguous-matches", requestKind: "elicitation" }); + expect((errors[0] as InputCorrelationError).candidates.map((candidate) => candidate.toolName)).toEqual(["first", "second"]); + for (const complete of completions) complete({ kind: "result", result: { content: [] } }); + await Promise.all([first, second]); + await session.close(); + }); + it("honors already-aborted call and session signals before dispatch", async () => { + const callPort = new FakePort(); + const callSession = withTasks(callPort, { tools: { currentTool: () => undefined } }); + const callController = new AbortController(); + callController.abort(new Error("call aborted")); + await expect(callSession.callTool("x", undefined, { signal: callController.signal })).rejects.toThrow("call aborted"); + expect(callPort.requests).toEqual([]); + await callSession.close(); + + const sessionPort = new FakePort(); + const sessionController = new AbortController(); + const session = withTasks(sessionPort, { tools: { currentTool: () => undefined }, signal: sessionController.signal }); + sessionController.abort(new Error("session aborted")); + await expect(session.callTool("x")).rejects.toThrow("session aborted"); + expect(sessionPort.requests).toEqual([]); + await session.close(); + }); + it("applies the exhaustive V1 capability-first task augmentation table", async () => { + const support = fc.option(fc.constantFrom("forbidden", "optional", "required"), { nil: undefined }); + await fc.assert(fc.asyncProperty(fc.boolean(), support, fc.boolean(), async (present, taskSupport, preferTask) => { + const capabilities: ServerTaskCapabilitiesV1 = present ? { requests: { tools: { call: {} } } } : {}; + const port = new FakePort({ generation: "v1", capabilities }); + port.response = { kind: "result", result: { content: [] } }; + let lookups = 0; + const tool: ToolV1 = { name: "x", inputSchema: { type: "object" }, execution: { taskSupport } }; + const session = withTasks(port, { tools: { currentTool: () => { lookups += 1; return tool; } } }); + const taskSelected = present && (taskSupport === "required" || (taskSupport === "optional" && preferTask)); + if (taskSelected) { + await expect(session.callTool("x", undefined, { preferTask })).rejects.toThrow("Task execution is not supported"); + expect(port.requests).toEqual([]); + } else { + await session.callTool("x", undefined, { preferTask }); + expect(port.requests).toEqual([{ method: "tools/call", params: { name: "x" } }]); + } + expect(lookups).toBe(1); + await session.close(); + })); + }); + + it("does not mistake open ordinary result extensions for task creation", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + port.response = { kind: "result", result: { content: [], task: "application-data" } }; + const session = withTasks(port, { tools: { currentTool: () => undefined } }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toEqual({ content: [], task: "application-data" }); + await session.close(); + }); + + it("manages initial tool declarations only when no provider is supplied", async () => { + const managed = new FakePort({ generation: "v1", capabilities: {} }); + managed.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/list") { + return { kind: "result", result: asJson({ tools: [{ name: "listed", inputSchema: { type: "object" } }] }) }; + } + return { kind: "result", result: asJson({ content: [] }) }; + }; + const managedSession = withTasks(managed); + await managedSession.callTool("listed"); + expect(managed.requests).toEqual([ + { method: "tools/list", params: {} }, + { method: "tools/call", params: { name: "listed" } }, + ]); + await managedSession.close(); + + const supplied = new FakePort(); + const suppliedSession = withTasks(supplied, { tools: { currentTool: () => undefined } }); + await suppliedSession.callTool("x"); + expect(supplied.requests).toEqual([{ method: "tools/call", params: { name: "x" } }]); + await suppliedSession.close(); + }); + + + it("retries initial discovery and follows tool-list cursors", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + let attempts = 0; + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method !== "tools/list") return { kind: "result", result: asJson({ content: [] }) }; + attempts += 1; + if (attempts === 1) throw new DispatchError("temporary", true); + const params = expectRecord(record.params as JsonValue); + if (params.cursor === undefined) { + return { kind: "result", result: asJson({ tools: [{ name: "first", inputSchema: { type: "object" } }], nextCursor: "next" }) }; + } + return { kind: "result", result: asJson({ tools: [{ name: "second", inputSchema: { type: "object" } }] }) }; + }; + const session = withTasks(port); + await session.callTool("second"); + expect(port.requests.slice(0, 3)).toEqual([ + { method: "tools/list", params: {} }, + { method: "tools/list", params: {} }, + { method: "tools/list", params: { cursor: "next" } }, + ]); + await session.close(); + }); + it("ignores stale tool-list refreshes", async () => { + const port = new FakePort({ generation: "v1", capabilities: { requests: { tools: { call: {} } } } }); + const pending: ((response: JsonRpcResponse) => void)[] = []; + let abortedRefreshes = 0; + let listCount = 0; + port.dispatchHandler = (request, options) => { + const record = expectRecord(request); + if (record.method !== "tools/list") return Promise.resolve({ kind: "result", result: asJson({ content: [] }) }); + listCount += 1; + if (listCount === 1) { + return Promise.resolve({ kind: "result", result: asJson({ tools: [{ name: "x", inputSchema: { type: "object" } }] }) }); + } + return new Promise((resolve, reject) => { + options?.signal?.addEventListener("abort", () => { + abortedRefreshes += 1; + reject(new DOMException("superseded", "AbortError")); + }, { once: true }); + pending.push(resolve); + }); + }; + const session = withTasks(port); + await session.callTool("x"); + port.requests.length = 0; + port.notify({ method: "notifications/tools/list_changed" }); + port.notify({ method: "notifications/tools/list_changed" }); + expect(abortedRefreshes).toBe(1); + pending[1]?.({ kind: "result", result: asJson({ tools: [{ name: "x", inputSchema: { type: "object" }, execution: { taskSupport: "required" } }] }) }); + await Promise.resolve(); + await expect(session.callTool("x")).rejects.toThrow("Task execution is not supported"); + expect(port.requests.every((request) => expectRecord(request).method === "tools/list")).toBe(true); + await session.close(); + }); + + + it("reports duplicate tools and aborts managed discovery on close", async () => { + const errors: Error[] = []; + const duplicatePort = new FakePort({ generation: "v1", capabilities: {} }); + duplicatePort.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/list") { + return { kind: "result", result: asJson({ tools: [ + { name: "duplicate", inputSchema: { type: "object" } }, + { name: "duplicate", inputSchema: { type: "object" }, title: "newer" }, + ] }) }; + } + return { kind: "result", result: asJson({ content: [] }) }; + }; + const duplicateSession = withTasks(duplicatePort, { onError: (error) => errors.push(error) }); + await duplicateSession.callTool("duplicate"); + expect(errors.map((error) => error.message)).toContain("Duplicate tool declaration: duplicate"); + await duplicateSession.close(); + + const callAbortPort = new FakePort(); + callAbortPort.dispatchHandler = (_request, options) => new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("discovery aborted", "AbortError")), { once: true }); + }); + const callAbortSession = withTasks(callAbortPort); + const callController = new AbortController(); + const call = callAbortSession.callTool("x", undefined, { signal: callController.signal }); + callController.abort(new Error("waiter aborted")); + await expect(call).rejects.toThrow("waiter aborted"); + expect(callAbortPort.requests).toHaveLength(1); + await callAbortSession.close(); + + const closePort = new FakePort(); + let refreshSignal: AbortSignal | undefined; + closePort.dispatchHandler = (_request, options) => new Promise((_resolve, reject) => { + refreshSignal = options?.signal; + options?.signal?.addEventListener("abort", () => reject(new DOMException("closed", "AbortError")), { once: true }); + }); + const closeSession = withTasks(closePort); + const pendingCall = closeSession.callTool("x"); + await closeSession.close(); + await expect(pendingCall).rejects.toThrow(/closed|aborted/i); + expect(refreshSignal?.aborted).toBe(true); + expect(closePort.requests).toHaveLength(1); + }); + it("rejects an unambiguous generation-mismatched declaration before dispatch", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + const v1Tool: ToolV1 = { name: "x", inputSchema: { type: "object" }, execution: { taskSupport: "required" } }; + const session = withTasks(port, { tools: { currentTool: () => v1Tool } }); + await expect(session.callTool("x")).rejects.toThrow(/generation/i); + expect(port.requests).toEqual([]); + await session.close(); }); }); diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts index f9bbd62..3d5a53c 100644 --- a/packages/ext-tasks/src/client/index.ts +++ b/packages/ext-tasks/src/client/index.ts @@ -1,4 +1,613 @@ -/** - * Requester-side MCP Tasks session and execution support. - */ -export {}; +/** Requester-side MCP Tasks session and execution support. */ + +import { + isJsonValue, + type JsonValue, + type RuntimeCodec, + type TaskGeneration, + type TaskId, + type TaskSnapshot, +} from "../core/index.js"; +import { + CallToolResultV1Codec, + CreateTaskResultV1Codec, + ToolV1Codec, + shouldCallToolAsTaskV1, + type CallToolResultV1, + type ServerTaskCapabilitiesV1, + type TaskEligibleMethodV1, + type ToolV1, +} from "../core/v1/index.js"; +import { + CallToolResultV2Codec, + ToolV2Codec, + isCreateTaskResultV2, + type CallToolResultV2, + type ErrorV2, + type TaskExtensionCapabilitiesV2, + type TaskEligibleMethodV2, + type ToolV2, +} from "../core/v2/index.js"; + +export type SessionTaskCapabilities = + | { readonly generation: "none" } + | { readonly generation: "v1"; readonly capabilities: ServerTaskCapabilitiesV1 } + | { readonly generation: "v2"; readonly capabilities: TaskExtensionCapabilitiesV2 }; + +export type JsonRpcResponse = + | { readonly kind: "result"; readonly result: JsonValue } + | { readonly kind: "error"; readonly error: ErrorV2 }; + +export interface IncomingServerRequest { + readonly request: JsonValue; + readonly requestContext: unknown; +} + +export interface ConnectedMcpSessionPort { + readonly taskCapabilities: SessionTaskCapabilities; + dispatch(request: JsonValue, options?: { readonly signal?: AbortSignal }): Promise; + onServerRequest(handler: (incoming: IncomingServerRequest) => Promise): () => void; + onNotification(listener: (notification: JsonValue) => void): () => void; + onInvalidated(listener: (reason: unknown) => void): () => void; + readonly invalidated: boolean; +} + +export class DispatchError extends Error { + readonly retryable: boolean; + + constructor(message: string, retryable = false, options?: ErrorOptions) { + super(message, options); + this.name = "DispatchError"; + this.retryable = retryable; + } +} + +export class JsonRpcResponseError extends Error { + readonly code: number; + readonly data?: JsonValue; + readonly response: ErrorV2; + + constructor(error: ErrorV2, options?: ErrorOptions) { + super(error.message, options); + this.name = "JsonRpcResponseError"; + this.code = error.code; + if (error.data !== undefined) this.data = error.data; + this.response = error; + } +} + +export interface ToolDeclarationProvider { + currentTool(name: string): ToolV1 | ToolV2 | undefined; +} + +export type ApplicationInputRequest = + | { readonly kind: "elicitation"; readonly params: Readonly> } + | { readonly kind: "sampling"; readonly params: Readonly> } + | { readonly kind: "roots"; readonly params?: Readonly> }; + +export interface ApplicationElicitResult { + readonly action: "accept" | "decline" | "cancel"; + readonly content?: Readonly>; +} + +export type ApplicationCreateMessageResult = Readonly> & { + readonly model: string; + readonly role: "assistant" | "user"; + readonly content: JsonValue; +}; + +export interface ApplicationListRootsResult { + readonly roots: readonly Readonly>[]; +} + +export type ApplicationInputResult = + TRequest extends { readonly kind: "elicitation" } ? ApplicationElicitResult : + TRequest extends { readonly kind: "sampling" } ? ApplicationCreateMessageResult : + TRequest extends { readonly kind: "roots" } ? ApplicationListRootsResult : + never; + +export type ResolvedInputExchangeContext = + | { readonly lifetime: "basic"; readonly executionId: string; readonly applicationContext: TApplicationContext; readonly signal?: AbortSignal } + | { readonly lifetime: "task-v1"; readonly taskId: string; readonly applicationContext: TApplicationContext; readonly signal?: AbortSignal } + | { readonly lifetime: "task-v2"; readonly taskId: string; readonly inputKey: string; readonly applicationContext: TApplicationContext; readonly signal?: AbortSignal }; + +export interface ApplicationInputHandler { + handle( + request: TRequest, + context: ResolvedInputExchangeContext, + ): Promise>; +} + +export type InputCorrelationFailureReason = + | "missing-evidence" + | "invalid-evidence" + | "zero-matches" + | "ambiguous-matches"; + +export interface InputCorrelationCandidate { + readonly generation: TaskGeneration; + readonly toolName: string; + readonly executionId: string; + readonly applicationContext: TApplicationContext; +} + +export class InputCorrelationError extends Error { + constructor( + readonly generation: TaskGeneration, + readonly requestKind: ApplicationInputRequest["kind"], + readonly candidates: readonly InputCorrelationCandidate[], + readonly reason: InputCorrelationFailureReason, + ) { + super(`Input request correlation failed: ${reason}`); + this.name = "InputCorrelationError"; + } +} + + +export interface WithTasksOptions { + readonly tools?: ToolDeclarationProvider; + readonly onInputRequest?: ApplicationInputHandler["handle"]; + readonly onError?: (error: Error) => void; + readonly signal?: AbortSignal; +} + +export type { TaskEligibleMethodV2 } from "../core/v2/index.js"; + +export type TaskHandle = + | { readonly generation: "v1"; readonly taskId: TaskId; readonly originalOperation: TaskEligibleMethodV1 } + | { readonly generation: "v2"; readonly taskId: TaskId; readonly originalOperation: TaskEligibleMethodV2 }; + +export interface ToolExecutionCommon { + readonly applicationContext: TApplicationContext; + updates(signal?: AbortSignal): AsyncIterable; + result(): Promise; + cancel(signal?: AbortSignal): Promise; + close(): Promise; + [Symbol.asyncDispose](): Promise; +} + +export type ToolExecution = + | (ToolExecutionCommon & { readonly kind: "immediate"; readonly handle?: undefined }) + | (ToolExecutionCommon & { readonly kind: "task"; readonly handle: TaskHandle }); + +export class TaskUpdatesAlreadyAcquiredError extends Error { + constructor() { + super("Task updates have already been acquired"); + this.name = "TaskUpdatesAlreadyAcquiredError"; + } +} + +export class TaskExecutionClosedError extends Error { + constructor() { + super("Task execution is closed"); + this.name = "TaskExecutionClosedError"; + } +} + +export interface TaskEnabledSession { + callTool( + name: string, + params?: Readonly>, + options?: { + readonly resultCodec?: RuntimeCodec; + readonly applicationContext?: TApplicationContext; + readonly signal?: AbortSignal; + readonly preferTask?: boolean; + }, + ): Promise>; + resumeTask( + reference: SerializedTaskReference, + options?: { + readonly resultCodec?: RuntimeCodec; + readonly applicationContext?: TApplicationContext; + readonly signal?: AbortSignal; + }, + ): Promise>; + close(): Promise; + [Symbol.asyncDispose](): Promise; +} + +export type SerializedTaskReference = + | { readonly endpointId: string; readonly generation: "v1"; readonly taskId: TaskId; readonly originalOperation: TaskEligibleMethodV1 } + | { readonly endpointId: string; readonly generation: "v2"; readonly taskId: TaskId; readonly originalOperation: TaskEligibleMethodV2 }; + + +function defaultResultCodec( + generation: SessionTaskCapabilities["generation"], +): RuntimeCodec { + return generation === "v2" + ? CallToolResultV2Codec + : CallToolResultV1Codec; +} + +function reasonAsError(reason: unknown): Error { + if (reason instanceof Error) return reason; + return new Error(typeof reason === "string" ? reason : "MCP session was invalidated", { cause: reason }); +} + +function unsupported(feature: string): Error { + return new Error(`${feature} is not supported`); +} + +class ImmediateExecution +implements ToolExecutionCommon { + readonly kind = "immediate" as const; + readonly handle = undefined; + private closed = false; + + constructor( + readonly applicationContext: TApplicationContext, + private readonly resultPromise: Promise, + ) {} + + async *updates(_signal?: AbortSignal): AsyncIterable { + } + + result(): Promise { + return this.resultPromise; + } + + async cancel(_signal?: AbortSignal): Promise { + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } +} + +class ManagedToolDeclarations implements ToolDeclarationProvider { + private tools = new Map(); + private refreshSequence = 0; + private refreshController: AbortController | undefined; + private initialReady: Promise; + private closed = false; + + constructor( + private readonly port: ConnectedMcpSessionPort, + private readonly reportError: (error: Error) => void, + ) { + this.initialReady = this.refresh(); + void this.initialReady.catch(() => {}); + } + + currentTool(name: string): ToolV1 | ToolV2 | undefined { + return this.tools.get(name); + } + + async ensureReady(signal?: AbortSignal): Promise { + throwIfAborted(signal); + const wait = async (): Promise => { + try { + await this.initialReady; + } catch (error) { + if (this.closed || (error instanceof DOMException && error.name === "AbortError")) throw error; + this.initialReady = this.refresh(); + void this.initialReady.catch(() => {}); + await this.initialReady; + } + }; + const waiting = wait(); + if (signal === undefined) return waiting; + let onAbort: (() => void) | undefined; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError")); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + await Promise.race([waiting, aborted]); + } finally { + if (onAbort !== undefined) signal.removeEventListener("abort", onAbort); + } + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.refreshController?.abort(); + } + + onNotification(notification: JsonValue): void { + if (this.closed) return; + if (notification === null || Array.isArray(notification) || typeof notification !== "object") return; + const record = notification as Readonly>; + if (record.method !== "notifications/tools/list_changed") return; + void this.refresh().catch((error: unknown) => { + if (!(error instanceof DOMException && error.name === "AbortError")) { + this.reportError(error instanceof Error ? error : new Error("Tool refresh failed", { cause: error })); + } + }); + } + + private async refresh(): Promise { + if (this.closed) throw new DOMException("Tool declarations are closed", "AbortError"); + const sequence = ++this.refreshSequence; + this.refreshController?.abort(); + const controller = new AbortController(); + this.refreshController = controller; + const decoded = new Map(); + let cursor: string | undefined; + do { + const response = await this.port.dispatch( + { method: "tools/list", params: cursor === undefined ? {} : { cursor } }, + { signal: controller.signal }, + ); + if (response.kind === "error") throw new JsonRpcResponseError(response.error); + if (response.result === null || Array.isArray(response.result) || typeof response.result !== "object") { + throw new Error("tools/list result must be an object"); + } + const result = response.result as Readonly>; + const listed = result.tools; + if (!Array.isArray(listed)) throw new Error("tools/list result must contain tools"); + for (const value of listed) { + const parsed = this.port.taskCapabilities.generation === "v1" + ? ToolV1Codec.parse(value) + : this.port.taskCapabilities.generation === "v2" + ? ToolV2Codec.parse(value) + : (() => { + const v2 = ToolV2Codec.parse(value); + return v2.success ? v2 : ToolV1Codec.parse(value); + })(); + if (!parsed.success) throw parsed.error; + if (decoded.has(parsed.value.name)) { + this.reportError(new Error(`Duplicate tool declaration: ${parsed.value.name}`)); + } + decoded.set(parsed.value.name, parsed.value); + } + cursor = typeof result.nextCursor === "string" ? result.nextCursor : undefined; + } while (cursor !== undefined); + if (sequence === this.refreshSequence) this.tools = decoded; + } +} + + +function requestParams(request: Readonly>): Readonly> { + if (request.params === undefined) return {}; + if (request.params === null || Array.isArray(request.params) || typeof request.params !== "object") { + throw new Error("Input request params must be an object"); + } + return request.params as Readonly>; +} + +interface OrdinaryInputCandidate { + readonly generation: TaskGeneration; + readonly toolName: string; + readonly executionId: string; + readonly applicationContext: TApplicationContext; + readonly signal?: AbortSignal; +} + +let nextExecutionId = 0; + + +function defaultServerRequestResponse(incoming: IncomingServerRequest): JsonRpcResponse { + if (incoming.request !== null && !Array.isArray(incoming.request) && typeof incoming.request === "object") { + const request = incoming.request as Readonly>; + if (request.method === "elicitation/create") { + return { kind: "result", result: { action: "cancel" } }; + } + } + return { kind: "error", error: { code: -32603, message: "Internal error" } }; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted !== true) return; + throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); +} + + +class PortTaskEnabledSession implements TaskEnabledSession { + private closed = false; + private invalidationError: Error | undefined; + private readonly disposeListeners: readonly (() => void)[]; + private readonly declarations: ToolDeclarationProvider; + private readonly managedDeclarations: ManagedToolDeclarations | undefined; + private readonly ordinaryInputCandidates = new Map>(); + + constructor( + private readonly port: ConnectedMcpSessionPort, + private readonly options: WithTasksOptions, + ) { + const reportError = (error: Error): void => { + try { + this.options.onError?.(error); + } catch (sinkError) { + console.error(sinkError); + } + }; + this.managedDeclarations = options.tools === undefined + ? new ManagedToolDeclarations(port, reportError) + : undefined; + this.declarations = options.tools ?? this.managedDeclarations!; + const onSessionAbort = (): void => { + if (this.invalidationError === undefined) { + this.invalidationError = options.signal?.reason instanceof Error + ? options.signal.reason + : new DOMException("The session was aborted", "AbortError"); + } + this.managedDeclarations?.close(); + }; + options.signal?.addEventListener("abort", onSessionAbort, { once: true }); + this.disposeListeners = [ + port.onServerRequest(async (incoming) => this.handleServerRequest(incoming)), + port.onNotification((notification) => this.managedDeclarations?.onNotification(notification)), + port.onInvalidated((reason) => { + if (this.invalidationError === undefined) this.invalidationError = reasonAsError(reason); + this.managedDeclarations?.close(); + }), + () => options.signal?.removeEventListener("abort", onSessionAbort), + () => this.managedDeclarations?.close(), + ]; + if (options.signal?.aborted === true) onSessionAbort(); + if (port.invalidated) { + this.invalidationError = new Error("MCP session was invalidated"); + this.managedDeclarations?.close(); + } + } + + async callTool( + name: string, + params?: Readonly>, + options: { + readonly resultCodec?: RuntimeCodec; + readonly applicationContext?: TApplicationContext; + readonly signal?: AbortSignal; + readonly preferTask?: boolean; + } = {}, + ): Promise> { + this.assertUsable(); + throwIfAborted(options.signal); + await this.managedDeclarations?.ensureReady(options.signal); + this.assertUsable(); + const declaration = this.declarations.currentTool(name); + if (this.port.taskCapabilities.generation === "v2" && declaration !== undefined && "execution" in declaration) { + throw new Error("Tool declaration generation does not match the V2 session"); + } + const requestParams: Record = { name }; + if (params !== undefined) requestParams.arguments = params; + if ( + this.port.taskCapabilities.generation === "v1" + && declaration !== undefined + && "execution" in declaration + && shouldCallToolAsTaskV1(this.port.taskCapabilities.capabilities, declaration as ToolV1, options.preferTask) + ) { + throw unsupported("Task execution"); + } + const executionId = `execution-${++nextExecutionId}`; + this.ordinaryInputCandidates.set(executionId, { + generation: this.port.taskCapabilities.generation === "none" ? "v1" : this.port.taskCapabilities.generation, + toolName: name, + executionId, + applicationContext: options.applicationContext as TApplicationContext, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); + let response: JsonRpcResponse; + try { + response = await this.port.dispatch( + { method: "tools/call", params: requestParams }, + options.signal === undefined ? undefined : { signal: options.signal }, + ); + } finally { + this.ordinaryInputCandidates.delete(executionId); + } + this.assertUsable(); + throwIfAborted(options.signal); + if (response.kind === "error") throw new JsonRpcResponseError(response.error); + if (isTaskResultForGeneration(this.port.taskCapabilities.generation, response.result)) { + throw unsupported("Task-result execution"); + } + + const codec = options.resultCodec + ?? defaultResultCodec(this.port.taskCapabilities.generation) as RuntimeCodec; + const decoded = codec.parse(response.result); + if (!decoded.success) throw decoded.error; + const resultPromise = Promise.resolve(decoded.value); + return new ImmediateExecution( + options.applicationContext as TApplicationContext, + resultPromise, + ); + } + + async resumeTask( + _reference: SerializedTaskReference, + _options?: { + readonly resultCodec?: RuntimeCodec; + readonly applicationContext?: TApplicationContext; + readonly signal?: AbortSignal; + }, + ): Promise> { + this.assertUsable(); + throw unsupported("resumeTask"); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + for (const dispose of this.disposeListeners) dispose(); + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } + + private async handleServerRequest(incoming: IncomingServerRequest): Promise { + if (this.options.onInputRequest === undefined) return defaultServerRequestResponse(incoming); + if (incoming.request === null || Array.isArray(incoming.request) || typeof incoming.request !== "object") { + return defaultServerRequestResponse(incoming); + } + const wire = incoming.request as Readonly>; + const method = wire.method; + const request: ApplicationInputRequest | undefined = method === "elicitation/create" + ? { kind: "elicitation", params: requestParams(wire) } + : method === "sampling/createMessage" + ? { kind: "sampling", params: requestParams(wire) } + : method === "roots/list" + ? { kind: "roots", ...(wire.params === undefined ? {} : { params: requestParams(wire) }) } + : undefined; + if (request === undefined) return defaultServerRequestResponse(incoming); + if (this.ordinaryInputCandidates.size !== 1) { + const candidates = [...this.ordinaryInputCandidates.values()].map((candidate) => ({ + generation: candidate.generation, + toolName: candidate.toolName, + executionId: candidate.executionId, + applicationContext: candidate.applicationContext, + })); + this.reportBackgroundError(new InputCorrelationError( + this.port.taskCapabilities.generation === "none" ? "v1" : this.port.taskCapabilities.generation, + request.kind, + candidates, + candidates.length === 0 ? "zero-matches" : "ambiguous-matches", + )); + return defaultServerRequestResponse(incoming); + } + const candidate = this.ordinaryInputCandidates.values().next().value as OrdinaryInputCandidate; + try { + const result = await this.options.onInputRequest(request, { + lifetime: "basic", + executionId: candidate.executionId, + applicationContext: candidate.applicationContext, + ...(candidate.signal === undefined ? {} : { signal: candidate.signal }), + }); + if (!isJsonValue(result)) throw new Error("Input handler returned a non-JSON value"); + return { kind: "result", result }; + } catch { + return defaultServerRequestResponse(incoming); + } + } + + + private reportBackgroundError(error: Error): void { + try { + if (this.options.onError === undefined) console.error(error); + else this.options.onError(error); + } catch (sinkError) { + console.error(sinkError); + } + } + + + private assertUsable(): void { + if (this.invalidationError !== undefined) throw this.invalidationError; + if (this.closed) throw new Error("Task-enabled session is closed"); + } +} + +function isTaskResultForGeneration( + generation: SessionTaskCapabilities["generation"], + value: JsonValue, +): boolean { + if (generation === "v1") return CreateTaskResultV1Codec.parse(value).success; + if (generation === "v2") return isCreateTaskResultV2(value); + return false; +} + +export function withTasks( + session: ConnectedMcpSessionPort, + options: WithTasksOptions = {}, +): TaskEnabledSession { + return new PortTaskEnabledSession(session, options); +} diff --git a/packages/ext-tasks/src/core/v1/index.ts b/packages/ext-tasks/src/core/v1/index.ts index 11c7cb7..511772d 100644 --- a/packages/ext-tasks/src/core/v1/index.ts +++ b/packages/ext-tasks/src/core/v1/index.ts @@ -225,8 +225,11 @@ export const CreateTaskResultV1Codec: RuntimeCodec = createR export const CallToolResultV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const record = expectRecord(value, path); if (!Array.isArray(record.content)) throw new ProtocolDecodeError("expected array", at(path, "content")); - const content = record.content.map((item, index) => decodeContentBlock(item, at(at(path, "content"), index))); - return { content, ...(record.structuredContent === undefined ? {} : { structuredContent: jsonRecord(record.structuredContent, at(path, "structuredContent")) }), ...(record.isError === undefined ? {} : { isError: optionalBoolean(record, "isError", path) }), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(path, "_meta")) }) }; + record.content.forEach((item, index) => decodeContentBlock(item, at(at(path, "content"), index))); + if (record.structuredContent !== undefined) jsonRecord(record.structuredContent, at(path, "structuredContent")); + if (record.isError !== undefined) optionalBoolean(record, "isError", path); + if (record._meta !== undefined) jsonRecord(record._meta, at(path, "_meta")); + return record as unknown as CallToolResultV1; }); export const GetTaskRequestV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => decodeTaskRequest(v, p, "tasks/get")); export const GetTaskResultV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => { const record = expectRecord(v, p); return { ...decodeTask(v, p), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(p, "_meta")) }) }; }); diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts index d5b4f3b..bdcba22 100644 --- a/packages/ext-tasks/src/core/v2/index.test.ts +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -4,10 +4,11 @@ import { describe, expect, it } from "vitest"; import type { JsonValue } from "../index.js"; import { - CancelTaskRequestV2Codec, CancelTaskResultV2Codec, CreateTaskResultV2Codec, - DetailedTaskV2Codec, ErrorV2Codec, GetTaskRequestV2Codec, GetTaskResultV2Codec, - InputRequestsV2Codec, InputResponsesV2Codec, TaskStatusNotificationV2Codec, - TaskV2Codec, UpdateTaskRequestV2Codec, UpdateTaskResultV2Codec, + CallToolResultV2Codec, CancelTaskRequestV2Codec, CancelTaskResultV2Codec, + CreateTaskResultV2Codec, DetailedTaskV2Codec, ErrorV2Codec, GetTaskRequestV2Codec, + GetTaskResultV2Codec, InputRequestsV2Codec, InputResponsesV2Codec, + TaskStatusNotificationV2Codec, TaskV2Codec, ToolV2Codec, UpdateTaskRequestV2Codec, + UpdateTaskResultV2Codec, contributeTaskFilterV2, hasTaskClientCapabilityV2, hasTaskServerCapabilityV2, isEligibleTaskResultV2, readAcceptedTaskIdsV2, withTaskCapabilityV2, type TaskStatusV2, @@ -81,6 +82,61 @@ describe("V2 generated wire contracts", () => { expect(ErrorV2Codec.parse({ code: 1.5, message: "bad" }).success).toBe(false); }); + it("round-trips open ToolV2 objects while validating every declared field", () => { + fc.assert(fc.property( + fc.string(), + fc.dictionary(fc.string(), fc.jsonValue()), + fc.dictionary(fc.string(), fc.jsonValue()), + fc.dictionary(fc.string(), fc.jsonValue()), + (name, rootExtra, inputExtra, outputExtra) => { + const tool = asJson({ + ...rootExtra, + name, + title: "Display name", + description: "Description", + inputSchema: { ...inputExtra, type: "object", $schema: "https://json-schema.org/draft/2020-12/schema" }, + outputSchema: { ...outputExtra, $schema: "https://json-schema.org/draft/2020-12/schema" }, + annotations: { title: "Annotated", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, extension: 1 }, + icons: [{ src: "https://example.test/icon.png", mimeType: "image/png", sizes: ["16x16", "32x32"], theme: "dark", extension: true }], + _meta: { trace: "test" }, + }); + const parsed = ToolV2Codec.parse(tool); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.value).toEqual(tool); + }, + )); + expect(ToolV2Codec.parse({ name: "x", inputSchema: {} }).success).toBe(false); + expect(ToolV2Codec.parse({ name: "x", inputSchema: { type: "array" } }).success).toBe(false); + for (const [field, invalid] of [["outputSchema", true], ["annotations", true], ["icons", true], ["_meta", true]] as const) { + expect(ToolV2Codec.parse({ name: "x", inputSchema: { type: "object" }, [field]: invalid }).success).toBe(false); + } + expect(ToolV2Codec.parse({ name: "x", inputSchema: { type: "object" }, annotations: { readOnlyHint: "yes" } }).success).toBe(false); + expect(ToolV2Codec.parse({ name: "x", inputSchema: { type: "object" }, icons: [{}] }).success).toBe(false); + }); + + it("round-trips open CallToolResultV2 objects with required string result/content discriminators", () => { + const content = [ + { type: "text", text: "hello", annotations: { audience: ["user"], priority: 0.5, lastModified: "now" }, _meta: { a: 1 }, extension: true }, + { type: "image", data: "aW1hZ2U=", mimeType: "image/png", extension: 1 }, + { type: "audio", data: "YXVkaW8=", mimeType: "audio/wav", extension: 2 }, + { type: "resource_link", name: "docs", uri: "https://example.test", title: "Docs", description: "d", mimeType: "text/html", size: 1, icons: [{ src: "icon.png" }], extension: 3 }, + { type: "resource", resource: { uri: "file:///x", text: "body", blob: "Ym9keQ==", mimeType: "text/plain", _meta: { r: 1 }, extension: 4 } }, + ]; + fc.assert(fc.property(fc.string(), fc.jsonValue(), fc.dictionary(fc.string(), fc.jsonValue()), (resultType, structuredContent, extra) => { + const result = asJson({ ...extra, resultType, content, structuredContent, isError: false, _meta: { trace: "test" } }); + const parsed = CallToolResultV2Codec.parse(result); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.value).toEqual(result); + })); + expect(CallToolResultV2Codec.parse({ resultType: "complete" }).success).toBe(false); + expect(CallToolResultV2Codec.parse({ resultType: 1, content: [] }).success).toBe(false); + expect(CallToolResultV2Codec.parse({ resultType: "complete", content: [{ type: "text" }] }).success).toBe(false); + fc.assert(fc.property(fc.string().filter((type) => !["text", "image", "audio", "resource_link", "resource"].includes(type)), (type) => { + expect(CallToolResultV2Codec.parse({ resultType: "complete", content: [{ type }] }).success).toBe(false); + })); + expect(CallToolResultV2Codec.parse({ resultType: "complete", content: [], isError: "no" }).success).toBe(false); + }); + it("binds strict get, update, and cancel request/result discriminators", () => { fc.assert(fc.property(fc.oneof(fc.string(), fc.integer()), fc.string(), (id, taskId) => { expect(GetTaskRequestV2Codec.parse({ jsonrpc: "2.0", id, method: "tasks/get", params: { taskId } }).success).toBe(true); diff --git a/packages/ext-tasks/src/core/v2/index.ts b/packages/ext-tasks/src/core/v2/index.ts index a484ebc..04d8bc1 100644 --- a/packages/ext-tasks/src/core/v2/index.ts +++ b/packages/ext-tasks/src/core/v2/index.ts @@ -15,6 +15,38 @@ export const TASKS_EXTENSION_ID_V2 = "io.modelcontextprotocol/tasks" as const; export const CLIENT_CAPABILITIES_META_KEY_V2 = "io.modelcontextprotocol/clientCapabilities" as const; + +type OpenObjectV2 = Readonly>; +type ToolAnnotationsV2 = OpenObjectV2 & { + readonly title?: string; + readonly readOnlyHint?: boolean; + readonly destructiveHint?: boolean; + readonly idempotentHint?: boolean; + readonly openWorldHint?: boolean; +}; +type IconV2 = OpenObjectV2 & { + readonly src: string; + readonly mimeType?: string; + readonly sizes?: readonly string[]; + readonly theme?: "light" | "dark"; +}; +type ContentBlockV2 = OpenObjectV2 & ( + | { readonly type: "text"; readonly text: string } + | { readonly type: "image" | "audio"; readonly data: string; readonly mimeType: string } + | { readonly type: "resource_link"; readonly name: string; readonly uri: string } + | { readonly type: "resource"; readonly resource: OpenObjectV2 } +); + +export type ToolV2 = OpenObjectV2 & { + readonly name: string; + readonly title?: string; + readonly description?: string; + readonly inputSchema: OpenObjectV2 & { readonly type: "object"; readonly $schema?: string }; + readonly outputSchema?: OpenObjectV2 & { readonly $schema?: string }; + readonly annotations?: ToolAnnotationsV2; + readonly icons?: readonly IconV2[]; + readonly _meta?: OpenObjectV2; +}; export type RequestIdV2 = string | number; export type TaskStatusV2 = | "working" @@ -23,6 +55,8 @@ export type TaskStatusV2 = | "failed" | "cancelled"; +export type TaskEligibleMethodV2 = "tools/call"; + export interface TaskV2 { readonly taskId: string; readonly status: TaskStatusV2; @@ -89,7 +123,15 @@ export interface CreateTaskResultV2 extends TaskV2 { readonly resultType: "task"; readonly _meta?: Readonly>; } -export type ToolCallResultV2 = Readonly> & { readonly resultType: string }; +export type CallToolResultV2 = OpenObjectV2 & { + readonly resultType: string; + readonly content: readonly ContentBlockV2[]; + readonly structuredContent?: JsonValue; + readonly isError?: boolean; + readonly _meta?: OpenObjectV2; +}; +/** @deprecated Use CallToolResultV2. */ +export type ToolCallResultV2 = CallToolResultV2; export type EligibleTaskResultV2 = ToolCallResultV2 | CreateTaskResultV2; interface JsonRpcRequestV2 { @@ -160,11 +202,130 @@ function expectConst(value: JsonValue | undefined, expected: string, path: Decod function optionalRecord(value: JsonValue | undefined, path: DecodePath) { return value === undefined ? undefined : expectRecord(value, path); } +function optionalString(object: Record, key: string, path: DecodePath): void { + if (object[key] !== undefined) expectString(object[key], [...path, key]); +} +function optionalBoolean(object: Record, key: string, path: DecodePath): void { + if (object[key] !== undefined && typeof object[key] !== "boolean") throw new ProtocolDecodeError("expected boolean", [...path, key]); +} +function optionalStringArray(object: Record, key: string, path: DecodePath): void { + const value = object[key]; + if (value !== undefined && (!Array.isArray(value) || !value.every((item) => typeof item === "string"))) { + throw new ProtocolDecodeError("expected string array", [...path, key]); + } +} + +function decodeAnnotations(value: JsonValue, path: DecodePath): void { + const object = expectRecord(value, path); + if (object.audience !== undefined) { + if (!Array.isArray(object.audience) || !object.audience.every((role) => role === "user" || role === "assistant")) { + throw new ProtocolDecodeError("expected role array", [...path, "audience"]); + } + } + if (object.priority !== undefined) { + const priority = expectNumber(object.priority, [...path, "priority"]); + if (priority < 0 || priority > 1) throw new ProtocolDecodeError("expected number from 0 to 1", [...path, "priority"]); + } + optionalString(object, "lastModified", path); +} + +function decodeIcon(value: JsonValue, path: DecodePath): void { + const object = expectRecord(value, path); + expectString(object.src, [...path, "src"]); + optionalString(object, "mimeType", path); + optionalStringArray(object, "sizes", path); + if (object.theme !== undefined) expectEnum(object.theme, ["light", "dark"], [...path, "theme"]); +} + +function decodeImplementation(value: JsonValue, path: DecodePath): void { + const object = expectRecord(value, path); + expectString(object.name, [...path, "name"]); + expectString(object.version, [...path, "version"]); + optionalString(object, "title", path); + optionalString(object, "description", path); + optionalString(object, "websiteUrl", path); + if (object.icons !== undefined) { + if (!Array.isArray(object.icons)) throw new ProtocolDecodeError("expected array", [...path, "icons"]); + object.icons.forEach((icon, index) => decodeIcon(icon, [...path, "icons", index])); + } +} + +function decodeContentBlock(value: JsonValue, path: DecodePath): ContentBlockV2 { + const object = expectRecord(value, path); + const type = expectEnum(object.type, ["text", "image", "audio", "resource_link", "resource"], [...path, "type"]); + if (type === "text") expectString(object.text, [...path, "text"]); + else if (type === "image" || type === "audio") { + expectString(object.data, [...path, "data"]); + expectString(object.mimeType, [...path, "mimeType"]); + } else if (type === "resource_link") { + expectString(object.name, [...path, "name"]); + expectString(object.uri, [...path, "uri"]); + optionalString(object, "title", path); + optionalString(object, "description", path); + optionalString(object, "mimeType", path); + if (object.size !== undefined) expectInteger(object.size, [...path, "size"]); + if (object.icons !== undefined) { + if (!Array.isArray(object.icons)) throw new ProtocolDecodeError("expected array", [...path, "icons"]); + object.icons.forEach((icon, index) => decodeIcon(icon, [...path, "icons", index])); + } + } else { + const resource = expectRecord(object.resource, [...path, "resource"]); + expectString(resource.uri, [...path, "resource", "uri"]); + optionalString(resource, "mimeType", [...path, "resource"]); + optionalRecord(resource._meta, [...path, "resource", "_meta"]); + const hasText = resource.text !== undefined; + const hasBlob = resource.blob !== undefined; + if (!hasText && !hasBlob) throw new ProtocolDecodeError("expected text or blob", [...path, "resource"]); + if (hasText) expectString(resource.text, [...path, "resource", "text"]); + if (hasBlob) expectString(resource.blob, [...path, "resource", "blob"]); + } + if (object.annotations !== undefined) decodeAnnotations(object.annotations, [...path, "annotations"]); + optionalRecord(object._meta, [...path, "_meta"]); + return object as ContentBlockV2; +} + +function decodeTool(value: JsonValue, path: DecodePath): ToolV2 { + const object = expectRecord(value, path); + expectString(object.name, [...path, "name"]); + optionalString(object, "title", path); + optionalString(object, "description", path); + const inputSchema = expectRecord(object.inputSchema, [...path, "inputSchema"]); + expectConst(inputSchema.type, "object", [...path, "inputSchema", "type"]); + optionalString(inputSchema, "$schema", [...path, "inputSchema"]); + if (object.outputSchema !== undefined) { + const outputSchema = expectRecord(object.outputSchema, [...path, "outputSchema"]); + optionalString(outputSchema, "$schema", [...path, "outputSchema"]); + } + if (object.annotations !== undefined) { + const annotations = expectRecord(object.annotations, [...path, "annotations"]); + optionalString(annotations, "title", [...path, "annotations"]); + for (const key of ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"]) optionalBoolean(annotations, key, [...path, "annotations"]); + } + if (object.icons !== undefined) { + if (!Array.isArray(object.icons)) throw new ProtocolDecodeError("expected array", [...path, "icons"]); + object.icons.forEach((icon, index) => decodeIcon(icon, [...path, "icons", index])); + } + optionalRecord(object._meta, [...path, "_meta"]); + return object as ToolV2; +} + +function decodeCallToolResult(value: JsonValue, path: DecodePath): CallToolResultV2 { + const object = expectRecord(value, path); + expectString(object.resultType, [...path, "resultType"]); + if (!Array.isArray(object.content)) throw new ProtocolDecodeError("expected array", [...path, "content"]); + object.content.forEach((block, index) => decodeContentBlock(block, [...path, "content", index])); + optionalBoolean(object, "isError", path); + const meta = optionalRecord(object._meta, [...path, "_meta"]); + if (meta?.["io.modelcontextprotocol/serverInfo"] !== undefined) { + decodeImplementation(meta["io.modelcontextprotocol/serverInfo"], [...path, "_meta", "io.modelcontextprotocol/serverInfo"]); + } + return object as CallToolResultV2; +} function decodeTask(value: JsonValue, path: DecodePath): TaskV2 { const object = expectRecord(value, path); const ttl = object.ttlMs; - if (ttl !== null && ttl === undefined) throw new ProtocolDecodeError("required field", [...path, "ttlMs"]); + if (!has(object, "ttlMs")) throw new ProtocolDecodeError("required field", [...path, "ttlMs"]); const task: TaskV2 = { taskId: expectString(object.taskId, [...path, "taskId"]), status: expectEnum(object.status, statuses, [...path, "status"]), @@ -242,6 +403,10 @@ function decodeCompleteResult(value: JsonValue, path: DecodePath) { return object; } +export const ToolV2Codec: RuntimeCodec = createRuntimeCodec(decodeTool); +export const CallToolResultV2Codec: RuntimeCodec = createRuntimeCodec(decodeCallToolResult); +/** @deprecated Use CallToolResultV2Codec. */ +export const ToolCallResultV2Codec: RuntimeCodec = CallToolResultV2Codec; export const TaskV2Codec: RuntimeCodec = createRuntimeCodec(decodeTask); export const DetailedTaskV2Codec: RuntimeCodec = createRuntimeCodec(decodeDetailedTask); export const ErrorV2Codec: RuntimeCodec = createRuntimeCodec(decodeError); From 2e60c063d81284135c7d5ebafdbeb8e9082b378b Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 4 Sep 2026 17:28:33 -0700 Subject: [PATCH 04/33] chore: format ext-tasks with Prettier - add package-level format and format-check scripts - enforce formatting in the package check workflow - preserve immutable schema artifacts through ignore rules --- package-lock.json | 19 +- packages/ext-tasks/.prettierignore | 4 + packages/ext-tasks/package.json | 7 +- .../scripts/check-schema-provenance.mjs | 4 +- packages/ext-tasks/src/client/index.test.ts | 480 +++++++--- packages/ext-tasks/src/client/index.ts | 376 +++++--- packages/ext-tasks/src/core/index.test.ts | 17 +- packages/ext-tasks/src/core/index.ts | 48 +- packages/ext-tasks/src/core/v1/index.test.ts | 418 +++++++-- packages/ext-tasks/src/core/v1/index.ts | 608 ++++++++++--- packages/ext-tasks/src/core/v2/index.test.ts | 664 +++++++++++--- packages/ext-tasks/src/core/v2/index.ts | 851 +++++++++++++----- packages/ext-tasks/tsconfig.json | 8 +- packages/ext-tasks/tsconfig.test.json | 4 +- 14 files changed, 2681 insertions(+), 827 deletions(-) create mode 100644 packages/ext-tasks/.prettierignore diff --git a/package-lock.json b/package-lock.json index f304878..ebc075c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4996,6 +4996,22 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -6884,7 +6900,8 @@ "license": "Apache-2.0", "devDependencies": { "@modelcontextprotocol/client": "^2.0.0", - "fast-check": "^4.9.0" + "fast-check": "^4.9.0", + "prettier": "^3.9.6" }, "peerDependencies": { "@modelcontextprotocol/client": "^2.0.0" diff --git a/packages/ext-tasks/.prettierignore b/packages/ext-tasks/.prettierignore new file mode 100644 index 0000000..59541ba --- /dev/null +++ b/packages/ext-tasks/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +.cache +schema \ No newline at end of file diff --git a/packages/ext-tasks/package.json b/packages/ext-tasks/package.json index 14e96db..a7bcba1 100644 --- a/packages/ext-tasks/package.json +++ b/packages/ext-tasks/package.json @@ -77,13 +77,16 @@ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit", "test": "vitest run", "test:watch": "vitest", + "format": "prettier --write .", + "format:check": "prettier --check .", "check:exports": "node scripts/check-exports.mjs", "check:provenance": "node scripts/check-schema-provenance.mjs", - "check": "npm run check:provenance && npm run typecheck && npm run test && npm run build && npm run check:exports", + "check": "npm run check:provenance && npm run format:check && npm run typecheck && npm run test && npm run build && npm run check:exports", "prepack": "npm run check" }, "devDependencies": { "@modelcontextprotocol/client": "^2.0.0", - "fast-check": "^4.9.0" + "fast-check": "^4.9.0", + "prettier": "^3.9.6" } } diff --git a/packages/ext-tasks/scripts/check-schema-provenance.mjs b/packages/ext-tasks/scripts/check-schema-provenance.mjs index a5d6302..5fbf62d 100644 --- a/packages/ext-tasks/scripts/check-schema-provenance.mjs +++ b/packages/ext-tasks/scripts/check-schema-provenance.mjs @@ -17,7 +17,9 @@ for (const [path, expected] of artifacts) { const bytes = await readFile(new URL(path, import.meta.url)); const actual = gitBlobId(bytes); if (actual !== expected) { - throw new Error(`${path} provenance mismatch: expected ${expected}, received ${actual}`); + throw new Error( + `${path} provenance mismatch: expected ${expected}, received ${actual}`, + ); } } diff --git a/packages/ext-tasks/src/client/index.test.ts b/packages/ext-tasks/src/client/index.test.ts index 53f06f7..be89da8 100644 --- a/packages/ext-tasks/src/client/index.test.ts +++ b/packages/ext-tasks/src/client/index.test.ts @@ -1,7 +1,11 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { createRuntimeCodec, expectRecord, type JsonValue } from "../core/index.js"; +import { + createRuntimeCodec, + expectRecord, + type JsonValue, +} from "../core/index.js"; import type { ServerTaskCapabilitiesV1, ToolV1 } from "../core/v1/index.js"; import { @@ -15,41 +19,65 @@ import { type SessionTaskCapabilities, } from "./index.js"; -const asJson = (value: unknown): JsonValue => JSON.parse(JSON.stringify(value)) as JsonValue; +const asJson = (value: unknown): JsonValue => + JSON.parse(JSON.stringify(value)) as JsonValue; class FakePort implements ConnectedMcpSessionPort { readonly requests: JsonValue[] = []; readonly taskCapabilities: SessionTaskCapabilities; invalidated = false; response: JsonRpcResponse = { kind: "result", result: { content: [] } }; - dispatchHandler?: (request: JsonValue, options?: { readonly signal?: AbortSignal }) => Promise; - private requestHandler?: (incoming: IncomingServerRequest) => Promise; + dispatchHandler?: ( + request: JsonValue, + options?: { readonly signal?: AbortSignal }, + ) => Promise; + private requestHandler?: ( + incoming: IncomingServerRequest, + ) => Promise; private notificationListener?: (notification: JsonValue) => void; private invalidationListener?: (reason: unknown) => void; listenerDisposals = 0; - constructor(taskCapabilities: SessionTaskCapabilities = { generation: "none" }) { + constructor( + taskCapabilities: SessionTaskCapabilities = { generation: "none" }, + ) { this.taskCapabilities = taskCapabilities; } - async dispatch(request: JsonValue, options?: { readonly signal?: AbortSignal }): Promise { + async dispatch( + request: JsonValue, + options?: { readonly signal?: AbortSignal }, + ): Promise { this.requests.push(request); - return this.dispatchHandler === undefined ? this.response : this.dispatchHandler(request, options); + return this.dispatchHandler === undefined + ? this.response + : this.dispatchHandler(request, options); } - onServerRequest(handler: (incoming: IncomingServerRequest) => Promise): () => void { + onServerRequest( + handler: (incoming: IncomingServerRequest) => Promise, + ): () => void { this.requestHandler = handler; - return () => { this.requestHandler = undefined; this.listenerDisposals += 1; }; + return () => { + this.requestHandler = undefined; + this.listenerDisposals += 1; + }; } onNotification(listener: (notification: JsonValue) => void): () => void { this.notificationListener = listener; - return () => { this.notificationListener = undefined; this.listenerDisposals += 1; }; + return () => { + this.notificationListener = undefined; + this.listenerDisposals += 1; + }; } onInvalidated(listener: (reason: unknown) => void): () => void { this.invalidationListener = listener; - return () => { this.invalidationListener = undefined; this.listenerDisposals += 1; }; + return () => { + this.invalidationListener = undefined; + this.listenerDisposals += 1; + }; } invalidate(reason: unknown): void { @@ -58,7 +86,8 @@ class FakePort implements ConnectedMcpSessionPort { } async serve(request: JsonValue): Promise { - if (this.requestHandler === undefined) throw new Error("request handler is not installed"); + if (this.requestHandler === undefined) + throw new Error("request handler is not installed"); return this.requestHandler({ request, requestContext: {} }); } @@ -69,30 +98,41 @@ class FakePort implements ConnectedMcpSessionPort { describe("client tool executions", () => { it("dispatches a non-task call and caches the decoded result", async () => { - await fc.assert(fc.asyncProperty( - fc.string(), - fc.dictionary(fc.string(), fc.jsonValue()), - async (name, args) => { - const port = new FakePort(); - const result = { content: [{ type: "text", text: name }] }; - port.response = { kind: "result", result: asJson(result) }; - const session = withTasks(port, { tools: { currentTool: () => undefined } }); - const normalizedArgs = asJson(args) as Readonly>; - const execution = await session.callTool(name, normalizedArgs); - expect(execution.kind).toBe("immediate"); - expect(port.requests).toEqual([{ method: "tools/call", params: { name, arguments: normalizedArgs } }]); - const first = execution.result(); - const second = execution.result(); - expect(first).toBe(second); - await expect(first).resolves.toEqual(result); - const updates: unknown[] = []; - for await (const update of execution.updates()) updates.push(update); - expect(updates).toEqual([]); - await execution.cancel(); - expect(port.requests).toHaveLength(1); - await session.close(); - }, - )); + await fc.assert( + fc.asyncProperty( + fc.string(), + fc.dictionary(fc.string(), fc.jsonValue()), + async (name, args) => { + const port = new FakePort(); + const result = { content: [{ type: "text", text: name }] }; + port.response = { kind: "result", result: asJson(result) }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const normalizedArgs = asJson(args) as Readonly< + Record + >; + const execution = await session.callTool(name, normalizedArgs); + expect(execution.kind).toBe("immediate"); + expect(port.requests).toEqual([ + { + method: "tools/call", + params: { name, arguments: normalizedArgs }, + }, + ]); + const first = execution.result(); + const second = execution.result(); + expect(first).toBe(second); + await expect(first).resolves.toEqual(result); + const updates: unknown[] = []; + for await (const update of execution.updates()) updates.push(update); + expect(updates).toEqual([]); + await execution.cancel(); + expect(port.requests).toHaveLength(1); + await session.close(); + }, + ), + ); }); it("uses an application result codec at the dispatch boundary", async () => { @@ -103,8 +143,13 @@ describe("client tool executions", () => { if (typeof record.answer !== "number") throw new Error("answer required"); return record.answer; }); - const session = withTasks(port, { tools: { currentTool: () => undefined } }); - const execution = await session.callTool("answer", undefined, { resultCodec: codec, applicationContext: "ctx" }); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("answer", undefined, { + resultCodec: codec, + applicationContext: "ctx", + }); expect(execution.applicationContext).toBe("ctx"); await expect(execution.result()).resolves.toBe(42); await session.close(); @@ -112,8 +157,13 @@ describe("client tool executions", () => { it("preserves complete JSON-RPC errors and dispatch failures", async () => { const port = new FakePort(); - port.response = { kind: "error", error: { code: -32001, message: "denied", data: { retry: false } } }; - const session = withTasks(port, { tools: { currentTool: () => undefined } }); + port.response = { + kind: "error", + error: { code: -32001, message: "denied", data: { retry: false } }, + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); await expect(session.callTool("denied")).rejects.toMatchObject({ name: "JsonRpcResponseError", code: -32001, @@ -122,13 +172,17 @@ describe("client tool executions", () => { }); const error = new DispatchError("offline", true); expect(error.retryable).toBe(true); - expect(new JsonRpcResponseError({ code: 1, message: "x" })).toBeInstanceOf(Error); + expect(new JsonRpcResponseError({ code: 1, message: "x" })).toBeInstanceOf( + Error, + ); await session.close(); }); it("closes executions and sessions idempotently without closing the borrowed port", async () => { const port = new FakePort(); - const session = withTasks(port, { tools: { currentTool: () => undefined } }); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); const execution = await session.callTool("x"); await execution.close(); await execution.close(); @@ -140,7 +194,9 @@ describe("client tool executions", () => { it("rejects new and pending work after session invalidation", async () => { const port = new FakePort(); - const session = withTasks(port, { tools: { currentTool: () => undefined } }); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); port.invalidate(new Error("replaced")); await expect(session.callTool("x")).rejects.toThrow("replaced"); await session.close(); @@ -148,9 +204,14 @@ describe("client tool executions", () => { it("aborts pending discovery when the port is invalidated", async () => { const port = new FakePort(); - port.dispatchHandler = (_request, options) => new Promise((_resolve, reject) => { - options?.signal?.addEventListener("abort", () => reject(new DOMException("invalidated", "AbortError")), { once: true }); - }); + port.dispatchHandler = (_request, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(new DOMException("invalidated", "AbortError")), + { once: true }, + ); + }); const session = withTasks(port); const pending = session.callTool("x"); port.invalidate(new Error("connection replaced")); @@ -159,11 +220,14 @@ describe("client tool executions", () => { await session.close(); }); - it("settles default V1 input declines with method-specific protocol values", async () => { const port = new FakePort({ generation: "v1", capabilities: {} }); - const session = withTasks(port, { tools: { currentTool: () => undefined } }); - await expect(port.serve({ method: "elicitation/create", params: {} })).resolves.toEqual({ + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + await expect( + port.serve({ method: "elicitation/create", params: {} }), + ).resolves.toEqual({ kind: "result", result: { action: "cancel" }, }); @@ -176,18 +240,29 @@ describe("client tool executions", () => { await session.close(); }); - it("routes ordinary input requests with the execution context", async () => { const cases = [ - { method: "elicitation/create", result: { action: "accept", content: { value: "ok" } } }, - { method: "sampling/createMessage", result: { model: "m", role: "assistant", content: { type: "text", text: "ok" } } }, + { + method: "elicitation/create", + result: { action: "accept", content: { value: "ok" } }, + }, + { + method: "sampling/createMessage", + result: { + model: "m", + role: "assistant", + content: { type: "text", text: "ok" }, + }, + }, { method: "roots/list", result: { roots: [{ uri: "file:///tmp" }] } }, ] as const; for (const input of cases) { const port = new FakePort({ generation: "v1", capabilities: {} }); const observed: unknown[] = []; port.dispatchHandler = async () => { - observed.push(await port.serve({ method: input.method, params: { prompt: "p" } })); + observed.push( + await port.serve({ method: input.method, params: { prompt: "p" } }), + ); return { kind: "result", result: { content: [] } }; }; const session = withTasks<{ readonly marker: string }>(port, { @@ -197,12 +272,17 @@ describe("client tool executions", () => { return input.result as never; }, }); - await session.callTool("x", undefined, { applicationContext: { marker: "ctx" } }); + await session.callTool("x", undefined, { + applicationContext: { marker: "ctx" }, + }); expect(observed[0]).toMatchObject({ request: { params: { prompt: "p" } }, context: { lifetime: "basic", applicationContext: { marker: "ctx" } }, }); - expect((observed[0] as { context: { executionId: string } }).context.executionId).toMatch(/^execution-/); + expect( + (observed[0] as { context: { executionId: string } }).context + .executionId, + ).toMatch(/^execution-/); expect(observed[1]).toEqual({ kind: "result", result: input.result }); await session.close(); } @@ -212,90 +292,161 @@ describe("client tool executions", () => { const port = new FakePort({ generation: "v1", capabilities: {} }); let settlement: JsonRpcResponse | undefined; port.dispatchHandler = async () => { - settlement = await port.serve({ method: "elicitation/create", params: {} }); + settlement = await port.serve({ + method: "elicitation/create", + params: {}, + }); return { kind: "result", result: { content: [] } }; }; const session = withTasks(port, { tools: { currentTool: () => undefined }, - onInputRequest: async () => { throw new Error("declined"); }, + onInputRequest: async () => { + throw new Error("declined"); + }, }); await session.callTool("x"); - expect(settlement).toEqual({ kind: "result", result: { action: "cancel" } }); + expect(settlement).toEqual({ + kind: "result", + result: { action: "cancel" }, + }); await session.close(); }); it("reports ambiguous ordinary input correlation before declining", async () => { const port = new FakePort({ generation: "v1", capabilities: {} }); const completions: ((response: JsonRpcResponse) => void)[] = []; - port.dispatchHandler = () => new Promise((resolve) => completions.push(resolve)); + port.dispatchHandler = () => + new Promise((resolve) => completions.push(resolve)); const errors: Error[] = []; let handlerCalls = 0; const session = withTasks(port, { tools: { currentTool: () => undefined }, - onInputRequest: async () => { handlerCalls += 1; return { action: "accept" } as never; }, + onInputRequest: async () => { + handlerCalls += 1; + return { action: "accept" } as never; + }, onError: (error) => errors.push(error), }); - const first = session.callTool("first", undefined, { applicationContext: "one" }); - const second = session.callTool("second", undefined, { applicationContext: "two" }); + const first = session.callTool("first", undefined, { + applicationContext: "one", + }); + const second = session.callTool("second", undefined, { + applicationContext: "two", + }); await Promise.resolve(); - await expect(port.serve({ method: "elicitation/create", params: {} })).resolves.toEqual({ + await expect( + port.serve({ method: "elicitation/create", params: {} }), + ).resolves.toEqual({ kind: "result", result: { action: "cancel" }, }); expect(handlerCalls).toBe(0); expect(errors).toHaveLength(1); expect(errors[0]).toBeInstanceOf(InputCorrelationError); - expect(errors[0]).toMatchObject({ reason: "ambiguous-matches", requestKind: "elicitation" }); - expect((errors[0] as InputCorrelationError).candidates.map((candidate) => candidate.toolName)).toEqual(["first", "second"]); - for (const complete of completions) complete({ kind: "result", result: { content: [] } }); + expect(errors[0]).toMatchObject({ + reason: "ambiguous-matches", + requestKind: "elicitation", + }); + expect( + (errors[0] as InputCorrelationError).candidates.map( + (candidate) => candidate.toolName, + ), + ).toEqual(["first", "second"]); + for (const complete of completions) + complete({ kind: "result", result: { content: [] } }); await Promise.all([first, second]); await session.close(); }); it("honors already-aborted call and session signals before dispatch", async () => { const callPort = new FakePort(); - const callSession = withTasks(callPort, { tools: { currentTool: () => undefined } }); + const callSession = withTasks(callPort, { + tools: { currentTool: () => undefined }, + }); const callController = new AbortController(); callController.abort(new Error("call aborted")); - await expect(callSession.callTool("x", undefined, { signal: callController.signal })).rejects.toThrow("call aborted"); + await expect( + callSession.callTool("x", undefined, { signal: callController.signal }), + ).rejects.toThrow("call aborted"); expect(callPort.requests).toEqual([]); await callSession.close(); const sessionPort = new FakePort(); const sessionController = new AbortController(); - const session = withTasks(sessionPort, { tools: { currentTool: () => undefined }, signal: sessionController.signal }); + const session = withTasks(sessionPort, { + tools: { currentTool: () => undefined }, + signal: sessionController.signal, + }); sessionController.abort(new Error("session aborted")); await expect(session.callTool("x")).rejects.toThrow("session aborted"); expect(sessionPort.requests).toEqual([]); await session.close(); }); it("applies the exhaustive V1 capability-first task augmentation table", async () => { - const support = fc.option(fc.constantFrom("forbidden", "optional", "required"), { nil: undefined }); - await fc.assert(fc.asyncProperty(fc.boolean(), support, fc.boolean(), async (present, taskSupport, preferTask) => { - const capabilities: ServerTaskCapabilitiesV1 = present ? { requests: { tools: { call: {} } } } : {}; - const port = new FakePort({ generation: "v1", capabilities }); - port.response = { kind: "result", result: { content: [] } }; - let lookups = 0; - const tool: ToolV1 = { name: "x", inputSchema: { type: "object" }, execution: { taskSupport } }; - const session = withTasks(port, { tools: { currentTool: () => { lookups += 1; return tool; } } }); - const taskSelected = present && (taskSupport === "required" || (taskSupport === "optional" && preferTask)); - if (taskSelected) { - await expect(session.callTool("x", undefined, { preferTask })).rejects.toThrow("Task execution is not supported"); - expect(port.requests).toEqual([]); - } else { - await session.callTool("x", undefined, { preferTask }); - expect(port.requests).toEqual([{ method: "tools/call", params: { name: "x" } }]); - } - expect(lookups).toBe(1); - await session.close(); - })); + const support = fc.option( + fc.constantFrom("forbidden", "optional", "required"), + { nil: undefined }, + ); + await fc.assert( + fc.asyncProperty( + fc.boolean(), + support, + fc.boolean(), + async (present, taskSupport, preferTask) => { + const capabilities: ServerTaskCapabilitiesV1 = present + ? { requests: { tools: { call: {} } } } + : {}; + const port = new FakePort({ generation: "v1", capabilities }); + port.response = { kind: "result", result: { content: [] } }; + let lookups = 0; + const tool: ToolV1 = { + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport }, + }; + const session = withTasks(port, { + tools: { + currentTool: () => { + lookups += 1; + return tool; + }, + }, + }); + const taskSelected = + present && + (taskSupport === "required" || + (taskSupport === "optional" && preferTask)); + if (taskSelected) { + await expect( + session.callTool("x", undefined, { preferTask }), + ).rejects.toThrow("Task execution is not supported"); + expect(port.requests).toEqual([]); + } else { + await session.callTool("x", undefined, { preferTask }); + expect(port.requests).toEqual([ + { method: "tools/call", params: { name: "x" } }, + ]); + } + expect(lookups).toBe(1); + await session.close(); + }, + ), + ); }); it("does not mistake open ordinary result extensions for task creation", async () => { const port = new FakePort({ generation: "v1", capabilities: {} }); - port.response = { kind: "result", result: { content: [], task: "application-data" } }; - const session = withTasks(port, { tools: { currentTool: () => undefined } }); + port.response = { + kind: "result", + result: { content: [], task: "application-data" }, + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toEqual({ content: [], task: "application-data" }); + await expect(execution.result()).resolves.toEqual({ + content: [], + task: "application-data", + }); await session.close(); }); @@ -304,7 +455,12 @@ describe("client tool executions", () => { managed.dispatchHandler = async (request) => { const record = expectRecord(request); if (record.method === "tools/list") { - return { kind: "result", result: asJson({ tools: [{ name: "listed", inputSchema: { type: "object" } }] }) }; + return { + kind: "result", + result: asJson({ + tools: [{ name: "listed", inputSchema: { type: "object" } }], + }), + }; } return { kind: "result", result: asJson({ content: [] }) }; }; @@ -317,26 +473,41 @@ describe("client tool executions", () => { await managedSession.close(); const supplied = new FakePort(); - const suppliedSession = withTasks(supplied, { tools: { currentTool: () => undefined } }); + const suppliedSession = withTasks(supplied, { + tools: { currentTool: () => undefined }, + }); await suppliedSession.callTool("x"); - expect(supplied.requests).toEqual([{ method: "tools/call", params: { name: "x" } }]); + expect(supplied.requests).toEqual([ + { method: "tools/call", params: { name: "x" } }, + ]); await suppliedSession.close(); }); - it("retries initial discovery and follows tool-list cursors", async () => { const port = new FakePort({ generation: "v1", capabilities: {} }); let attempts = 0; port.dispatchHandler = async (request) => { const record = expectRecord(request); - if (record.method !== "tools/list") return { kind: "result", result: asJson({ content: [] }) }; + if (record.method !== "tools/list") + return { kind: "result", result: asJson({ content: [] }) }; attempts += 1; if (attempts === 1) throw new DispatchError("temporary", true); const params = expectRecord(record.params as JsonValue); if (params.cursor === undefined) { - return { kind: "result", result: asJson({ tools: [{ name: "first", inputSchema: { type: "object" } }], nextCursor: "next" }) }; + return { + kind: "result", + result: asJson({ + tools: [{ name: "first", inputSchema: { type: "object" } }], + nextCursor: "next", + }), + }; } - return { kind: "result", result: asJson({ tools: [{ name: "second", inputSchema: { type: "object" } }] }) }; + return { + kind: "result", + result: asJson({ + tools: [{ name: "second", inputSchema: { type: "object" } }], + }), + }; }; const session = withTasks(port); await session.callTool("second"); @@ -348,22 +519,38 @@ describe("client tool executions", () => { await session.close(); }); it("ignores stale tool-list refreshes", async () => { - const port = new FakePort({ generation: "v1", capabilities: { requests: { tools: { call: {} } } } }); + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } } }, + }); const pending: ((response: JsonRpcResponse) => void)[] = []; let abortedRefreshes = 0; let listCount = 0; port.dispatchHandler = (request, options) => { const record = expectRecord(request); - if (record.method !== "tools/list") return Promise.resolve({ kind: "result", result: asJson({ content: [] }) }); + if (record.method !== "tools/list") + return Promise.resolve({ + kind: "result", + result: asJson({ content: [] }), + }); listCount += 1; if (listCount === 1) { - return Promise.resolve({ kind: "result", result: asJson({ tools: [{ name: "x", inputSchema: { type: "object" } }] }) }); + return Promise.resolve({ + kind: "result", + result: asJson({ + tools: [{ name: "x", inputSchema: { type: "object" } }], + }), + }); } return new Promise((resolve, reject) => { - options?.signal?.addEventListener("abort", () => { - abortedRefreshes += 1; - reject(new DOMException("superseded", "AbortError")); - }, { once: true }); + options?.signal?.addEventListener( + "abort", + () => { + abortedRefreshes += 1; + reject(new DOMException("superseded", "AbortError")); + }, + { once: true }, + ); pending.push(resolve); }); }; @@ -373,39 +560,75 @@ describe("client tool executions", () => { port.notify({ method: "notifications/tools/list_changed" }); port.notify({ method: "notifications/tools/list_changed" }); expect(abortedRefreshes).toBe(1); - pending[1]?.({ kind: "result", result: asJson({ tools: [{ name: "x", inputSchema: { type: "object" }, execution: { taskSupport: "required" } }] }) }); + pending[1]?.({ + kind: "result", + result: asJson({ + tools: [ + { + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }, + ], + }), + }); await Promise.resolve(); - await expect(session.callTool("x")).rejects.toThrow("Task execution is not supported"); - expect(port.requests.every((request) => expectRecord(request).method === "tools/list")).toBe(true); + await expect(session.callTool("x")).rejects.toThrow( + "Task execution is not supported", + ); + expect( + port.requests.every( + (request) => expectRecord(request).method === "tools/list", + ), + ).toBe(true); await session.close(); }); - it("reports duplicate tools and aborts managed discovery on close", async () => { const errors: Error[] = []; const duplicatePort = new FakePort({ generation: "v1", capabilities: {} }); duplicatePort.dispatchHandler = async (request) => { const record = expectRecord(request); if (record.method === "tools/list") { - return { kind: "result", result: asJson({ tools: [ - { name: "duplicate", inputSchema: { type: "object" } }, - { name: "duplicate", inputSchema: { type: "object" }, title: "newer" }, - ] }) }; + return { + kind: "result", + result: asJson({ + tools: [ + { name: "duplicate", inputSchema: { type: "object" } }, + { + name: "duplicate", + inputSchema: { type: "object" }, + title: "newer", + }, + ], + }), + }; } return { kind: "result", result: asJson({ content: [] }) }; }; - const duplicateSession = withTasks(duplicatePort, { onError: (error) => errors.push(error) }); + const duplicateSession = withTasks(duplicatePort, { + onError: (error) => errors.push(error), + }); await duplicateSession.callTool("duplicate"); - expect(errors.map((error) => error.message)).toContain("Duplicate tool declaration: duplicate"); + expect(errors.map((error) => error.message)).toContain( + "Duplicate tool declaration: duplicate", + ); await duplicateSession.close(); const callAbortPort = new FakePort(); - callAbortPort.dispatchHandler = (_request, options) => new Promise((_resolve, reject) => { - options?.signal?.addEventListener("abort", () => reject(new DOMException("discovery aborted", "AbortError")), { once: true }); - }); + callAbortPort.dispatchHandler = (_request, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(new DOMException("discovery aborted", "AbortError")), + { once: true }, + ); + }); const callAbortSession = withTasks(callAbortPort); const callController = new AbortController(); - const call = callAbortSession.callTool("x", undefined, { signal: callController.signal }); + const call = callAbortSession.callTool("x", undefined, { + signal: callController.signal, + }); callController.abort(new Error("waiter aborted")); await expect(call).rejects.toThrow("waiter aborted"); expect(callAbortPort.requests).toHaveLength(1); @@ -413,10 +636,15 @@ describe("client tool executions", () => { const closePort = new FakePort(); let refreshSignal: AbortSignal | undefined; - closePort.dispatchHandler = (_request, options) => new Promise((_resolve, reject) => { - refreshSignal = options?.signal; - options?.signal?.addEventListener("abort", () => reject(new DOMException("closed", "AbortError")), { once: true }); - }); + closePort.dispatchHandler = (_request, options) => + new Promise((_resolve, reject) => { + refreshSignal = options?.signal; + options?.signal?.addEventListener( + "abort", + () => reject(new DOMException("closed", "AbortError")), + { once: true }, + ); + }); const closeSession = withTasks(closePort); const pendingCall = closeSession.callTool("x"); await closeSession.close(); @@ -426,7 +654,11 @@ describe("client tool executions", () => { }); it("rejects an unambiguous generation-mismatched declaration before dispatch", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); - const v1Tool: ToolV1 = { name: "x", inputSchema: { type: "object" }, execution: { taskSupport: "required" } }; + const v1Tool: ToolV1 = { + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }; const session = withTasks(port, { tools: { currentTool: () => v1Tool } }); await expect(session.callTool("x")).rejects.toThrow(/generation/i); expect(port.requests).toEqual([]); diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts index 3d5a53c..a2f79c2 100644 --- a/packages/ext-tasks/src/client/index.ts +++ b/packages/ext-tasks/src/client/index.ts @@ -31,8 +31,14 @@ import { export type SessionTaskCapabilities = | { readonly generation: "none" } - | { readonly generation: "v1"; readonly capabilities: ServerTaskCapabilitiesV1 } - | { readonly generation: "v2"; readonly capabilities: TaskExtensionCapabilitiesV2 }; + | { + readonly generation: "v1"; + readonly capabilities: ServerTaskCapabilitiesV1; + } + | { + readonly generation: "v2"; + readonly capabilities: TaskExtensionCapabilitiesV2; + }; export type JsonRpcResponse = | { readonly kind: "result"; readonly result: JsonValue } @@ -45,8 +51,13 @@ export interface IncomingServerRequest { export interface ConnectedMcpSessionPort { readonly taskCapabilities: SessionTaskCapabilities; - dispatch(request: JsonValue, options?: { readonly signal?: AbortSignal }): Promise; - onServerRequest(handler: (incoming: IncomingServerRequest) => Promise): () => void; + dispatch( + request: JsonValue, + options?: { readonly signal?: AbortSignal }, + ): Promise; + onServerRequest( + handler: (incoming: IncomingServerRequest) => Promise, + ): () => void; onNotification(listener: (notification: JsonValue) => void): () => void; onInvalidated(listener: (reason: unknown) => void): () => void; readonly invalidated: boolean; @@ -81,16 +92,27 @@ export interface ToolDeclarationProvider { } export type ApplicationInputRequest = - | { readonly kind: "elicitation"; readonly params: Readonly> } - | { readonly kind: "sampling"; readonly params: Readonly> } - | { readonly kind: "roots"; readonly params?: Readonly> }; + | { + readonly kind: "elicitation"; + readonly params: Readonly>; + } + | { + readonly kind: "sampling"; + readonly params: Readonly>; + } + | { + readonly kind: "roots"; + readonly params?: Readonly>; + }; export interface ApplicationElicitResult { readonly action: "accept" | "decline" | "cancel"; readonly content?: Readonly>; } -export type ApplicationCreateMessageResult = Readonly> & { +export type ApplicationCreateMessageResult = Readonly< + Record +> & { readonly model: string; readonly role: "assistant" | "user"; readonly content: JsonValue; @@ -101,15 +123,34 @@ export interface ApplicationListRootsResult { } export type ApplicationInputResult = - TRequest extends { readonly kind: "elicitation" } ? ApplicationElicitResult : - TRequest extends { readonly kind: "sampling" } ? ApplicationCreateMessageResult : - TRequest extends { readonly kind: "roots" } ? ApplicationListRootsResult : - never; + TRequest extends { readonly kind: "elicitation" } + ? ApplicationElicitResult + : TRequest extends { readonly kind: "sampling" } + ? ApplicationCreateMessageResult + : TRequest extends { readonly kind: "roots" } + ? ApplicationListRootsResult + : never; export type ResolvedInputExchangeContext = - | { readonly lifetime: "basic"; readonly executionId: string; readonly applicationContext: TApplicationContext; readonly signal?: AbortSignal } - | { readonly lifetime: "task-v1"; readonly taskId: string; readonly applicationContext: TApplicationContext; readonly signal?: AbortSignal } - | { readonly lifetime: "task-v2"; readonly taskId: string; readonly inputKey: string; readonly applicationContext: TApplicationContext; readonly signal?: AbortSignal }; + | { + readonly lifetime: "basic"; + readonly executionId: string; + readonly applicationContext: TApplicationContext; + readonly signal?: AbortSignal; + } + | { + readonly lifetime: "task-v1"; + readonly taskId: string; + readonly applicationContext: TApplicationContext; + readonly signal?: AbortSignal; + } + | { + readonly lifetime: "task-v2"; + readonly taskId: string; + readonly inputKey: string; + readonly applicationContext: TApplicationContext; + readonly signal?: AbortSignal; + }; export interface ApplicationInputHandler { handle( @@ -143,7 +184,6 @@ export class InputCorrelationError extends Error { } } - export interface WithTasksOptions { readonly tools?: ToolDeclarationProvider; readonly onInputRequest?: ApplicationInputHandler["handle"]; @@ -154,8 +194,16 @@ export interface WithTasksOptions { export type { TaskEligibleMethodV2 } from "../core/v2/index.js"; export type TaskHandle = - | { readonly generation: "v1"; readonly taskId: TaskId; readonly originalOperation: TaskEligibleMethodV1 } - | { readonly generation: "v2"; readonly taskId: TaskId; readonly originalOperation: TaskEligibleMethodV2 }; + | { + readonly generation: "v1"; + readonly taskId: TaskId; + readonly originalOperation: TaskEligibleMethodV1; + } + | { + readonly generation: "v2"; + readonly taskId: TaskId; + readonly originalOperation: TaskEligibleMethodV2; + }; export interface ToolExecutionCommon { readonly applicationContext: TApplicationContext; @@ -167,8 +215,14 @@ export interface ToolExecutionCommon { } export type ToolExecution = - | (ToolExecutionCommon & { readonly kind: "immediate"; readonly handle?: undefined }) - | (ToolExecutionCommon & { readonly kind: "task"; readonly handle: TaskHandle }); + | (ToolExecutionCommon & { + readonly kind: "immediate"; + readonly handle?: undefined; + }) + | (ToolExecutionCommon & { + readonly kind: "task"; + readonly handle: TaskHandle; + }); export class TaskUpdatesAlreadyAcquiredError extends Error { constructor() { @@ -208,29 +262,41 @@ export interface TaskEnabledSession { } export type SerializedTaskReference = - | { readonly endpointId: string; readonly generation: "v1"; readonly taskId: TaskId; readonly originalOperation: TaskEligibleMethodV1 } - | { readonly endpointId: string; readonly generation: "v2"; readonly taskId: TaskId; readonly originalOperation: TaskEligibleMethodV2 }; - + | { + readonly endpointId: string; + readonly generation: "v1"; + readonly taskId: TaskId; + readonly originalOperation: TaskEligibleMethodV1; + } + | { + readonly endpointId: string; + readonly generation: "v2"; + readonly taskId: TaskId; + readonly originalOperation: TaskEligibleMethodV2; + }; function defaultResultCodec( generation: SessionTaskCapabilities["generation"], ): RuntimeCodec { - return generation === "v2" - ? CallToolResultV2Codec - : CallToolResultV1Codec; + return generation === "v2" ? CallToolResultV2Codec : CallToolResultV1Codec; } function reasonAsError(reason: unknown): Error { if (reason instanceof Error) return reason; - return new Error(typeof reason === "string" ? reason : "MCP session was invalidated", { cause: reason }); + return new Error( + typeof reason === "string" ? reason : "MCP session was invalidated", + { cause: reason }, + ); } function unsupported(feature: string): Error { return new Error(`${feature} is not supported`); } -class ImmediateExecution -implements ToolExecutionCommon { +class ImmediateExecution< + TResult, + TApplicationContext, +> implements ToolExecutionCommon { readonly kind = "immediate" as const; readonly handle = undefined; private closed = false; @@ -240,15 +306,13 @@ implements ToolExecutionCommon { private readonly resultPromise: Promise, ) {} - async *updates(_signal?: AbortSignal): AsyncIterable { - } + async *updates(_signal?: AbortSignal): AsyncIterable {} result(): Promise { return this.resultPromise; } - async cancel(_signal?: AbortSignal): Promise { - } + async cancel(_signal?: AbortSignal): Promise {} async close(): Promise { if (this.closed) return; @@ -285,7 +349,11 @@ class ManagedToolDeclarations implements ToolDeclarationProvider { try { await this.initialReady; } catch (error) { - if (this.closed || (error instanceof DOMException && error.name === "AbortError")) throw error; + if ( + this.closed || + (error instanceof DOMException && error.name === "AbortError") + ) + throw error; this.initialReady = this.refresh(); void this.initialReady.catch(() => {}); await this.initialReady; @@ -295,7 +363,12 @@ class ManagedToolDeclarations implements ToolDeclarationProvider { if (signal === undefined) return waiting; let onAbort: (() => void) | undefined; const aborted = new Promise((_, reject) => { - onAbort = () => reject(signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError")); + onAbort = () => + reject( + signal.reason instanceof Error + ? signal.reason + : new DOMException("The operation was aborted", "AbortError"), + ); signal.addEventListener("abort", onAbort, { once: true }); }); try { @@ -313,18 +386,28 @@ class ManagedToolDeclarations implements ToolDeclarationProvider { onNotification(notification: JsonValue): void { if (this.closed) return; - if (notification === null || Array.isArray(notification) || typeof notification !== "object") return; + if ( + notification === null || + Array.isArray(notification) || + typeof notification !== "object" + ) + return; const record = notification as Readonly>; if (record.method !== "notifications/tools/list_changed") return; void this.refresh().catch((error: unknown) => { if (!(error instanceof DOMException && error.name === "AbortError")) { - this.reportError(error instanceof Error ? error : new Error("Tool refresh failed", { cause: error })); + this.reportError( + error instanceof Error + ? error + : new Error("Tool refresh failed", { cause: error }), + ); } }); } private async refresh(): Promise { - if (this.closed) throw new DOMException("Tool declarations are closed", "AbortError"); + if (this.closed) + throw new DOMException("Tool declarations are closed", "AbortError"); const sequence = ++this.refreshSequence; this.refreshController?.abort(); const controller = new AbortController(); @@ -333,41 +416,59 @@ class ManagedToolDeclarations implements ToolDeclarationProvider { let cursor: string | undefined; do { const response = await this.port.dispatch( - { method: "tools/list", params: cursor === undefined ? {} : { cursor } }, + { + method: "tools/list", + params: cursor === undefined ? {} : { cursor }, + }, { signal: controller.signal }, ); - if (response.kind === "error") throw new JsonRpcResponseError(response.error); - if (response.result === null || Array.isArray(response.result) || typeof response.result !== "object") { + if (response.kind === "error") + throw new JsonRpcResponseError(response.error); + if ( + response.result === null || + Array.isArray(response.result) || + typeof response.result !== "object" + ) { throw new Error("tools/list result must be an object"); } const result = response.result as Readonly>; const listed = result.tools; - if (!Array.isArray(listed)) throw new Error("tools/list result must contain tools"); + if (!Array.isArray(listed)) + throw new Error("tools/list result must contain tools"); for (const value of listed) { - const parsed = this.port.taskCapabilities.generation === "v1" - ? ToolV1Codec.parse(value) - : this.port.taskCapabilities.generation === "v2" - ? ToolV2Codec.parse(value) - : (() => { - const v2 = ToolV2Codec.parse(value); - return v2.success ? v2 : ToolV1Codec.parse(value); - })(); + const parsed = + this.port.taskCapabilities.generation === "v1" + ? ToolV1Codec.parse(value) + : this.port.taskCapabilities.generation === "v2" + ? ToolV2Codec.parse(value) + : (() => { + const v2 = ToolV2Codec.parse(value); + return v2.success ? v2 : ToolV1Codec.parse(value); + })(); if (!parsed.success) throw parsed.error; if (decoded.has(parsed.value.name)) { - this.reportError(new Error(`Duplicate tool declaration: ${parsed.value.name}`)); + this.reportError( + new Error(`Duplicate tool declaration: ${parsed.value.name}`), + ); } decoded.set(parsed.value.name, parsed.value); } - cursor = typeof result.nextCursor === "string" ? result.nextCursor : undefined; + cursor = + typeof result.nextCursor === "string" ? result.nextCursor : undefined; } while (cursor !== undefined); if (sequence === this.refreshSequence) this.tools = decoded; } } - -function requestParams(request: Readonly>): Readonly> { +function requestParams( + request: Readonly>, +): Readonly> { if (request.params === undefined) return {}; - if (request.params === null || Array.isArray(request.params) || typeof request.params !== "object") { + if ( + request.params === null || + Array.isArray(request.params) || + typeof request.params !== "object" + ) { throw new Error("Input request params must be an object"); } return request.params as Readonly>; @@ -383,9 +484,14 @@ interface OrdinaryInputCandidate { let nextExecutionId = 0; - -function defaultServerRequestResponse(incoming: IncomingServerRequest): JsonRpcResponse { - if (incoming.request !== null && !Array.isArray(incoming.request) && typeof incoming.request === "object") { +function defaultServerRequestResponse( + incoming: IncomingServerRequest, +): JsonRpcResponse { + if ( + incoming.request !== null && + !Array.isArray(incoming.request) && + typeof incoming.request === "object" + ) { const request = incoming.request as Readonly>; if (request.method === "elicitation/create") { return { kind: "result", result: { action: "cancel" } }; @@ -396,17 +502,23 @@ function defaultServerRequestResponse(incoming: IncomingServerRequest): JsonRpcR function throwIfAborted(signal: AbortSignal | undefined): void { if (signal?.aborted !== true) return; - throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); + throw signal.reason instanceof Error + ? signal.reason + : new DOMException("The operation was aborted", "AbortError"); } - -class PortTaskEnabledSession implements TaskEnabledSession { +class PortTaskEnabledSession< + TApplicationContext, +> implements TaskEnabledSession { private closed = false; private invalidationError: Error | undefined; private readonly disposeListeners: readonly (() => void)[]; private readonly declarations: ToolDeclarationProvider; private readonly managedDeclarations: ManagedToolDeclarations | undefined; - private readonly ordinaryInputCandidates = new Map>(); + private readonly ordinaryInputCandidates = new Map< + string, + OrdinaryInputCandidate + >(); constructor( private readonly port: ConnectedMcpSessionPort, @@ -419,24 +531,31 @@ class PortTaskEnabledSession implements TaskEnabledSession< console.error(sinkError); } }; - this.managedDeclarations = options.tools === undefined - ? new ManagedToolDeclarations(port, reportError) - : undefined; + this.managedDeclarations = + options.tools === undefined + ? new ManagedToolDeclarations(port, reportError) + : undefined; this.declarations = options.tools ?? this.managedDeclarations!; const onSessionAbort = (): void => { if (this.invalidationError === undefined) { - this.invalidationError = options.signal?.reason instanceof Error - ? options.signal.reason - : new DOMException("The session was aborted", "AbortError"); + this.invalidationError = + options.signal?.reason instanceof Error + ? options.signal.reason + : new DOMException("The session was aborted", "AbortError"); } this.managedDeclarations?.close(); }; options.signal?.addEventListener("abort", onSessionAbort, { once: true }); this.disposeListeners = [ - port.onServerRequest(async (incoming) => this.handleServerRequest(incoming)), - port.onNotification((notification) => this.managedDeclarations?.onNotification(notification)), + port.onServerRequest(async (incoming) => + this.handleServerRequest(incoming), + ), + port.onNotification((notification) => + this.managedDeclarations?.onNotification(notification), + ), port.onInvalidated((reason) => { - if (this.invalidationError === undefined) this.invalidationError = reasonAsError(reason); + if (this.invalidationError === undefined) + this.invalidationError = reasonAsError(reason); this.managedDeclarations?.close(); }), () => options.signal?.removeEventListener("abort", onSessionAbort), @@ -464,22 +583,35 @@ class PortTaskEnabledSession implements TaskEnabledSession< await this.managedDeclarations?.ensureReady(options.signal); this.assertUsable(); const declaration = this.declarations.currentTool(name); - if (this.port.taskCapabilities.generation === "v2" && declaration !== undefined && "execution" in declaration) { - throw new Error("Tool declaration generation does not match the V2 session"); + if ( + this.port.taskCapabilities.generation === "v2" && + declaration !== undefined && + "execution" in declaration + ) { + throw new Error( + "Tool declaration generation does not match the V2 session", + ); } const requestParams: Record = { name }; if (params !== undefined) requestParams.arguments = params; if ( - this.port.taskCapabilities.generation === "v1" - && declaration !== undefined - && "execution" in declaration - && shouldCallToolAsTaskV1(this.port.taskCapabilities.capabilities, declaration as ToolV1, options.preferTask) + this.port.taskCapabilities.generation === "v1" && + declaration !== undefined && + "execution" in declaration && + shouldCallToolAsTaskV1( + this.port.taskCapabilities.capabilities, + declaration as ToolV1, + options.preferTask, + ) ) { throw unsupported("Task execution"); } const executionId = `execution-${++nextExecutionId}`; this.ordinaryInputCandidates.set(executionId, { - generation: this.port.taskCapabilities.generation === "none" ? "v1" : this.port.taskCapabilities.generation, + generation: + this.port.taskCapabilities.generation === "none" + ? "v1" + : this.port.taskCapabilities.generation, toolName: name, executionId, applicationContext: options.applicationContext as TApplicationContext, @@ -496,13 +628,22 @@ class PortTaskEnabledSession implements TaskEnabledSession< } this.assertUsable(); throwIfAborted(options.signal); - if (response.kind === "error") throw new JsonRpcResponseError(response.error); - if (isTaskResultForGeneration(this.port.taskCapabilities.generation, response.result)) { + if (response.kind === "error") + throw new JsonRpcResponseError(response.error); + if ( + isTaskResultForGeneration( + this.port.taskCapabilities.generation, + response.result, + ) + ) { throw unsupported("Task-result execution"); } - const codec = options.resultCodec - ?? defaultResultCodec(this.port.taskCapabilities.generation) as RuntimeCodec; + const codec = + options.resultCodec ?? + (defaultResultCodec( + this.port.taskCapabilities.generation, + ) as RuntimeCodec); const decoded = codec.parse(response.result); if (!decoded.success) throw decoded.error; const resultPromise = Promise.resolve(decoded.value); @@ -534,37 +675,57 @@ class PortTaskEnabledSession implements TaskEnabledSession< return this.close(); } - private async handleServerRequest(incoming: IncomingServerRequest): Promise { - if (this.options.onInputRequest === undefined) return defaultServerRequestResponse(incoming); - if (incoming.request === null || Array.isArray(incoming.request) || typeof incoming.request !== "object") { + private async handleServerRequest( + incoming: IncomingServerRequest, + ): Promise { + if (this.options.onInputRequest === undefined) + return defaultServerRequestResponse(incoming); + if ( + incoming.request === null || + Array.isArray(incoming.request) || + typeof incoming.request !== "object" + ) { return defaultServerRequestResponse(incoming); } const wire = incoming.request as Readonly>; const method = wire.method; - const request: ApplicationInputRequest | undefined = method === "elicitation/create" - ? { kind: "elicitation", params: requestParams(wire) } - : method === "sampling/createMessage" - ? { kind: "sampling", params: requestParams(wire) } - : method === "roots/list" - ? { kind: "roots", ...(wire.params === undefined ? {} : { params: requestParams(wire) }) } - : undefined; + const request: ApplicationInputRequest | undefined = + method === "elicitation/create" + ? { kind: "elicitation", params: requestParams(wire) } + : method === "sampling/createMessage" + ? { kind: "sampling", params: requestParams(wire) } + : method === "roots/list" + ? { + kind: "roots", + ...(wire.params === undefined + ? {} + : { params: requestParams(wire) }), + } + : undefined; if (request === undefined) return defaultServerRequestResponse(incoming); if (this.ordinaryInputCandidates.size !== 1) { - const candidates = [...this.ordinaryInputCandidates.values()].map((candidate) => ({ - generation: candidate.generation, - toolName: candidate.toolName, - executionId: candidate.executionId, - applicationContext: candidate.applicationContext, - })); - this.reportBackgroundError(new InputCorrelationError( - this.port.taskCapabilities.generation === "none" ? "v1" : this.port.taskCapabilities.generation, - request.kind, - candidates, - candidates.length === 0 ? "zero-matches" : "ambiguous-matches", - )); + const candidates = [...this.ordinaryInputCandidates.values()].map( + (candidate) => ({ + generation: candidate.generation, + toolName: candidate.toolName, + executionId: candidate.executionId, + applicationContext: candidate.applicationContext, + }), + ); + this.reportBackgroundError( + new InputCorrelationError( + this.port.taskCapabilities.generation === "none" + ? "v1" + : this.port.taskCapabilities.generation, + request.kind, + candidates, + candidates.length === 0 ? "zero-matches" : "ambiguous-matches", + ), + ); return defaultServerRequestResponse(incoming); } - const candidate = this.ordinaryInputCandidates.values().next().value as OrdinaryInputCandidate; + const candidate = this.ordinaryInputCandidates.values().next() + .value as OrdinaryInputCandidate; try { const result = await this.options.onInputRequest(request, { lifetime: "basic", @@ -572,14 +733,14 @@ class PortTaskEnabledSession implements TaskEnabledSession< applicationContext: candidate.applicationContext, ...(candidate.signal === undefined ? {} : { signal: candidate.signal }), }); - if (!isJsonValue(result)) throw new Error("Input handler returned a non-JSON value"); + if (!isJsonValue(result)) + throw new Error("Input handler returned a non-JSON value"); return { kind: "result", result }; } catch { return defaultServerRequestResponse(incoming); } } - private reportBackgroundError(error: Error): void { try { if (this.options.onError === undefined) console.error(error); @@ -589,7 +750,6 @@ class PortTaskEnabledSession implements TaskEnabledSession< } } - private assertUsable(): void { if (this.invalidationError !== undefined) throw this.invalidationError; if (this.closed) throw new Error("Task-enabled session is closed"); diff --git a/packages/ext-tasks/src/core/index.test.ts b/packages/ext-tasks/src/core/index.test.ts index 64ef4bb..57fc66f 100644 --- a/packages/ext-tasks/src/core/index.test.ts +++ b/packages/ext-tasks/src/core/index.test.ts @@ -21,9 +21,11 @@ const jsonValue = fc.letrec((tie) => ({ describe("core runtime contracts", () => { it("recognizes exactly JSON-compatible generated values", () => { - fc.assert(fc.property(jsonValue, (value) => { - expect(isJsonValue(value)).toBe(true); - })); + fc.assert( + fc.property(jsonValue, (value) => { + expect(isJsonValue(value)).toBe(true); + }), + ); fc.assert( fc.property( fc.oneof(fc.constant(undefined), fc.bigInt(), fc.constant(Symbol("x"))), @@ -34,7 +36,9 @@ describe("core runtime contracts", () => { ); expect(isJsonValue(new Date())).toBe(false); expect(isJsonValue(new Map())).toBe(false); - expect(isJsonValue(Object.assign(Object.create(null), { ok: true }))).toBe(true); + expect(isJsonValue(Object.assign(Object.create(null), { ok: true }))).toBe( + true, + ); const cyclic: Record = {}; cyclic.self = cyclic; expect(isJsonValue(cyclic)).toBe(false); @@ -52,7 +56,10 @@ describe("core runtime contracts", () => { }); it("exposes decode failures as Error values with stable paths", () => { - const error = new ProtocolDecodeError("expected string", ["task", "taskId"]); + const error = new ProtocolDecodeError("expected string", [ + "task", + "taskId", + ]); expect(error).toBeInstanceOf(Error); expect(error.path).toEqual(["task", "taskId"]); expect(error.message).toContain("task.taskId"); diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts index 3707e1b..9c5ed1a 100644 --- a/packages/ext-tasks/src/core/index.ts +++ b/packages/ext-tasks/src/core/index.ts @@ -26,7 +26,9 @@ export class ProtocolDecodeError extends Error { } export interface RuntimeCodec { - parse(value: JsonValue): + parse( + value: JsonValue, + ): | { readonly success: true; readonly value: T } | { readonly success: false; readonly error: ProtocolDecodeError }; } @@ -42,17 +44,26 @@ export function taskId(value: string): TaskId { export function isJsonValue(value: unknown): value is JsonValue { const visiting = new WeakSet(); const visit = (candidate: unknown): boolean => { - if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return true; + if ( + candidate === null || + typeof candidate === "string" || + typeof candidate === "boolean" + ) + return true; if (typeof candidate === "number") return Number.isFinite(candidate); if (typeof candidate !== "object") return false; if (visiting.has(candidate)) return false; visiting.add(candidate); let valid: boolean; if (Array.isArray(candidate)) { - valid = candidate.length === Object.keys(candidate).length && candidate.every(visit); + valid = + candidate.length === Object.keys(candidate).length && + candidate.every(visit); } else { const prototype = Object.getPrototypeOf(candidate); - valid = (prototype === Object.prototype || prototype === null) && Object.values(candidate).every(visit); + valid = + (prototype === Object.prototype || prototype === null) && + Object.values(candidate).every(visit); } visiting.delete(candidate); return valid; @@ -62,7 +73,7 @@ export function isJsonValue(value: unknown): value is JsonValue { export function createRuntimeCodec( decode: (value: JsonValue, path: DecodePath) => T, - ): RuntimeCodec { +): RuntimeCodec { return { parse(value) { try { @@ -70,28 +81,39 @@ export function createRuntimeCodec( } catch (error) { return { success: false, - error: error instanceof ProtocolDecodeError - ? error - : new ProtocolDecodeError("invalid protocol value"), + error: + error instanceof ProtocolDecodeError + ? error + : new ProtocolDecodeError("invalid protocol value"), }; } }, }; } -export function expectRecord(value: JsonValue, path: DecodePath = []): Record { +export function expectRecord( + value: JsonValue, + path: DecodePath = [], +): Record { if (value === null || Array.isArray(value) || typeof value !== "object") { throw new ProtocolDecodeError("expected object", path); } return value as Record; } -export function expectString(value: JsonValue | undefined, path: DecodePath): string { - if (typeof value !== "string") throw new ProtocolDecodeError("expected string", path); +export function expectString( + value: JsonValue | undefined, + path: DecodePath, +): string { + if (typeof value !== "string") + throw new ProtocolDecodeError("expected string", path); return value; } -export function expectNumber(value: JsonValue | undefined, path: DecodePath): number { +export function expectNumber( + value: JsonValue | undefined, + path: DecodePath, +): number { if (typeof value !== "number" || !Number.isFinite(value)) { throw new ProtocolDecodeError("expected finite number", path); } @@ -102,7 +124,7 @@ export function expectEnum( value: JsonValue | undefined, values: readonly T[], path: DecodePath, - ): T { +): T { if (typeof value !== "string" || !values.includes(value as T)) { throw new ProtocolDecodeError(`expected one of ${values.join(", ")}`, path); } diff --git a/packages/ext-tasks/src/core/v1/index.test.ts b/packages/ext-tasks/src/core/v1/index.test.ts index 0bca0e0..611672b 100644 --- a/packages/ext-tasks/src/core/v1/index.test.ts +++ b/packages/ext-tasks/src/core/v1/index.test.ts @@ -2,26 +2,64 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; import { - CallToolRequestV1Codec, CallToolResultV1Codec, CancelTaskRequestV1Codec, CancelTaskResultV1Codec, - CreateTaskResultV1Codec, GetTaskRequestV1Codec, GetTaskResultRequestV1Codec, GetTaskResultV1Codec, - ListTasksRequestV1Codec, ListTasksResultV1Codec, ServerTaskCapabilitiesV1Codec, TaskResultV1Codec, - TaskStatusNotificationV1Codec, TaskStatusV1Codec, TaskV1Codec, ToolV1Codec, - callToolAsTaskV1, hasTaskCancelCapabilityV1, hasTaskListCapabilityV1, hasTaskToolCallCapabilityV1, - isTaskEligibleMethodV1, shouldCallToolAsTaskV1, - type ServerTaskCapabilitiesV1, type TaskStatusV1, type ToolV1, + CallToolRequestV1Codec, + CallToolResultV1Codec, + CancelTaskRequestV1Codec, + CancelTaskResultV1Codec, + CreateTaskResultV1Codec, + GetTaskRequestV1Codec, + GetTaskResultRequestV1Codec, + GetTaskResultV1Codec, + ListTasksRequestV1Codec, + ListTasksResultV1Codec, + ServerTaskCapabilitiesV1Codec, + TaskResultV1Codec, + TaskStatusNotificationV1Codec, + TaskStatusV1Codec, + TaskV1Codec, + ToolV1Codec, + callToolAsTaskV1, + hasTaskCancelCapabilityV1, + hasTaskListCapabilityV1, + hasTaskToolCallCapabilityV1, + isTaskEligibleMethodV1, + shouldCallToolAsTaskV1, + type ServerTaskCapabilitiesV1, + type TaskStatusV1, + type ToolV1, } from "./index.js"; -const statuses: readonly TaskStatusV1[] = ["working", "input_required", "completed", "failed", "cancelled"]; +const statuses: readonly TaskStatusV1[] = [ + "working", + "input_required", + "completed", + "failed", + "cancelled", +]; const taskArb = fc.record({ - taskId: fc.string(), status: fc.constantFrom(...statuses), - statusMessage: fc.option(fc.string(), { nil: undefined }), createdAt: fc.string(), lastUpdatedAt: fc.string(), - ttl: fc.oneof(fc.integer(), fc.constant(null)), pollInterval: fc.option(fc.integer(), { nil: undefined }), + taskId: fc.string(), + status: fc.constantFrom(...statuses), + statusMessage: fc.option(fc.string(), { nil: undefined }), + createdAt: fc.string(), + lastUpdatedAt: fc.string(), + ttl: fc.oneof(fc.integer(), fc.constant(null)), + pollInterval: fc.option(fc.integer(), { nil: undefined }), }); const idArb = fc.oneof(fc.string(), fc.integer()); -const taskRequestArb = (method: "tasks/get" | "tasks/result" | "tasks/cancel") => - fc.record({ jsonrpc: fc.constant("2.0" as const), id: idArb, method: fc.constant(method), params: fc.record({ taskId: fc.string() }) }); +const taskRequestArb = ( + method: "tasks/get" | "tasks/result" | "tasks/cancel", +) => + fc.record({ + jsonrpc: fc.constant("2.0" as const), + id: idArb, + method: fc.constant(method), + params: fc.record({ taskId: fc.string() }), + }); -function expectRoundTrip(codec: { parse(value: never): { success: boolean; value?: unknown } }, value: unknown): void { +function expectRoundTrip( + codec: { parse(value: never): { success: boolean; value?: unknown } }, + value: unknown, +): void { const decoded = codec.parse(value as never); expect(decoded.success).toBe(true); if (decoded.success) expect(decoded.value).toEqual(value); @@ -29,18 +67,53 @@ function expectRoundTrip(codec: { parse(value: never): { success: boolean; value describe("V1 generated wire contracts", () => { it("round-trips Tasks and rejects missing fields, fractions, and unknown statuses", () => { - fc.assert(fc.property(taskArb, (task) => { expectRoundTrip(TaskV1Codec, task); })); - fc.assert(fc.property(fc.string().filter((v) => !statuses.includes(v as TaskStatusV1)), (value) => { - expect(TaskStatusV1Codec.parse(value).success).toBe(false); - })); - fc.assert(fc.property(taskArb, fc.constantFrom("taskId", "status", "createdAt", "lastUpdatedAt", "ttl"), (task, key) => { - const { [key]: ignored, ...incomplete } = task; void ignored; - expect(TaskV1Codec.parse(incomplete as never).success).toBe(false); - })); - fc.assert(fc.property(taskArb, fc.double({ noNaN: true, noDefaultInfinity: true }).filter((n) => !Number.isInteger(n)), (task, fraction) => { - expect(TaskV1Codec.parse({ ...task, ttl: fraction } as never).success).toBe(false); - expect(TaskV1Codec.parse({ ...task, pollInterval: fraction } as never).success).toBe(false); - })); + fc.assert( + fc.property(taskArb, (task) => { + expectRoundTrip(TaskV1Codec, task); + }), + ); + fc.assert( + fc.property( + fc.string().filter((v) => !statuses.includes(v as TaskStatusV1)), + (value) => { + expect(TaskStatusV1Codec.parse(value).success).toBe(false); + }, + ), + ); + fc.assert( + fc.property( + taskArb, + fc.constantFrom( + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttl", + ), + (task, key) => { + const { [key]: ignored, ...incomplete } = task; + void ignored; + expect(TaskV1Codec.parse(incomplete as never).success).toBe(false); + }, + ), + ); + fc.assert( + fc.property( + taskArb, + fc + .double({ noNaN: true, noDefaultInfinity: true }) + .filter((n) => !Number.isInteger(n)), + (task, fraction) => { + expect( + TaskV1Codec.parse({ ...task, ttl: fraction } as never).success, + ).toBe(false); + expect( + TaskV1Codec.parse({ ...task, pollInterval: fraction } as never) + .success, + ).toBe(false); + }, + ), + ); }); it("round-trips strict task operation requests and rejects discriminator changes", () => { @@ -49,93 +122,250 @@ describe("V1 generated wire contracts", () => { [GetTaskResultRequestV1Codec, taskRequestArb("tasks/result")], [CancelTaskRequestV1Codec, taskRequestArb("tasks/cancel")], ] as const; - for (const [codec, arbitrary] of cases) fc.assert(fc.property(arbitrary, (request) => { - expectRoundTrip(codec as never, request); - expect(codec.parse({ ...request, method: "tasks/nope" } as never).success).toBe(false); - expect(codec.parse({ ...request, jsonrpc: "1.0" } as never).success).toBe(false); - const { params: ignored, ...withoutParams } = request; void ignored; - expect(codec.parse(withoutParams as never).success).toBe(false); - })); + for (const [codec, arbitrary] of cases) + fc.assert( + fc.property(arbitrary, (request) => { + expectRoundTrip(codec as never, request); + expect( + codec.parse({ ...request, method: "tasks/nope" } as never).success, + ).toBe(false); + expect( + codec.parse({ ...request, jsonrpc: "1.0" } as never).success, + ).toBe(false); + const { params: ignored, ...withoutParams } = request; + void ignored; + expect(codec.parse(withoutParams as never).success).toBe(false); + }), + ); }); it("round-trips get/cancel/list/create results and notifications", () => { - fc.assert(fc.property(taskArb, (task) => { - expectRoundTrip(GetTaskResultV1Codec, task); expectRoundTrip(CancelTaskResultV1Codec, task); - expectRoundTrip(CreateTaskResultV1Codec, { task }); - expectRoundTrip(TaskStatusNotificationV1Codec, { jsonrpc: "2.0", method: "notifications/tasks/status", params: task }); - })); - fc.assert(fc.property(fc.array(taskArb), fc.option(fc.string(), { nil: undefined }), (tasks, nextCursor) => { - expectRoundTrip(ListTasksResultV1Codec, { tasks, ...(nextCursor === undefined ? {} : { nextCursor }) }); - })); - fc.assert(fc.property(idArb, fc.option(fc.string(), { nil: undefined }), (id, cursor) => { - expectRoundTrip(ListTasksRequestV1Codec, { jsonrpc: "2.0", id, method: "tasks/list", ...(cursor === undefined ? {} : { params: { cursor } }) }); - })); - expect(TaskStatusNotificationV1Codec.parse({ jsonrpc: "2.0", method: "notifications/tasks/nope", params: {} }).success).toBe(false); + fc.assert( + fc.property(taskArb, (task) => { + expectRoundTrip(GetTaskResultV1Codec, task); + expectRoundTrip(CancelTaskResultV1Codec, task); + expectRoundTrip(CreateTaskResultV1Codec, { task }); + expectRoundTrip(TaskStatusNotificationV1Codec, { + jsonrpc: "2.0", + method: "notifications/tasks/status", + params: task, + }); + }), + ); + fc.assert( + fc.property( + fc.array(taskArb), + fc.option(fc.string(), { nil: undefined }), + (tasks, nextCursor) => { + expectRoundTrip(ListTasksResultV1Codec, { + tasks, + ...(nextCursor === undefined ? {} : { nextCursor }), + }); + }, + ), + ); + fc.assert( + fc.property( + idArb, + fc.option(fc.string(), { nil: undefined }), + (id, cursor) => { + expectRoundTrip(ListTasksRequestV1Codec, { + jsonrpc: "2.0", + id, + method: "tasks/list", + ...(cursor === undefined ? {} : { params: { cursor } }), + }); + }, + ), + ); + expect( + TaskStatusNotificationV1Codec.parse({ + jsonrpc: "2.0", + method: "notifications/tasks/nope", + params: {}, + }).success, + ).toBe(false); }); it("decodes arbitrary task results and strict tool call content discriminators", () => { - fc.assert(fc.property(fc.dictionary(fc.string(), fc.jsonValue()), (result) => { expectRoundTrip(TaskResultV1Codec, result); })); + fc.assert( + fc.property(fc.dictionary(fc.string(), fc.jsonValue()), (result) => { + expectRoundTrip(TaskResultV1Codec, result); + }), + ); const content = [ - { type: "text", text: "hello" }, { type: "image", data: "x", mimeType: "image/png" }, - { type: "audio", data: "x", mimeType: "audio/wav" }, { type: "resource_link", name: "n", uri: "https://x" }, + { type: "text", text: "hello" }, + { type: "image", data: "x", mimeType: "image/png" }, + { type: "audio", data: "x", mimeType: "audio/wav" }, + { type: "resource_link", name: "n", uri: "https://x" }, { type: "resource", resource: { uri: "https://x", text: "body" } }, ]; - expectRoundTrip(CallToolResultV1Codec, { content, structuredContent: { ok: true }, isError: false }); - fc.assert(fc.property(fc.string().filter((type) => !["text", "image", "audio", "resource_link", "resource"].includes(type)), (type) => { - expect(CallToolResultV1Codec.parse({ content: [{ type }] }).success).toBe(false); - })); - expect(CallToolResultV1Codec.parse({ content: [{ type: "text" }] }).success).toBe(false); + expectRoundTrip(CallToolResultV1Codec, { + content, + structuredContent: { ok: true }, + isError: false, + }); + fc.assert( + fc.property( + fc + .string() + .filter( + (type) => + !["text", "image", "audio", "resource_link", "resource"].includes( + type, + ), + ), + (type) => { + expect( + CallToolResultV1Codec.parse({ content: [{ type }] }).success, + ).toBe(false); + }, + ), + ); + expect( + CallToolResultV1Codec.parse({ content: [{ type: "text" }] }).success, + ).toBe(false); }); it("decodes tools, task-augmented calls, and nested capabilities strictly", () => { - fc.assert(fc.property( - fc.string(), - fc.option(fc.constantFrom("forbidden", "optional", "required"), { nil: undefined }), - fc.dictionary(fc.string(), fc.jsonValue()), - fc.array(fc.dictionary(fc.string(), fc.jsonValue())), - (name, taskSupport, metadata, icons) => { - expectRoundTrip(ToolV1Codec, { - name, - title: "title", - description: "description", + fc.assert( + fc.property( + fc.string(), + fc.option(fc.constantFrom("forbidden", "optional", "required"), { + nil: undefined, + }), + fc.dictionary(fc.string(), fc.jsonValue()), + fc.array(fc.dictionary(fc.string(), fc.jsonValue())), + (name, taskSupport, metadata, icons) => { + expectRoundTrip(ToolV1Codec, { + name, + title: "title", + description: "description", + inputSchema: { type: "object" }, + outputSchema: { type: "object", properties: {} }, + execution: { + ...(taskSupport === undefined ? {} : { taskSupport }), + }, + annotations: metadata, + icons, + _meta: metadata, + }); + }, + ), + ); + expect(ToolV1Codec.parse({ name: "x", inputSchema: {} }).success).toBe( + false, + ); + expect( + ToolV1Codec.parse({ + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "sometimes" }, + }).success, + ).toBe(false); + for (const [field, invalid] of [ + ["outputSchema", true], + ["annotations", true], + ["icons", true], + ["_meta", true], + ] as const) { + expect( + ToolV1Codec.parse({ + name: "x", inputSchema: { type: "object" }, - outputSchema: { type: "object", properties: {} }, - execution: { ...(taskSupport === undefined ? {} : { taskSupport }) }, - annotations: metadata, - icons, - _meta: metadata, - }); - }, - )); - expect(ToolV1Codec.parse({ name: "x", inputSchema: {} }).success).toBe(false); - expect(ToolV1Codec.parse({ name: "x", inputSchema: { type: "object" }, execution: { taskSupport: "sometimes" } }).success).toBe(false); - for (const [field, invalid] of [["outputSchema", true], ["annotations", true], ["icons", true], ["_meta", true]] as const) { - expect(ToolV1Codec.parse({ name: "x", inputSchema: { type: "object" }, [field]: invalid }).success).toBe(false); + [field]: invalid, + }).success, + ).toBe(false); } - expect(ToolV1Codec.parse({ name: "x", inputSchema: { type: "object" }, icons: [true] }).success).toBe(false); - fc.assert(fc.property(idArb, fc.string(), fc.dictionary(fc.string(), fc.jsonValue()), (id, name, args) => { - expectRoundTrip(CallToolRequestV1Codec, { jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args, task: {} } }); - })); - expect(CallToolRequestV1Codec.parse({ jsonrpc: "2.0", id: 1, method: "tools/nope", params: { name: "x" } }).success).toBe(false); - expectRoundTrip(ServerTaskCapabilitiesV1Codec, { list: {}, cancel: {}, requests: { tools: { call: {} } } }); - expect(ServerTaskCapabilitiesV1Codec.parse({ requests: { tools: { call: true } } }).success).toBe(false); + expect( + ToolV1Codec.parse({ + name: "x", + inputSchema: { type: "object" }, + icons: [true], + }).success, + ).toBe(false); + fc.assert( + fc.property( + idArb, + fc.string(), + fc.dictionary(fc.string(), fc.jsonValue()), + (id, name, args) => { + expectRoundTrip(CallToolRequestV1Codec, { + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name, arguments: args, task: {} }, + }); + }, + ), + ); + expect( + CallToolRequestV1Codec.parse({ + jsonrpc: "2.0", + id: 1, + method: "tools/nope", + params: { name: "x" }, + }).success, + ).toBe(false); + expectRoundTrip(ServerTaskCapabilitiesV1Codec, { + list: {}, + cancel: {}, + requests: { tools: { call: {} } }, + }); + expect( + ServerTaskCapabilitiesV1Codec.parse({ + requests: { tools: { call: true } }, + }).success, + ).toBe(false); }); it("follows every capability-first negotiation row and narrow guard", () => { - const support = fc.option(fc.constantFrom("forbidden", "optional", "required"), { nil: undefined }); - fc.assert(fc.property(fc.boolean(), support, fc.boolean(), (present, taskSupport, preferTask) => { - const capabilities: ServerTaskCapabilitiesV1 = present ? { requests: { tools: { call: {} } } } : {}; - const tool: ToolV1 = { name: "tool", inputSchema: { type: "object" }, execution: { taskSupport } }; - expect(shouldCallToolAsTaskV1(capabilities, tool, preferTask)).toBe(present && (taskSupport === "required" || (taskSupport === "optional" && preferTask))); - expect(hasTaskToolCallCapabilityV1(capabilities)).toBe(present); - })); - expect(hasTaskListCapabilityV1({ list: {} })).toBe(true); expect(hasTaskListCapabilityV1({})).toBe(false); - expect(hasTaskCancelCapabilityV1({ cancel: {} })).toBe(true); expect(hasTaskCancelCapabilityV1({})).toBe(false); - fc.assert(fc.property(fc.string(), (method) => { expect(isTaskEligibleMethodV1(method)).toBe(method === "tools/call"); })); + const support = fc.option( + fc.constantFrom("forbidden", "optional", "required"), + { nil: undefined }, + ); + fc.assert( + fc.property( + fc.boolean(), + support, + fc.boolean(), + (present, taskSupport, preferTask) => { + const capabilities: ServerTaskCapabilitiesV1 = present + ? { requests: { tools: { call: {} } } } + : {}; + const tool: ToolV1 = { + name: "tool", + inputSchema: { type: "object" }, + execution: { taskSupport }, + }; + expect(shouldCallToolAsTaskV1(capabilities, tool, preferTask)).toBe( + present && + (taskSupport === "required" || + (taskSupport === "optional" && preferTask)), + ); + expect(hasTaskToolCallCapabilityV1(capabilities)).toBe(present); + }, + ), + ); + expect(hasTaskListCapabilityV1({ list: {} })).toBe(true); + expect(hasTaskListCapabilityV1({})).toBe(false); + expect(hasTaskCancelCapabilityV1({ cancel: {} })).toBe(true); + expect(hasTaskCancelCapabilityV1({})).toBe(false); + fc.assert( + fc.property(fc.string(), (method) => { + expect(isTaskEligibleMethodV1(method)).toBe(method === "tools/call"); + }), + ); }); it("constructs exact call augmentation", () => { - expect(callToolAsTaskV1("tool", { x: 1 })).toEqual({ method: "tools/call", params: { name: "tool", arguments: { x: 1 }, task: {} } }); - expect(callToolAsTaskV1("tool")).toEqual({ method: "tools/call", params: { name: "tool", task: {} } }); + expect(callToolAsTaskV1("tool", { x: 1 })).toEqual({ + method: "tools/call", + params: { name: "tool", arguments: { x: 1 }, task: {} }, + }); + expect(callToolAsTaskV1("tool")).toEqual({ + method: "tools/call", + params: { name: "tool", task: {} }, + }); }); }); diff --git a/packages/ext-tasks/src/core/v1/index.ts b/packages/ext-tasks/src/core/v1/index.ts index 511772d..392cebb 100644 --- a/packages/ext-tasks/src/core/v1/index.ts +++ b/packages/ext-tasks/src/core/v1/index.ts @@ -23,7 +23,9 @@ export type TaskSupportV1 = "forbidden" | "optional" | "required"; export type TaskEligibleMethodV1 = "tools/call"; export type JsonRpcRequestIdV1 = string | number; -export interface TaskMetadataV1 { readonly ttl?: number } +export interface TaskMetadataV1 { + readonly ttl?: number; +} export interface TaskV1 { readonly taskId: string; readonly status: TaskStatusV1; @@ -40,13 +42,19 @@ export interface CreateTaskResultV1 { readonly _meta?: Readonly>; } -export interface ToolExecutionV1 { readonly taskSupport?: TaskSupportV1 } +export interface ToolExecutionV1 { + readonly taskSupport?: TaskSupportV1; +} export interface ToolV1 { readonly name: string; readonly title?: string; readonly description?: string; - readonly inputSchema: Readonly> & { readonly type: "object" }; - readonly outputSchema?: Readonly> & { readonly type: "object" }; + readonly inputSchema: Readonly> & { + readonly type: "object"; + }; + readonly outputSchema?: Readonly> & { + readonly type: "object"; + }; readonly execution?: ToolExecutionV1; readonly annotations?: Readonly>; readonly icons?: readonly Readonly>[]; @@ -54,15 +62,37 @@ export interface ToolV1 { } export type ContentBlockV1 = - | { readonly type: "text"; readonly text: string; readonly [key: string]: JsonValue } - | { readonly type: "image" | "audio"; readonly data: string; readonly mimeType: string; readonly [key: string]: JsonValue } - | { readonly type: "resource_link"; readonly name: string; readonly uri: string; readonly [key: string]: JsonValue } - | { readonly type: "resource"; readonly resource: Readonly>; readonly [key: string]: JsonValue }; + | { + readonly type: "text"; + readonly text: string; + readonly [key: string]: JsonValue; + } + | { + readonly type: "image" | "audio"; + readonly data: string; + readonly mimeType: string; + readonly [key: string]: JsonValue; + } + | { + readonly type: "resource_link"; + readonly name: string; + readonly uri: string; + readonly [key: string]: JsonValue; + } + | { + readonly type: "resource"; + readonly resource: Readonly>; + readonly [key: string]: JsonValue; + }; export interface CallToolRequestV1 { readonly jsonrpc: "2.0"; readonly id: JsonRpcRequestIdV1; readonly method: "tools/call"; - readonly params: { readonly name: string; readonly arguments?: Readonly>; readonly task?: TaskMetadataV1 }; + readonly params: { + readonly name: string; + readonly arguments?: Readonly>; + readonly task?: TaskMetadataV1; + }; } export interface CallToolResultV1 { @@ -75,9 +105,13 @@ export interface CallToolResultV1 { export interface ServerTaskCapabilitiesV1 { readonly list?: Readonly>; readonly cancel?: Readonly>; - readonly requests?: { readonly tools?: { readonly call?: Readonly> } }; + readonly requests?: { + readonly tools?: { readonly call?: Readonly> }; + }; +} +export interface ServerCapabilitiesV1 { + readonly tasks?: ServerTaskCapabilitiesV1; } -export interface ServerCapabilitiesV1 { readonly tasks?: ServerTaskCapabilitiesV1 } interface JsonRpcRequestV1 { readonly jsonrpc: "2.0"; @@ -85,9 +119,17 @@ interface JsonRpcRequestV1 { readonly method: M; readonly params: P; } -export type GetTaskRequestV1 = JsonRpcRequestV1<"tasks/get", { readonly taskId: string }>; -export type GetTaskResultV1 = TaskV1 & { readonly _meta?: Readonly> }; -export type GetTaskResultRequestV1 = JsonRpcRequestV1<"tasks/result", { readonly taskId: string }>; +export type GetTaskRequestV1 = JsonRpcRequestV1< + "tasks/get", + { readonly taskId: string } +>; +export type GetTaskResultV1 = TaskV1 & { + readonly _meta?: Readonly>; +}; +export type GetTaskResultRequestV1 = JsonRpcRequestV1< + "tasks/result", + { readonly taskId: string } +>; export type TaskResultV1 = Readonly>; export interface ListTasksRequestV1 { readonly jsonrpc: "2.0"; @@ -100,12 +142,19 @@ export interface ListTasksResultV1 { readonly nextCursor?: string; readonly _meta?: Readonly>; } -export type CancelTaskRequestV1 = JsonRpcRequestV1<"tasks/cancel", { readonly taskId: string }>; -export type CancelTaskResultV1 = TaskV1 & { readonly _meta?: Readonly> }; +export type CancelTaskRequestV1 = JsonRpcRequestV1< + "tasks/cancel", + { readonly taskId: string } +>; +export type CancelTaskResultV1 = TaskV1 & { + readonly _meta?: Readonly>; +}; export interface TaskStatusNotificationV1 { readonly jsonrpc: "2.0"; readonly method: "notifications/tasks/status"; - readonly params: TaskV1 & { readonly _meta?: Readonly> }; + readonly params: TaskV1 & { + readonly _meta?: Readonly>; + }; } export interface CallToolAsTaskRequestV1 { @@ -117,152 +166,451 @@ export interface CallToolAsTaskRequestV1 { }; } -function at(path: DecodePath, key: string | number): DecodePath { return [...path, key] } -function optionalBoolean(record: Record, key: string, path: DecodePath): boolean | undefined { +function at(path: DecodePath, key: string | number): DecodePath { + return [...path, key]; +} +function optionalBoolean( + record: Record, + key: string, + path: DecodePath, +): boolean | undefined { const value = record[key]; if (value === undefined) return undefined; - if (typeof value !== "boolean") throw new ProtocolDecodeError("expected boolean", at(path, key)); + if (typeof value !== "boolean") + throw new ProtocolDecodeError("expected boolean", at(path, key)); return value; } -function jsonRecord(value: JsonValue | undefined, path: DecodePath): Readonly> { - if (value === undefined) throw new ProtocolDecodeError("expected object", path); +function jsonRecord( + value: JsonValue | undefined, + path: DecodePath, +): Readonly> { + if (value === undefined) + throw new ProtocolDecodeError("expected object", path); return expectRecord(value, path); } -function optionalJsonRecord(record: Record, key: string, path: DecodePath) { - return record[key] === undefined ? undefined : jsonRecord(record[key], at(path, key)); +function optionalJsonRecord( + record: Record, + key: string, + path: DecodePath, +) { + return record[key] === undefined + ? undefined + : jsonRecord(record[key], at(path, key)); } -function literal(record: Record, key: string, expected: string, path: DecodePath): void { - if (record[key] !== expected) throw new ProtocolDecodeError(`expected ${expected}`, at(path, key)); +function literal( + record: Record, + key: string, + expected: string, + path: DecodePath, +): void { + if (record[key] !== expected) + throw new ProtocolDecodeError(`expected ${expected}`, at(path, key)); } -function decodeId(value: JsonValue | undefined, path: DecodePath): JsonRpcRequestIdV1 { - if (typeof value !== "string" && typeof value !== "number") throw new ProtocolDecodeError("expected request id", path); +function decodeId( + value: JsonValue | undefined, + path: DecodePath, +): JsonRpcRequestIdV1 { + if (typeof value !== "string" && typeof value !== "number") + throw new ProtocolDecodeError("expected request id", path); return value; } function expectInteger(value: JsonValue | undefined, path: DecodePath): number { const number = expectNumber(value, path); - if (!Number.isInteger(number)) throw new ProtocolDecodeError("expected integer", path); + if (!Number.isInteger(number)) + throw new ProtocolDecodeError("expected integer", path); return number; } -function decodeContentBlock(value: JsonValue, path: DecodePath): ContentBlockV1 { +function decodeContentBlock( + value: JsonValue, + path: DecodePath, +): ContentBlockV1 { const record = expectRecord(value, path); - const type = expectEnum(record.type, ["text", "image", "audio", "resource_link", "resource"] as const, at(path, "type")); - if (type === "text") { expectString(record.text, at(path, "text")); } - else if (type === "image" || type === "audio") { expectString(record.data, at(path, "data")); expectString(record.mimeType, at(path, "mimeType")); } - else if (type === "resource_link") { expectString(record.name, at(path, "name")); expectString(record.uri, at(path, "uri")); } - else { jsonRecord(record.resource, at(path, "resource")); } + const type = expectEnum( + record.type, + ["text", "image", "audio", "resource_link", "resource"] as const, + at(path, "type"), + ); + if (type === "text") { + expectString(record.text, at(path, "text")); + } else if (type === "image" || type === "audio") { + expectString(record.data, at(path, "data")); + expectString(record.mimeType, at(path, "mimeType")); + } else if (type === "resource_link") { + expectString(record.name, at(path, "name")); + expectString(record.uri, at(path, "uri")); + } else { + jsonRecord(record.resource, at(path, "resource")); + } return record as ContentBlockV1; } function decodeTask(value: JsonValue, path: DecodePath): TaskV1 { const record = expectRecord(value, path); const ttlValue = record.ttl; - const ttl = ttlValue === null ? null : expectInteger(ttlValue, at(path, "ttl")); + const ttl = + ttlValue === null ? null : expectInteger(ttlValue, at(path, "ttl")); return { taskId: expectString(record.taskId, at(path, "taskId")), status: expectEnum(record.status, TaskStatusesV1, at(path, "status")), - ...(record.statusMessage === undefined ? {} : { statusMessage: expectString(record.statusMessage, at(path, "statusMessage")) }), + ...(record.statusMessage === undefined + ? {} + : { + statusMessage: expectString( + record.statusMessage, + at(path, "statusMessage"), + ), + }), createdAt: expectString(record.createdAt, at(path, "createdAt")), - lastUpdatedAt: expectString(record.lastUpdatedAt, at(path, "lastUpdatedAt")), + lastUpdatedAt: expectString( + record.lastUpdatedAt, + at(path, "lastUpdatedAt"), + ), ttl, - ...(record.pollInterval === undefined ? {} : { pollInterval: expectInteger(record.pollInterval, at(path, "pollInterval")) }), + ...(record.pollInterval === undefined + ? {} + : { + pollInterval: expectInteger( + record.pollInterval, + at(path, "pollInterval"), + ), + }), }; } -function decodeTaskRequest(value: JsonValue, path: DecodePath, method: M): JsonRpcRequestV1 { +function decodeTaskRequest< + M extends "tasks/get" | "tasks/result" | "tasks/cancel", +>( + value: JsonValue, + path: DecodePath, + method: M, +): JsonRpcRequestV1 { const record = expectRecord(value, path); - literal(record, "jsonrpc", "2.0", path); literal(record, "method", method, path); + literal(record, "jsonrpc", "2.0", path); + literal(record, "method", method, path); const params = jsonRecord(record.params, at(path, "params")); - return { jsonrpc: "2.0", id: decodeId(record.id, at(path, "id")), method, params: { taskId: expectString(params.taskId, at(at(path, "params"), "taskId")) } }; -} - -export const ToolV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - const inputSchema = jsonRecord(record.inputSchema, at(path, "inputSchema")); - literal(inputSchema as Record, "type", "object", at(path, "inputSchema")); - let outputSchema: ToolV1["outputSchema"]; - if (record.outputSchema !== undefined) { - const decoded = jsonRecord(record.outputSchema, at(path, "outputSchema")); - literal(decoded as Record, "type", "object", at(path, "outputSchema")); - outputSchema = decoded as ToolV1["outputSchema"]; - } - let execution: ToolExecutionV1 | undefined; - if (record.execution !== undefined) { - const decoded = jsonRecord(record.execution, at(path, "execution")); - execution = decoded.taskSupport === undefined ? {} : { taskSupport: expectEnum(decoded.taskSupport, ["forbidden", "optional", "required"] as const, at(at(path, "execution"), "taskSupport")) }; - } - let icons: ToolV1["icons"]; - if (record.icons !== undefined) { - if (!Array.isArray(record.icons)) throw new ProtocolDecodeError("expected array", at(path, "icons")); - icons = record.icons.map((icon, index) => jsonRecord(icon, at(at(path, "icons"), index))); - } return { - name: expectString(record.name, at(path, "name")), - inputSchema: inputSchema as ToolV1["inputSchema"], - ...(record.title === undefined ? {} : { title: expectString(record.title, at(path, "title")) }), - ...(record.description === undefined ? {} : { description: expectString(record.description, at(path, "description")) }), - ...(outputSchema === undefined ? {} : { outputSchema }), - ...(execution === undefined ? {} : { execution }), - ...(record.annotations === undefined ? {} : { annotations: jsonRecord(record.annotations, at(path, "annotations")) }), - ...(icons === undefined ? {} : { icons }), - ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), + jsonrpc: "2.0", + id: decodeId(record.id, at(path, "id")), + method, + params: { + taskId: expectString(params.taskId, at(at(path, "params"), "taskId")), + }, }; -}); -export const ServerTaskCapabilitiesV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - const list = optionalJsonRecord(record, "list", path); const cancel = optionalJsonRecord(record, "cancel", path); - let requests: ServerTaskCapabilitiesV1["requests"]; - if (record.requests !== undefined) { const r = jsonRecord(record.requests, at(path, "requests")); let tools: NonNullable["tools"]; if (r.tools !== undefined) { const t = jsonRecord(r.tools, at(at(path, "requests"), "tools")); tools = t.call === undefined ? {} : { call: jsonRecord(t.call, at(at(at(path, "requests"), "tools"), "call")) }; } requests = tools === undefined ? {} : { tools }; } - return { ...(list === undefined ? {} : { list }), ...(cancel === undefined ? {} : { cancel }), ...(requests === undefined ? {} : { requests }) }; -}); -export const CallToolRequestV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); literal(record, "jsonrpc", "2.0", path); literal(record, "method", "tools/call", path); const params = jsonRecord(record.params, at(path, "params")); - return { jsonrpc: "2.0", id: decodeId(record.id, at(path, "id")), method: "tools/call", params: { name: expectString(params.name, at(at(path, "params"), "name")), ...(params.arguments === undefined ? {} : { arguments: jsonRecord(params.arguments, at(at(path, "params"), "arguments")) }), ...(params.task === undefined ? {} : { task: (() => { const t = jsonRecord(params.task, at(at(path, "params"), "task")); return t.ttl === undefined ? {} : { ttl: expectInteger(t.ttl, at(at(at(path, "params"), "task"), "ttl")) }; })() }) } }; -}); -export const TaskStatusV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => expectEnum(value, TaskStatusesV1, path)); +} + +export const ToolV1Codec: RuntimeCodec = createRuntimeCodec( + (value, path) => { + const record = expectRecord(value, path); + const inputSchema = jsonRecord(record.inputSchema, at(path, "inputSchema")); + literal( + inputSchema as Record, + "type", + "object", + at(path, "inputSchema"), + ); + let outputSchema: ToolV1["outputSchema"]; + if (record.outputSchema !== undefined) { + const decoded = jsonRecord(record.outputSchema, at(path, "outputSchema")); + literal( + decoded as Record, + "type", + "object", + at(path, "outputSchema"), + ); + outputSchema = decoded as ToolV1["outputSchema"]; + } + let execution: ToolExecutionV1 | undefined; + if (record.execution !== undefined) { + const decoded = jsonRecord(record.execution, at(path, "execution")); + execution = + decoded.taskSupport === undefined + ? {} + : { + taskSupport: expectEnum( + decoded.taskSupport, + ["forbidden", "optional", "required"] as const, + at(at(path, "execution"), "taskSupport"), + ), + }; + } + let icons: ToolV1["icons"]; + if (record.icons !== undefined) { + if (!Array.isArray(record.icons)) + throw new ProtocolDecodeError("expected array", at(path, "icons")); + icons = record.icons.map((icon, index) => + jsonRecord(icon, at(at(path, "icons"), index)), + ); + } + return { + name: expectString(record.name, at(path, "name")), + inputSchema: inputSchema as ToolV1["inputSchema"], + ...(record.title === undefined + ? {} + : { title: expectString(record.title, at(path, "title")) }), + ...(record.description === undefined + ? {} + : { + description: expectString( + record.description, + at(path, "description"), + ), + }), + ...(outputSchema === undefined ? {} : { outputSchema }), + ...(execution === undefined ? {} : { execution }), + ...(record.annotations === undefined + ? {} + : { + annotations: jsonRecord( + record.annotations, + at(path, "annotations"), + ), + }), + ...(icons === undefined ? {} : { icons }), + ...(record._meta === undefined + ? {} + : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), + }; + }, +); +export const ServerTaskCapabilitiesV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + const list = optionalJsonRecord(record, "list", path); + const cancel = optionalJsonRecord(record, "cancel", path); + let requests: ServerTaskCapabilitiesV1["requests"]; + if (record.requests !== undefined) { + const r = jsonRecord(record.requests, at(path, "requests")); + let tools: NonNullable["tools"]; + if (r.tools !== undefined) { + const t = jsonRecord(r.tools, at(at(path, "requests"), "tools")); + tools = + t.call === undefined + ? {} + : { + call: jsonRecord( + t.call, + at(at(at(path, "requests"), "tools"), "call"), + ), + }; + } + requests = tools === undefined ? {} : { tools }; + } + return { + ...(list === undefined ? {} : { list }), + ...(cancel === undefined ? {} : { cancel }), + ...(requests === undefined ? {} : { requests }), + }; + }); +export const CallToolRequestV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + literal(record, "jsonrpc", "2.0", path); + literal(record, "method", "tools/call", path); + const params = jsonRecord(record.params, at(path, "params")); + return { + jsonrpc: "2.0", + id: decodeId(record.id, at(path, "id")), + method: "tools/call", + params: { + name: expectString(params.name, at(at(path, "params"), "name")), + ...(params.arguments === undefined + ? {} + : { + arguments: jsonRecord( + params.arguments, + at(at(path, "params"), "arguments"), + ), + }), + ...(params.task === undefined + ? {} + : { + task: (() => { + const t = jsonRecord( + params.task, + at(at(path, "params"), "task"), + ); + return t.ttl === undefined + ? {} + : { + ttl: expectInteger( + t.ttl, + at(at(at(path, "params"), "task"), "ttl"), + ), + }; + })(), + }), + }, + }; + }); +export const TaskStatusV1Codec: RuntimeCodec = createRuntimeCodec( + (value, path) => expectEnum(value, TaskStatusesV1, path), +); export const TaskV1Codec: RuntimeCodec = createRuntimeCodec(decodeTask); -export const CreateTaskResultV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - return { task: decodeTask(record.task as JsonValue, at(path, "task")), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(path, "_meta")) }) }; -}); -export const CallToolResultV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - if (!Array.isArray(record.content)) throw new ProtocolDecodeError("expected array", at(path, "content")); - record.content.forEach((item, index) => decodeContentBlock(item, at(at(path, "content"), index))); - if (record.structuredContent !== undefined) jsonRecord(record.structuredContent, at(path, "structuredContent")); - if (record.isError !== undefined) optionalBoolean(record, "isError", path); - if (record._meta !== undefined) jsonRecord(record._meta, at(path, "_meta")); - return record as unknown as CallToolResultV1; -}); -export const GetTaskRequestV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => decodeTaskRequest(v, p, "tasks/get")); -export const GetTaskResultV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => { const record = expectRecord(v, p); return { ...decodeTask(v, p), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(p, "_meta")) }) }; }); -export const GetTaskResultRequestV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => decodeTaskRequest(v, p, "tasks/result")); -export const TaskResultV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => expectRecord(v, p)); -export const CancelTaskRequestV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => decodeTaskRequest(v, p, "tasks/cancel")); -export const CancelTaskResultV1Codec: RuntimeCodec = createRuntimeCodec((v, p) => { const record = expectRecord(v, p); return { ...decodeTask(v, p), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(p, "_meta")) }) }; }); -export const ListTasksRequestV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); literal(record, "jsonrpc", "2.0", path); literal(record, "method", "tasks/list", path); - const result: ListTasksRequestV1 = { jsonrpc: "2.0", id: decodeId(record.id, at(path, "id")), method: "tasks/list" }; - if (record.params === undefined) return result; - const params = jsonRecord(record.params, at(path, "params")); - return { ...result, params: { ...(params.cursor === undefined ? {} : { cursor: expectString(params.cursor, at(at(path, "params"), "cursor")) }) } }; -}); -export const ListTasksResultV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - if (!Array.isArray(record.tasks)) throw new ProtocolDecodeError("expected array", at(path, "tasks")); - return { tasks: record.tasks.map((task, index) => decodeTask(task, at(at(path, "tasks"), index))), ...(record.nextCursor === undefined ? {} : { nextCursor: expectString(record.nextCursor, at(path, "nextCursor")) }), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(path, "_meta")) }) }; -}); -export const TaskStatusNotificationV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); literal(record, "jsonrpc", "2.0", path); literal(record, "method", "notifications/tasks/status", path); - const paramsRecord = jsonRecord(record.params, at(path, "params")); - return { jsonrpc: "2.0", method: "notifications/tasks/status", params: { ...decodeTask(record.params as JsonValue, at(path, "params")), ...(paramsRecord._meta === undefined ? {} : { _meta: jsonRecord(paramsRecord._meta, at(at(path, "params"), "_meta")) }) } }; -}); +export const CreateTaskResultV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + return { + task: decodeTask(record.task as JsonValue, at(path, "task")), + ...(record._meta === undefined + ? {} + : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), + }; + }); +export const CallToolResultV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + if (!Array.isArray(record.content)) + throw new ProtocolDecodeError("expected array", at(path, "content")); + record.content.forEach((item, index) => + decodeContentBlock(item, at(at(path, "content"), index)), + ); + if (record.structuredContent !== undefined) + jsonRecord(record.structuredContent, at(path, "structuredContent")); + if (record.isError !== undefined) optionalBoolean(record, "isError", path); + if (record._meta !== undefined) jsonRecord(record._meta, at(path, "_meta")); + return record as unknown as CallToolResultV1; + }); +export const GetTaskRequestV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => + decodeTaskRequest(v, p, "tasks/get"), + ); +export const GetTaskResultV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => { + const record = expectRecord(v, p); + return { + ...decodeTask(v, p), + ...(record._meta === undefined + ? {} + : { _meta: jsonRecord(record._meta, at(p, "_meta")) }), + }; + }); +export const GetTaskResultRequestV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => + decodeTaskRequest(v, p, "tasks/result"), + ); +export const TaskResultV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => expectRecord(v, p)); +export const CancelTaskRequestV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => + decodeTaskRequest(v, p, "tasks/cancel"), + ); +export const CancelTaskResultV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => { + const record = expectRecord(v, p); + return { + ...decodeTask(v, p), + ...(record._meta === undefined + ? {} + : { _meta: jsonRecord(record._meta, at(p, "_meta")) }), + }; + }); +export const ListTasksRequestV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + literal(record, "jsonrpc", "2.0", path); + literal(record, "method", "tasks/list", path); + const result: ListTasksRequestV1 = { + jsonrpc: "2.0", + id: decodeId(record.id, at(path, "id")), + method: "tasks/list", + }; + if (record.params === undefined) return result; + const params = jsonRecord(record.params, at(path, "params")); + return { + ...result, + params: { + ...(params.cursor === undefined + ? {} + : { + cursor: expectString( + params.cursor, + at(at(path, "params"), "cursor"), + ), + }), + }, + }; + }); +export const ListTasksResultV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + if (!Array.isArray(record.tasks)) + throw new ProtocolDecodeError("expected array", at(path, "tasks")); + return { + tasks: record.tasks.map((task, index) => + decodeTask(task, at(at(path, "tasks"), index)), + ), + ...(record.nextCursor === undefined + ? {} + : { + nextCursor: expectString(record.nextCursor, at(path, "nextCursor")), + }), + ...(record._meta === undefined + ? {} + : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), + }; + }); +export const TaskStatusNotificationV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + literal(record, "jsonrpc", "2.0", path); + literal(record, "method", "notifications/tasks/status", path); + const paramsRecord = jsonRecord(record.params, at(path, "params")); + return { + jsonrpc: "2.0", + method: "notifications/tasks/status", + params: { + ...decodeTask(record.params as JsonValue, at(path, "params")), + ...(paramsRecord._meta === undefined + ? {} + : { + _meta: jsonRecord( + paramsRecord._meta, + at(at(path, "params"), "_meta"), + ), + }), + }, + }; + }); -export function hasTaskListCapabilityV1(capabilities: ServerTaskCapabilitiesV1): boolean { return capabilities.list !== undefined } -export function hasTaskCancelCapabilityV1(capabilities: ServerTaskCapabilitiesV1): boolean { return capabilities.cancel !== undefined } -export function hasTaskToolCallCapabilityV1(capabilities: ServerTaskCapabilitiesV1): boolean { return capabilities.requests?.tools?.call !== undefined } -export function isTaskEligibleMethodV1(method: string): method is TaskEligibleMethodV1 { return method === "tools/call" } -export function shouldCallToolAsTaskV1(capabilities: ServerTaskCapabilitiesV1, tool: ToolV1, preferTask = false): boolean { +export function hasTaskListCapabilityV1( + capabilities: ServerTaskCapabilitiesV1, +): boolean { + return capabilities.list !== undefined; +} +export function hasTaskCancelCapabilityV1( + capabilities: ServerTaskCapabilitiesV1, +): boolean { + return capabilities.cancel !== undefined; +} +export function hasTaskToolCallCapabilityV1( + capabilities: ServerTaskCapabilitiesV1, +): boolean { + return capabilities.requests?.tools?.call !== undefined; +} +export function isTaskEligibleMethodV1( + method: string, +): method is TaskEligibleMethodV1 { + return method === "tools/call"; +} +export function shouldCallToolAsTaskV1( + capabilities: ServerTaskCapabilitiesV1, + tool: ToolV1, + preferTask = false, +): boolean { if (!hasTaskToolCallCapabilityV1(capabilities)) return false; - return tool.execution?.taskSupport === "required" || (tool.execution?.taskSupport === "optional" && preferTask); + return ( + tool.execution?.taskSupport === "required" || + (tool.execution?.taskSupport === "optional" && preferTask) + ); } -export function callToolAsTaskV1(name: string, arguments_?: Readonly>): CallToolAsTaskRequestV1 { - return { method: "tools/call", params: { name, ...(arguments_ === undefined ? {} : { arguments: arguments_ }), task: {} } }; +export function callToolAsTaskV1( + name: string, + arguments_?: Readonly>, +): CallToolAsTaskRequestV1 { + return { + method: "tools/call", + params: { + name, + ...(arguments_ === undefined ? {} : { arguments: arguments_ }), + task: {}, + }, + }; } diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts index bdcba22..34e1bb7 100644 --- a/packages/ext-tasks/src/core/v2/index.test.ts +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -4,17 +4,37 @@ import { describe, expect, it } from "vitest"; import type { JsonValue } from "../index.js"; import { - CallToolResultV2Codec, CancelTaskRequestV2Codec, CancelTaskResultV2Codec, - CreateTaskResultV2Codec, DetailedTaskV2Codec, ErrorV2Codec, GetTaskRequestV2Codec, - GetTaskResultV2Codec, InputRequestsV2Codec, InputResponsesV2Codec, - TaskStatusNotificationV2Codec, TaskV2Codec, ToolV2Codec, UpdateTaskRequestV2Codec, + CallToolResultV2Codec, + CancelTaskRequestV2Codec, + CancelTaskResultV2Codec, + CreateTaskResultV2Codec, + DetailedTaskV2Codec, + ErrorV2Codec, + GetTaskRequestV2Codec, + GetTaskResultV2Codec, + InputRequestsV2Codec, + InputResponsesV2Codec, + TaskStatusNotificationV2Codec, + TaskV2Codec, + ToolV2Codec, + UpdateTaskRequestV2Codec, UpdateTaskResultV2Codec, - contributeTaskFilterV2, hasTaskClientCapabilityV2, hasTaskServerCapabilityV2, - isEligibleTaskResultV2, readAcceptedTaskIdsV2, withTaskCapabilityV2, + contributeTaskFilterV2, + hasTaskClientCapabilityV2, + hasTaskServerCapabilityV2, + isEligibleTaskResultV2, + readAcceptedTaskIdsV2, + withTaskCapabilityV2, type TaskStatusV2, } from "./index.js"; -const statuses: readonly TaskStatusV2[] = ["working", "input_required", "completed", "failed", "cancelled"]; +const statuses: readonly TaskStatusV2[] = [ + "working", + "input_required", + "completed", + "failed", + "cancelled", +]; const baseTask = fc.record({ taskId: fc.string(), status: fc.constantFrom(...statuses), @@ -24,176 +44,560 @@ const baseTask = fc.record({ ttlMs: fc.oneof(fc.integer(), fc.constant(null)), pollIntervalMs: fc.option(fc.integer(), { nil: undefined }), }); -const taskFor = (status: TaskStatusV2) => baseTask.map((task) => ({ ...task, status })); -const asJson = (value: unknown): JsonValue => JSON.parse(JSON.stringify(value)) as JsonValue; +const taskFor = (status: TaskStatusV2) => + baseTask.map((task) => ({ ...task, status })); +const asJson = (value: unknown): JsonValue => + JSON.parse(JSON.stringify(value)) as JsonValue; describe("V2 generated wire contracts", () => { it("accepts every valid base Task and rejects missing required fields, invalid integers, and statuses", () => { - fc.assert(fc.property(baseTask, (task) => { expect(TaskV2Codec.parse(asJson(task)).success).toBe(true); })); - fc.assert(fc.property(baseTask, fc.constantFrom("taskId", "status", "createdAt", "lastUpdatedAt", "ttlMs"), (task, key) => { - const invalid = { ...task }; delete invalid[key]; - expect(TaskV2Codec.parse(asJson(invalid)).success).toBe(false); - })); - fc.assert(fc.property(baseTask, fc.string().filter((status) => !statuses.includes(status as TaskStatusV2)), (task, status) => { - expect(TaskV2Codec.parse(asJson({ ...task, status })).success).toBe(false); - })); - fc.assert(fc.property(baseTask, fc.double({ noNaN: true, noDefaultInfinity: true }).filter((n) => !Number.isInteger(n)), (task, ttlMs) => { - expect(TaskV2Codec.parse(asJson({ ...task, ttlMs })).success).toBe(false); - })); + fc.assert( + fc.property(baseTask, (task) => { + expect(TaskV2Codec.parse(asJson(task)).success).toBe(true); + }), + ); + fc.assert( + fc.property( + baseTask, + fc.constantFrom( + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + ), + (task, key) => { + const invalid = { ...task }; + delete invalid[key]; + expect(TaskV2Codec.parse(asJson(invalid)).success).toBe(false); + }, + ), + ); + fc.assert( + fc.property( + baseTask, + fc + .string() + .filter((status) => !statuses.includes(status as TaskStatusV2)), + (task, status) => { + expect(TaskV2Codec.parse(asJson({ ...task, status })).success).toBe( + false, + ); + }, + ), + ); + fc.assert( + fc.property( + baseTask, + fc + .double({ noNaN: true, noDefaultInfinity: true }) + .filter((n) => !Number.isInteger(n)), + (task, ttlMs) => { + expect(TaskV2Codec.parse(asJson({ ...task, ttlMs })).success).toBe( + false, + ); + }, + ), + ); }); it("enforces status-owned DetailedTask payloads", () => { - fc.assert(fc.property(taskFor("working"), (task) => { expect(DetailedTaskV2Codec.parse(asJson(task)).success).toBe(true); })); - fc.assert(fc.property(taskFor("cancelled"), (task) => { expect(DetailedTaskV2Codec.parse(asJson(task)).success).toBe(true); })); - fc.assert(fc.property(taskFor("input_required"), fc.dictionary(fc.string(), fc.constant({ method: "roots/list" as const })), (task, inputRequests) => { - expect(DetailedTaskV2Codec.parse(asJson({ ...task, inputRequests })).success).toBe(true); - })); - fc.assert(fc.property(taskFor("completed"), fc.dictionary(fc.string(), fc.jsonValue()), (task, result) => { - expect(DetailedTaskV2Codec.parse(asJson({ ...task, result })).success).toBe(true); - })); - fc.assert(fc.property(taskFor("failed"), fc.integer(), fc.string(), (task, code, message) => { - expect(DetailedTaskV2Codec.parse(asJson({ ...task, error: { code, message } })).success).toBe(true); - })); - fc.assert(fc.property(fc.constantFrom("input_required", "completed", "failed"), (status) => { - expect(DetailedTaskV2Codec.parse({ taskId: "id", status, createdAt: "a", lastUpdatedAt: "b", ttlMs: null }).success).toBe(false); - })); + fc.assert( + fc.property(taskFor("working"), (task) => { + expect(DetailedTaskV2Codec.parse(asJson(task)).success).toBe(true); + }), + ); + fc.assert( + fc.property(taskFor("cancelled"), (task) => { + expect(DetailedTaskV2Codec.parse(asJson(task)).success).toBe(true); + }), + ); + fc.assert( + fc.property( + taskFor("input_required"), + fc.dictionary( + fc.string(), + fc.constant({ method: "roots/list" as const }), + ), + (task, inputRequests) => { + expect( + DetailedTaskV2Codec.parse(asJson({ ...task, inputRequests })) + .success, + ).toBe(true); + }, + ), + ); + fc.assert( + fc.property( + taskFor("completed"), + fc.dictionary(fc.string(), fc.jsonValue()), + (task, result) => { + expect( + DetailedTaskV2Codec.parse(asJson({ ...task, result })).success, + ).toBe(true); + }, + ), + ); + fc.assert( + fc.property( + taskFor("failed"), + fc.integer(), + fc.string(), + (task, code, message) => { + expect( + DetailedTaskV2Codec.parse( + asJson({ ...task, error: { code, message } }), + ).success, + ).toBe(true); + }, + ), + ); + fc.assert( + fc.property( + fc.constantFrom("input_required", "completed", "failed"), + (status) => { + expect( + DetailedTaskV2Codec.parse({ + taskId: "id", + status, + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + }).success, + ).toBe(false); + }, + ), + ); }); it("strictly decodes input request and response maps", () => { - fc.assert(fc.property(fc.dictionary(fc.string(), fc.oneof( - fc.record({ method: fc.constant("roots/list" as const) }), - fc.record({ method: fc.constant("sampling/createMessage" as const), params: fc.dictionary(fc.string(), fc.jsonValue()) }), - fc.record({ method: fc.constant("elicitation/create" as const), params: fc.dictionary(fc.string(), fc.jsonValue()) }), - )), (requests) => { expect(InputRequestsV2Codec.parse(asJson(requests)).success).toBe(true); })); - expect(InputRequestsV2Codec.parse({ key: { method: "unknown", params: {} } }).success).toBe(false); - fc.assert(fc.property(fc.dictionary(fc.string(), fc.oneof( - fc.record({ action: fc.constantFrom("accept" as const, "decline" as const, "cancel" as const) }), - fc.record({ roots: fc.array(fc.jsonValue()) }), - fc.record({ content: fc.jsonValue(), model: fc.string(), role: fc.constantFrom("user" as const, "assistant" as const) }), - )), (responses) => { expect(InputResponsesV2Codec.parse(asJson(responses)).success).toBe(true); })); + fc.assert( + fc.property( + fc.dictionary( + fc.string(), + fc.oneof( + fc.record({ method: fc.constant("roots/list" as const) }), + fc.record({ + method: fc.constant("sampling/createMessage" as const), + params: fc.dictionary(fc.string(), fc.jsonValue()), + }), + fc.record({ + method: fc.constant("elicitation/create" as const), + params: fc.dictionary(fc.string(), fc.jsonValue()), + }), + ), + ), + (requests) => { + expect(InputRequestsV2Codec.parse(asJson(requests)).success).toBe( + true, + ); + }, + ), + ); + expect( + InputRequestsV2Codec.parse({ key: { method: "unknown", params: {} } }) + .success, + ).toBe(false); + fc.assert( + fc.property( + fc.dictionary( + fc.string(), + fc.oneof( + fc.record({ + action: fc.constantFrom( + "accept" as const, + "decline" as const, + "cancel" as const, + ), + }), + fc.record({ roots: fc.array(fc.jsonValue()) }), + fc.record({ + content: fc.jsonValue(), + model: fc.string(), + role: fc.constantFrom("user" as const, "assistant" as const), + }), + ), + ), + (responses) => { + expect(InputResponsesV2Codec.parse(asJson(responses)).success).toBe( + true, + ); + }, + ), + ); expect(InputResponsesV2Codec.parse({ key: {} }).success).toBe(false); }); it("decodes complete JSON-RPC errors", () => { - fc.assert(fc.property(fc.integer(), fc.string(), fc.option(fc.jsonValue(), { nil: undefined }), (code, message, data) => { - expect(ErrorV2Codec.parse(asJson({ code, message, ...(data === undefined ? {} : { data }) })).success).toBe(true); - })); + fc.assert( + fc.property( + fc.integer(), + fc.string(), + fc.option(fc.jsonValue(), { nil: undefined }), + (code, message, data) => { + expect( + ErrorV2Codec.parse( + asJson({ + code, + message, + ...(data === undefined ? {} : { data }), + }), + ).success, + ).toBe(true); + }, + ), + ); expect(ErrorV2Codec.parse({ code: 1 }).success).toBe(false); - expect(ErrorV2Codec.parse({ code: 1.5, message: "bad" }).success).toBe(false); + expect(ErrorV2Codec.parse({ code: 1.5, message: "bad" }).success).toBe( + false, + ); }); it("round-trips open ToolV2 objects while validating every declared field", () => { - fc.assert(fc.property( - fc.string(), - fc.dictionary(fc.string(), fc.jsonValue()), - fc.dictionary(fc.string(), fc.jsonValue()), - fc.dictionary(fc.string(), fc.jsonValue()), - (name, rootExtra, inputExtra, outputExtra) => { - const tool = asJson({ - ...rootExtra, - name, - title: "Display name", - description: "Description", - inputSchema: { ...inputExtra, type: "object", $schema: "https://json-schema.org/draft/2020-12/schema" }, - outputSchema: { ...outputExtra, $schema: "https://json-schema.org/draft/2020-12/schema" }, - annotations: { title: "Annotated", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, extension: 1 }, - icons: [{ src: "https://example.test/icon.png", mimeType: "image/png", sizes: ["16x16", "32x32"], theme: "dark", extension: true }], - _meta: { trace: "test" }, - }); - const parsed = ToolV2Codec.parse(tool); - expect(parsed.success).toBe(true); - if (parsed.success) expect(parsed.value).toEqual(tool); - }, - )); - expect(ToolV2Codec.parse({ name: "x", inputSchema: {} }).success).toBe(false); - expect(ToolV2Codec.parse({ name: "x", inputSchema: { type: "array" } }).success).toBe(false); - for (const [field, invalid] of [["outputSchema", true], ["annotations", true], ["icons", true], ["_meta", true]] as const) { - expect(ToolV2Codec.parse({ name: "x", inputSchema: { type: "object" }, [field]: invalid }).success).toBe(false); + fc.assert( + fc.property( + fc.string(), + fc.dictionary(fc.string(), fc.jsonValue()), + fc.dictionary(fc.string(), fc.jsonValue()), + fc.dictionary(fc.string(), fc.jsonValue()), + (name, rootExtra, inputExtra, outputExtra) => { + const tool = asJson({ + ...rootExtra, + name, + title: "Display name", + description: "Description", + inputSchema: { + ...inputExtra, + type: "object", + $schema: "https://json-schema.org/draft/2020-12/schema", + }, + outputSchema: { + ...outputExtra, + $schema: "https://json-schema.org/draft/2020-12/schema", + }, + annotations: { + title: "Annotated", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + extension: 1, + }, + icons: [ + { + src: "https://example.test/icon.png", + mimeType: "image/png", + sizes: ["16x16", "32x32"], + theme: "dark", + extension: true, + }, + ], + _meta: { trace: "test" }, + }); + const parsed = ToolV2Codec.parse(tool); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.value).toEqual(tool); + }, + ), + ); + expect(ToolV2Codec.parse({ name: "x", inputSchema: {} }).success).toBe( + false, + ); + expect( + ToolV2Codec.parse({ name: "x", inputSchema: { type: "array" } }).success, + ).toBe(false); + for (const [field, invalid] of [ + ["outputSchema", true], + ["annotations", true], + ["icons", true], + ["_meta", true], + ] as const) { + expect( + ToolV2Codec.parse({ + name: "x", + inputSchema: { type: "object" }, + [field]: invalid, + }).success, + ).toBe(false); } - expect(ToolV2Codec.parse({ name: "x", inputSchema: { type: "object" }, annotations: { readOnlyHint: "yes" } }).success).toBe(false); - expect(ToolV2Codec.parse({ name: "x", inputSchema: { type: "object" }, icons: [{}] }).success).toBe(false); + expect( + ToolV2Codec.parse({ + name: "x", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: "yes" }, + }).success, + ).toBe(false); + expect( + ToolV2Codec.parse({ + name: "x", + inputSchema: { type: "object" }, + icons: [{}], + }).success, + ).toBe(false); }); it("round-trips open CallToolResultV2 objects with required string result/content discriminators", () => { const content = [ - { type: "text", text: "hello", annotations: { audience: ["user"], priority: 0.5, lastModified: "now" }, _meta: { a: 1 }, extension: true }, + { + type: "text", + text: "hello", + annotations: { audience: ["user"], priority: 0.5, lastModified: "now" }, + _meta: { a: 1 }, + extension: true, + }, { type: "image", data: "aW1hZ2U=", mimeType: "image/png", extension: 1 }, { type: "audio", data: "YXVkaW8=", mimeType: "audio/wav", extension: 2 }, - { type: "resource_link", name: "docs", uri: "https://example.test", title: "Docs", description: "d", mimeType: "text/html", size: 1, icons: [{ src: "icon.png" }], extension: 3 }, - { type: "resource", resource: { uri: "file:///x", text: "body", blob: "Ym9keQ==", mimeType: "text/plain", _meta: { r: 1 }, extension: 4 } }, + { + type: "resource_link", + name: "docs", + uri: "https://example.test", + title: "Docs", + description: "d", + mimeType: "text/html", + size: 1, + icons: [{ src: "icon.png" }], + extension: 3, + }, + { + type: "resource", + resource: { + uri: "file:///x", + text: "body", + blob: "Ym9keQ==", + mimeType: "text/plain", + _meta: { r: 1 }, + extension: 4, + }, + }, ]; - fc.assert(fc.property(fc.string(), fc.jsonValue(), fc.dictionary(fc.string(), fc.jsonValue()), (resultType, structuredContent, extra) => { - const result = asJson({ ...extra, resultType, content, structuredContent, isError: false, _meta: { trace: "test" } }); - const parsed = CallToolResultV2Codec.parse(result); - expect(parsed.success).toBe(true); - if (parsed.success) expect(parsed.value).toEqual(result); - })); - expect(CallToolResultV2Codec.parse({ resultType: "complete" }).success).toBe(false); - expect(CallToolResultV2Codec.parse({ resultType: 1, content: [] }).success).toBe(false); - expect(CallToolResultV2Codec.parse({ resultType: "complete", content: [{ type: "text" }] }).success).toBe(false); - fc.assert(fc.property(fc.string().filter((type) => !["text", "image", "audio", "resource_link", "resource"].includes(type)), (type) => { - expect(CallToolResultV2Codec.parse({ resultType: "complete", content: [{ type }] }).success).toBe(false); - })); - expect(CallToolResultV2Codec.parse({ resultType: "complete", content: [], isError: "no" }).success).toBe(false); + fc.assert( + fc.property( + fc.string(), + fc.jsonValue(), + fc.dictionary(fc.string(), fc.jsonValue()), + (resultType, structuredContent, extra) => { + const result = asJson({ + ...extra, + resultType, + content, + structuredContent, + isError: false, + _meta: { trace: "test" }, + }); + const parsed = CallToolResultV2Codec.parse(result); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.value).toEqual(result); + }, + ), + ); + expect( + CallToolResultV2Codec.parse({ resultType: "complete" }).success, + ).toBe(false); + expect( + CallToolResultV2Codec.parse({ resultType: 1, content: [] }).success, + ).toBe(false); + expect( + CallToolResultV2Codec.parse({ + resultType: "complete", + content: [{ type: "text" }], + }).success, + ).toBe(false); + fc.assert( + fc.property( + fc + .string() + .filter( + (type) => + !["text", "image", "audio", "resource_link", "resource"].includes( + type, + ), + ), + (type) => { + expect( + CallToolResultV2Codec.parse({ + resultType: "complete", + content: [{ type }], + }).success, + ).toBe(false); + }, + ), + ); + expect( + CallToolResultV2Codec.parse({ + resultType: "complete", + content: [], + isError: "no", + }).success, + ).toBe(false); }); it("binds strict get, update, and cancel request/result discriminators", () => { - fc.assert(fc.property(fc.oneof(fc.string(), fc.integer()), fc.string(), (id, taskId) => { - expect(GetTaskRequestV2Codec.parse({ jsonrpc: "2.0", id, method: "tasks/get", params: { taskId } }).success).toBe(true); - expect(CancelTaskRequestV2Codec.parse({ jsonrpc: "2.0", id, method: "tasks/cancel", params: { taskId } }).success).toBe(true); - expect(UpdateTaskRequestV2Codec.parse({ jsonrpc: "2.0", id, method: "tasks/update", params: { taskId, inputResponses: {} } }).success).toBe(true); - })); - for (const codec of [GetTaskRequestV2Codec, UpdateTaskRequestV2Codec, CancelTaskRequestV2Codec]) { - expect(codec.parse({ jsonrpc: "2.0", id: 1, method: "wrong", params: {} }).success).toBe(false); + fc.assert( + fc.property( + fc.oneof(fc.string(), fc.integer()), + fc.string(), + (id, taskId) => { + expect( + GetTaskRequestV2Codec.parse({ + jsonrpc: "2.0", + id, + method: "tasks/get", + params: { taskId }, + }).success, + ).toBe(true); + expect( + CancelTaskRequestV2Codec.parse({ + jsonrpc: "2.0", + id, + method: "tasks/cancel", + params: { taskId }, + }).success, + ).toBe(true); + expect( + UpdateTaskRequestV2Codec.parse({ + jsonrpc: "2.0", + id, + method: "tasks/update", + params: { taskId, inputResponses: {} }, + }).success, + ).toBe(true); + }, + ), + ); + for (const codec of [ + GetTaskRequestV2Codec, + UpdateTaskRequestV2Codec, + CancelTaskRequestV2Codec, + ]) { + expect( + codec.parse({ jsonrpc: "2.0", id: 1, method: "wrong", params: {} }) + .success, + ).toBe(false); } - expect(UpdateTaskResultV2Codec.parse({ resultType: "complete" }).success).toBe(true); - expect(CancelTaskResultV2Codec.parse({ resultType: "complete" }).success).toBe(true); + expect( + UpdateTaskResultV2Codec.parse({ resultType: "complete" }).success, + ).toBe(true); + expect( + CancelTaskResultV2Codec.parse({ resultType: "complete" }).success, + ).toBe(true); expect(UpdateTaskResultV2Codec.parse({}).success).toBe(false); - fc.assert(fc.property(taskFor("completed"), fc.dictionary(fc.string(), fc.jsonValue()), (task, result) => { - expect(GetTaskResultV2Codec.parse(asJson({ ...task, result, resultType: "complete" })).success).toBe(true); - })); + fc.assert( + fc.property( + taskFor("completed"), + fc.dictionary(fc.string(), fc.jsonValue()), + (task, result) => { + expect( + GetTaskResultV2Codec.parse( + asJson({ ...task, result, resultType: "complete" }), + ).success, + ).toBe(true); + }, + ), + ); }); it("discriminates Task creation only for eligible tools/call results", () => { - fc.assert(fc.property(baseTask, (task) => { - const result = { ...task, resultType: "task" }; - expect(CreateTaskResultV2Codec.parse(asJson(result)).success).toBe(true); - expect(isEligibleTaskResultV2("tools/call", result)).toBe(true); - expect(isEligibleTaskResultV2("prompts/get", result)).toBe(false); - })); - expect(CreateTaskResultV2Codec.parse({ resultType: "complete" }).success).toBe(false); + fc.assert( + fc.property(baseTask, (task) => { + const result = { ...task, resultType: "task" }; + expect(CreateTaskResultV2Codec.parse(asJson(result)).success).toBe( + true, + ); + expect(isEligibleTaskResultV2("tools/call", result)).toBe(true); + expect(isEligibleTaskResultV2("prompts/get", result)).toBe(false); + }), + ); + expect( + CreateTaskResultV2Codec.parse({ resultType: "complete" }).success, + ).toBe(false); }); it("decodes detailed task notifications with exact envelope discriminators", () => { - fc.assert(fc.property(taskFor("working"), (task) => { - expect(TaskStatusNotificationV2Codec.parse(asJson({ jsonrpc: "2.0", method: "notifications/tasks", params: task })).success).toBe(true); - })); - expect(TaskStatusNotificationV2Codec.parse({ jsonrpc: "2.0", method: "notifications/wrong", params: {} }).success).toBe(false); + fc.assert( + fc.property(taskFor("working"), (task) => { + expect( + TaskStatusNotificationV2Codec.parse( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: task, + }), + ).success, + ).toBe(true); + }), + ); + expect( + TaskStatusNotificationV2Codec.parse({ + jsonrpc: "2.0", + method: "notifications/wrong", + params: {}, + }).success, + ).toBe(false); }); it("contributes task IDs without changing unrelated filters or prior notification fields", () => { - fc.assert(fc.property(fc.dictionary(fc.string(), fc.jsonValue()), fc.dictionary(fc.string(), fc.jsonValue()), fc.array(fc.string()), (filter, notifications, ids) => { - const source = asJson({ ...filter, notifications }) as Readonly>; - const result = contributeTaskFilterV2(source, ids); - for (const [key, value] of Object.entries(source)) if (key !== "notifications") expect(result[key]).toEqual(value); - const normalizedNotifications = source.notifications as Readonly>; - for (const [key, value] of Object.entries(normalizedNotifications)) if (key !== "taskIds") expect(result.notifications[key]).toEqual(value); - expect(result.notifications.taskIds).toEqual([...new Set(ids)]); - })); + fc.assert( + fc.property( + fc.dictionary(fc.string(), fc.jsonValue()), + fc.dictionary(fc.string(), fc.jsonValue()), + fc.array(fc.string()), + (filter, notifications, ids) => { + const source = asJson({ ...filter, notifications }) as Readonly< + Record + >; + const result = contributeTaskFilterV2(source, ids); + for (const [key, value] of Object.entries(source)) + if (key !== "notifications") expect(result[key]).toEqual(value); + const normalizedNotifications = source.notifications as Readonly< + Record + >; + for (const [key, value] of Object.entries(normalizedNotifications)) + if (key !== "taskIds") + expect(result.notifications[key]).toEqual(value); + expect(result.notifications.taskIds).toEqual([...new Set(ids)]); + }, + ), + ); }); it("reads only fully valid acknowledged task IDs", () => { - fc.assert(fc.property(fc.array(fc.string()), (ids) => { expect(readAcceptedTaskIdsV2({ notifications: { taskIds: ids } })).toEqual(ids); })); - fc.assert(fc.property(fc.array(fc.oneof(fc.string(), fc.integer())).filter((ids) => ids.some((id) => typeof id !== "string")), (ids) => { - expect(readAcceptedTaskIdsV2({ notifications: { taskIds: ids } })).toEqual([]); - })); + fc.assert( + fc.property(fc.array(fc.string()), (ids) => { + expect( + readAcceptedTaskIdsV2({ notifications: { taskIds: ids } }), + ).toEqual(ids); + }), + ); + fc.assert( + fc.property( + fc + .array(fc.oneof(fc.string(), fc.integer())) + .filter((ids) => ids.some((id) => typeof id !== "string")), + (ids) => { + expect( + readAcceptedTaskIdsV2({ notifications: { taskIds: ids } }), + ).toEqual([]); + }, + ), + ); }); it("uses exact client and server capability envelopes", () => { const wire = withTaskCapabilityV2({ _meta: { trace: "x" } }); - expect(wire).toEqual({ _meta: { trace: "x", "io.modelcontextprotocol/clientCapabilities": { extensions: { "io.modelcontextprotocol/tasks": {} } } } }); + expect(wire).toEqual({ + _meta: { + trace: "x", + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }); expect(hasTaskClientCapabilityV2(wire)).toBe(true); - expect(hasTaskServerCapabilityV2({ extensions: { "io.modelcontextprotocol/tasks": {} } })).toBe(true); + expect( + hasTaskServerCapabilityV2({ + extensions: { "io.modelcontextprotocol/tasks": {} }, + }), + ).toBe(true); expect(hasTaskServerCapabilityV2({ extensions: {} })).toBe(false); }); }); diff --git a/packages/ext-tasks/src/core/v2/index.ts b/packages/ext-tasks/src/core/v2/index.ts index 04d8bc1..abfc142 100644 --- a/packages/ext-tasks/src/core/v2/index.ts +++ b/packages/ext-tasks/src/core/v2/index.ts @@ -15,7 +15,6 @@ export const TASKS_EXTENSION_ID_V2 = "io.modelcontextprotocol/tasks" as const; export const CLIENT_CAPABILITIES_META_KEY_V2 = "io.modelcontextprotocol/clientCapabilities" as const; - type OpenObjectV2 = Readonly>; type ToolAnnotationsV2 = OpenObjectV2 & { readonly title?: string; @@ -30,18 +29,30 @@ type IconV2 = OpenObjectV2 & { readonly sizes?: readonly string[]; readonly theme?: "light" | "dark"; }; -type ContentBlockV2 = OpenObjectV2 & ( - | { readonly type: "text"; readonly text: string } - | { readonly type: "image" | "audio"; readonly data: string; readonly mimeType: string } - | { readonly type: "resource_link"; readonly name: string; readonly uri: string } - | { readonly type: "resource"; readonly resource: OpenObjectV2 } -); +type ContentBlockV2 = OpenObjectV2 & + ( + | { readonly type: "text"; readonly text: string } + | { + readonly type: "image" | "audio"; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly name: string; + readonly uri: string; + } + | { readonly type: "resource"; readonly resource: OpenObjectV2 } + ); export type ToolV2 = OpenObjectV2 & { readonly name: string; readonly title?: string; readonly description?: string; - readonly inputSchema: OpenObjectV2 & { readonly type: "object"; readonly $schema?: string }; + readonly inputSchema: OpenObjectV2 & { + readonly type: "object"; + readonly $schema?: string; + }; readonly outputSchema?: OpenObjectV2 & { readonly $schema?: string }; readonly annotations?: ToolAnnotationsV2; readonly icons?: readonly IconV2[]; @@ -49,11 +60,7 @@ export type ToolV2 = OpenObjectV2 & { }; export type RequestIdV2 = string | number; export type TaskStatusV2 = - | "working" - | "input_required" - | "completed" - | "failed" - | "cancelled"; + "working" | "input_required" | "completed" | "failed" | "cancelled"; export type TaskEligibleMethodV2 = "tools/call"; @@ -67,7 +74,9 @@ export interface TaskV2 { readonly pollIntervalMs?: number; } -export interface WorkingTaskV2 extends TaskV2 { readonly status: "working"; } +export interface WorkingTaskV2 extends TaskV2 { + readonly status: "working"; +} export interface InputRequiredTaskV2 extends TaskV2 { readonly status: "input_required"; readonly inputRequests: InputRequestsV2; @@ -80,9 +89,15 @@ export interface FailedTaskV2 extends TaskV2 { readonly status: "failed"; readonly error: ErrorV2; } -export interface CancelledTaskV2 extends TaskV2 { readonly status: "cancelled"; } +export interface CancelledTaskV2 extends TaskV2 { + readonly status: "cancelled"; +} export type DetailedTaskV2 = - | WorkingTaskV2 | InputRequiredTaskV2 | CompletedTaskV2 | FailedTaskV2 | CancelledTaskV2; + | WorkingTaskV2 + | InputRequiredTaskV2 + | CompletedTaskV2 + | FailedTaskV2 + | CancelledTaskV2; export interface ErrorV2 { readonly code: number; @@ -102,10 +117,13 @@ export interface ElicitRequestV2 { readonly method: "elicitation/create"; readonly params: Readonly>; } -export type InputRequestV2 = CreateMessageRequestV2 | ListRootsRequestV2 | ElicitRequestV2; +export type InputRequestV2 = + CreateMessageRequestV2 | ListRootsRequestV2 | ElicitRequestV2; export type InputRequestsV2 = Readonly>; -export interface CreateMessageResultV2 extends Readonly> { +export interface CreateMessageResultV2 extends Readonly< + Record +> { readonly content: JsonValue; readonly model: string; readonly role: "user" | "assistant"; @@ -116,7 +134,8 @@ export interface ListRootsResultV2 extends Readonly> { export interface ElicitResultV2 extends Readonly> { readonly action: "accept" | "decline" | "cancel"; } -export type InputResponseV2 = CreateMessageResultV2 | ListRootsResultV2 | ElicitResultV2; +export type InputResponseV2 = + CreateMessageResultV2 | ListRootsResultV2 | ElicitResultV2; export type InputResponsesV2 = Readonly>; export interface CreateTaskResultV2 extends TaskV2 { @@ -144,7 +163,10 @@ export interface GetTaskRequestV2 extends JsonRpcRequestV2 { } export interface UpdateTaskRequestV2 extends JsonRpcRequestV2 { readonly method: "tasks/update"; - readonly params: { readonly taskId: string; readonly inputResponses: InputResponsesV2 }; + readonly params: { + readonly taskId: string; + readonly inputResponses: InputResponsesV2; + }; } export interface CancelTaskRequestV2 extends JsonRpcRequestV2 { readonly method: "tasks/cancel"; @@ -154,10 +176,14 @@ export type GetTaskResultV2 = DetailedTaskV2 & { readonly resultType: "complete"; readonly _meta?: Readonly>; }; -export interface UpdateTaskResultV2 extends Readonly> { +export interface UpdateTaskResultV2 extends Readonly< + Record +> { readonly resultType: "complete"; } -export interface CancelTaskResultV2 extends Readonly> { +export interface CancelTaskResultV2 extends Readonly< + Record +> { readonly resultType: "complete"; } @@ -169,48 +195,89 @@ export interface TaskStatusNotificationV2 { readonly method: "notifications/tasks"; readonly params: TaskStatusNotificationParamsV2; } -export interface TaskSubscriptionNotificationsV2 { readonly taskIds?: readonly string[]; } -export interface TaskSubscriptionAcknowledgedNotificationsV2 { readonly taskIds?: readonly string[]; } +export interface TaskSubscriptionNotificationsV2 { + readonly taskIds?: readonly string[]; +} +export interface TaskSubscriptionAcknowledgedNotificationsV2 { + readonly taskIds?: readonly string[]; +} export type TaskExtensionCapabilitiesV2 = Readonly>; export type TasksExtensionCapabilityV2 = TaskExtensionCapabilitiesV2; export interface ClientTaskCapabilityEnvelopeV2 { - readonly extensions: { readonly [TASKS_EXTENSION_ID_V2]: TaskExtensionCapabilitiesV2 }; + readonly extensions: { + readonly [TASKS_EXTENSION_ID_V2]: TaskExtensionCapabilitiesV2; + }; } export interface ServerTaskCapabilityEnvelopeV2 { readonly extensions?: Readonly>; } -const statuses = ["working", "input_required", "completed", "failed", "cancelled"] as const; -const inputMethods = ["sampling/createMessage", "roots/list", "elicitation/create"] as const; +const statuses = [ + "working", + "input_required", + "completed", + "failed", + "cancelled", +] as const; +const inputMethods = [ + "sampling/createMessage", + "roots/list", + "elicitation/create", +] as const; function has(record: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(record, key); } function expectInteger(value: JsonValue | undefined, path: DecodePath): number { const number = expectNumber(value, path); - if (!Number.isInteger(number)) throw new ProtocolDecodeError("expected integer", path); + if (!Number.isInteger(number)) + throw new ProtocolDecodeError("expected integer", path); return number; } -function expectRequestId(value: JsonValue | undefined, path: DecodePath): RequestIdV2 { +function expectRequestId( + value: JsonValue | undefined, + path: DecodePath, +): RequestIdV2 { if (typeof value === "string") return value; return expectInteger(value, path); } -function expectConst(value: JsonValue | undefined, expected: string, path: DecodePath): void { - if (value !== expected) throw new ProtocolDecodeError(`expected ${expected}`, path); +function expectConst( + value: JsonValue | undefined, + expected: string, + path: DecodePath, +): void { + if (value !== expected) + throw new ProtocolDecodeError(`expected ${expected}`, path); } function optionalRecord(value: JsonValue | undefined, path: DecodePath) { return value === undefined ? undefined : expectRecord(value, path); } -function optionalString(object: Record, key: string, path: DecodePath): void { +function optionalString( + object: Record, + key: string, + path: DecodePath, +): void { if (object[key] !== undefined) expectString(object[key], [...path, key]); } -function optionalBoolean(object: Record, key: string, path: DecodePath): void { - if (object[key] !== undefined && typeof object[key] !== "boolean") throw new ProtocolDecodeError("expected boolean", [...path, key]); -} -function optionalStringArray(object: Record, key: string, path: DecodePath): void { +function optionalBoolean( + object: Record, + key: string, + path: DecodePath, +): void { + if (object[key] !== undefined && typeof object[key] !== "boolean") + throw new ProtocolDecodeError("expected boolean", [...path, key]); +} +function optionalStringArray( + object: Record, + key: string, + path: DecodePath, +): void { const value = object[key]; - if (value !== undefined && (!Array.isArray(value) || !value.every((item) => typeof item === "string"))) { + if ( + value !== undefined && + (!Array.isArray(value) || !value.every((item) => typeof item === "string")) + ) { throw new ProtocolDecodeError("expected string array", [...path, key]); } } @@ -218,13 +285,23 @@ function optionalStringArray(object: Record, key: string, pat function decodeAnnotations(value: JsonValue, path: DecodePath): void { const object = expectRecord(value, path); if (object.audience !== undefined) { - if (!Array.isArray(object.audience) || !object.audience.every((role) => role === "user" || role === "assistant")) { - throw new ProtocolDecodeError("expected role array", [...path, "audience"]); + if ( + !Array.isArray(object.audience) || + !object.audience.every((role) => role === "user" || role === "assistant") + ) { + throw new ProtocolDecodeError("expected role array", [ + ...path, + "audience", + ]); } } if (object.priority !== undefined) { const priority = expectNumber(object.priority, [...path, "priority"]); - if (priority < 0 || priority > 1) throw new ProtocolDecodeError("expected number from 0 to 1", [...path, "priority"]); + if (priority < 0 || priority > 1) + throw new ProtocolDecodeError("expected number from 0 to 1", [ + ...path, + "priority", + ]); } optionalString(object, "lastModified", path); } @@ -234,7 +311,8 @@ function decodeIcon(value: JsonValue, path: DecodePath): void { expectString(object.src, [...path, "src"]); optionalString(object, "mimeType", path); optionalStringArray(object, "sizes", path); - if (object.theme !== undefined) expectEnum(object.theme, ["light", "dark"], [...path, "theme"]); + if (object.theme !== undefined) + expectEnum(object.theme, ["light", "dark"], [...path, "theme"]); } function decodeImplementation(value: JsonValue, path: DecodePath): void { @@ -245,14 +323,24 @@ function decodeImplementation(value: JsonValue, path: DecodePath): void { optionalString(object, "description", path); optionalString(object, "websiteUrl", path); if (object.icons !== undefined) { - if (!Array.isArray(object.icons)) throw new ProtocolDecodeError("expected array", [...path, "icons"]); - object.icons.forEach((icon, index) => decodeIcon(icon, [...path, "icons", index])); + if (!Array.isArray(object.icons)) + throw new ProtocolDecodeError("expected array", [...path, "icons"]); + object.icons.forEach((icon, index) => + decodeIcon(icon, [...path, "icons", index]), + ); } } -function decodeContentBlock(value: JsonValue, path: DecodePath): ContentBlockV2 { +function decodeContentBlock( + value: JsonValue, + path: DecodePath, +): ContentBlockV2 { const object = expectRecord(value, path); - const type = expectEnum(object.type, ["text", "image", "audio", "resource_link", "resource"], [...path, "type"]); + const type = expectEnum( + object.type, + ["text", "image", "audio", "resource_link", "resource"], + [...path, "type"], + ); if (type === "text") expectString(object.text, [...path, "text"]); else if (type === "image" || type === "audio") { expectString(object.data, [...path, "data"]); @@ -263,10 +351,14 @@ function decodeContentBlock(value: JsonValue, path: DecodePath): ContentBlockV2 optionalString(object, "title", path); optionalString(object, "description", path); optionalString(object, "mimeType", path); - if (object.size !== undefined) expectInteger(object.size, [...path, "size"]); + if (object.size !== undefined) + expectInteger(object.size, [...path, "size"]); if (object.icons !== undefined) { - if (!Array.isArray(object.icons)) throw new ProtocolDecodeError("expected array", [...path, "icons"]); - object.icons.forEach((icon, index) => decodeIcon(icon, [...path, "icons", index])); + if (!Array.isArray(object.icons)) + throw new ProtocolDecodeError("expected array", [...path, "icons"]); + object.icons.forEach((icon, index) => + decodeIcon(icon, [...path, "icons", index]), + ); } } else { const resource = expectRecord(object.resource, [...path, "resource"]); @@ -275,11 +367,16 @@ function decodeContentBlock(value: JsonValue, path: DecodePath): ContentBlockV2 optionalRecord(resource._meta, [...path, "resource", "_meta"]); const hasText = resource.text !== undefined; const hasBlob = resource.blob !== undefined; - if (!hasText && !hasBlob) throw new ProtocolDecodeError("expected text or blob", [...path, "resource"]); + if (!hasText && !hasBlob) + throw new ProtocolDecodeError("expected text or blob", [ + ...path, + "resource", + ]); if (hasText) expectString(resource.text, [...path, "resource", "text"]); if (hasBlob) expectString(resource.blob, [...path, "resource", "blob"]); } - if (object.annotations !== undefined) decodeAnnotations(object.annotations, [...path, "annotations"]); + if (object.annotations !== undefined) + decodeAnnotations(object.annotations, [...path, "annotations"]); optionalRecord(object._meta, [...path, "_meta"]); return object as ContentBlockV2; } @@ -289,35 +386,63 @@ function decodeTool(value: JsonValue, path: DecodePath): ToolV2 { expectString(object.name, [...path, "name"]); optionalString(object, "title", path); optionalString(object, "description", path); - const inputSchema = expectRecord(object.inputSchema, [...path, "inputSchema"]); + const inputSchema = expectRecord(object.inputSchema, [ + ...path, + "inputSchema", + ]); expectConst(inputSchema.type, "object", [...path, "inputSchema", "type"]); optionalString(inputSchema, "$schema", [...path, "inputSchema"]); if (object.outputSchema !== undefined) { - const outputSchema = expectRecord(object.outputSchema, [...path, "outputSchema"]); + const outputSchema = expectRecord(object.outputSchema, [ + ...path, + "outputSchema", + ]); optionalString(outputSchema, "$schema", [...path, "outputSchema"]); } if (object.annotations !== undefined) { - const annotations = expectRecord(object.annotations, [...path, "annotations"]); + const annotations = expectRecord(object.annotations, [ + ...path, + "annotations", + ]); optionalString(annotations, "title", [...path, "annotations"]); - for (const key of ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"]) optionalBoolean(annotations, key, [...path, "annotations"]); + for (const key of [ + "readOnlyHint", + "destructiveHint", + "idempotentHint", + "openWorldHint", + ]) + optionalBoolean(annotations, key, [...path, "annotations"]); } if (object.icons !== undefined) { - if (!Array.isArray(object.icons)) throw new ProtocolDecodeError("expected array", [...path, "icons"]); - object.icons.forEach((icon, index) => decodeIcon(icon, [...path, "icons", index])); + if (!Array.isArray(object.icons)) + throw new ProtocolDecodeError("expected array", [...path, "icons"]); + object.icons.forEach((icon, index) => + decodeIcon(icon, [...path, "icons", index]), + ); } optionalRecord(object._meta, [...path, "_meta"]); return object as ToolV2; } -function decodeCallToolResult(value: JsonValue, path: DecodePath): CallToolResultV2 { +function decodeCallToolResult( + value: JsonValue, + path: DecodePath, +): CallToolResultV2 { const object = expectRecord(value, path); expectString(object.resultType, [...path, "resultType"]); - if (!Array.isArray(object.content)) throw new ProtocolDecodeError("expected array", [...path, "content"]); - object.content.forEach((block, index) => decodeContentBlock(block, [...path, "content", index])); + if (!Array.isArray(object.content)) + throw new ProtocolDecodeError("expected array", [...path, "content"]); + object.content.forEach((block, index) => + decodeContentBlock(block, [...path, "content", index]), + ); optionalBoolean(object, "isError", path); const meta = optionalRecord(object._meta, [...path, "_meta"]); if (meta?.["io.modelcontextprotocol/serverInfo"] !== undefined) { - decodeImplementation(meta["io.modelcontextprotocol/serverInfo"], [...path, "_meta", "io.modelcontextprotocol/serverInfo"]); + decodeImplementation(meta["io.modelcontextprotocol/serverInfo"], [ + ...path, + "_meta", + "io.modelcontextprotocol/serverInfo", + ]); } return object as CallToolResultV2; } @@ -325,15 +450,33 @@ function decodeCallToolResult(value: JsonValue, path: DecodePath): CallToolResul function decodeTask(value: JsonValue, path: DecodePath): TaskV2 { const object = expectRecord(value, path); const ttl = object.ttlMs; - if (!has(object, "ttlMs")) throw new ProtocolDecodeError("required field", [...path, "ttlMs"]); + if (!has(object, "ttlMs")) + throw new ProtocolDecodeError("required field", [...path, "ttlMs"]); const task: TaskV2 = { taskId: expectString(object.taskId, [...path, "taskId"]), status: expectEnum(object.status, statuses, [...path, "status"]), createdAt: expectString(object.createdAt, [...path, "createdAt"]), - lastUpdatedAt: expectString(object.lastUpdatedAt, [...path, "lastUpdatedAt"]), + lastUpdatedAt: expectString(object.lastUpdatedAt, [ + ...path, + "lastUpdatedAt", + ]), ttlMs: ttl === null ? null : expectInteger(ttl, [...path, "ttlMs"]), - ...(object.statusMessage === undefined ? {} : { statusMessage: expectString(object.statusMessage, [...path, "statusMessage"]) }), - ...(object.pollIntervalMs === undefined ? {} : { pollIntervalMs: expectInteger(object.pollIntervalMs, [...path, "pollIntervalMs"]) }), + ...(object.statusMessage === undefined + ? {} + : { + statusMessage: expectString(object.statusMessage, [ + ...path, + "statusMessage", + ]), + }), + ...(object.pollIntervalMs === undefined + ? {} + : { + pollIntervalMs: expectInteger(object.pollIntervalMs, [ + ...path, + "pollIntervalMs", + ]), + }), }; return task; } @@ -347,46 +490,105 @@ function decodeError(value: JsonValue, path: DecodePath): ErrorV2 { }; } -function decodeInputRequest(value: JsonValue, path: DecodePath): InputRequestV2 { +function decodeInputRequest( + value: JsonValue, + path: DecodePath, +): InputRequestV2 { const object = expectRecord(value, path); const method = expectEnum(object.method, inputMethods, [...path, "method"]); if (method === "roots/list") { - return { method, ...(object.params === undefined ? {} : { params: expectRecord(object.params, [...path, "params"]) }) }; + return { + method, + ...(object.params === undefined + ? {} + : { params: expectRecord(object.params, [...path, "params"]) }), + }; } - return { method, params: expectRecord(object.params as JsonValue, [...path, "params"]) }; + return { + method, + params: expectRecord(object.params as JsonValue, [...path, "params"]), + }; } -function decodeInputRequests(value: JsonValue, path: DecodePath): InputRequestsV2 { +function decodeInputRequests( + value: JsonValue, + path: DecodePath, +): InputRequestsV2 { const object = expectRecord(value, path); - return Object.fromEntries(Object.entries(object).map(([key, request]) => [key, decodeInputRequest(request, [...path, key])])); -} - -function decodeInputResponse(value: JsonValue, path: DecodePath): InputResponseV2 { + return Object.fromEntries( + Object.entries(object).map(([key, request]) => [ + key, + decodeInputRequest(request, [...path, key]), + ]), + ); +} + +function decodeInputResponse( + value: JsonValue, + path: DecodePath, +): InputResponseV2 { const object = expectRecord(value, path); if (has(object, "action")) { - expectEnum(object.action, ["accept", "decline", "cancel"], [...path, "action"]); + expectEnum( + object.action, + ["accept", "decline", "cancel"], + [...path, "action"], + ); } else if (has(object, "roots")) { - if (!Array.isArray(object.roots)) throw new ProtocolDecodeError("expected array", [...path, "roots"]); + if (!Array.isArray(object.roots)) + throw new ProtocolDecodeError("expected array", [...path, "roots"]); } else { - if (!has(object, "content")) throw new ProtocolDecodeError("required field", [...path, "content"]); + if (!has(object, "content")) + throw new ProtocolDecodeError("required field", [...path, "content"]); expectString(object.model, [...path, "model"]); expectEnum(object.role, ["user", "assistant"], [...path, "role"]); } return object as InputResponseV2; } -function decodeInputResponses(value: JsonValue, path: DecodePath): InputResponsesV2 { +function decodeInputResponses( + value: JsonValue, + path: DecodePath, +): InputResponsesV2 { const object = expectRecord(value, path); - return Object.fromEntries(Object.entries(object).map(([key, response]) => [key, decodeInputResponse(response, [...path, key])])); -} - -function decodeDetailedTask(value: JsonValue, path: DecodePath): DetailedTaskV2 { + return Object.fromEntries( + Object.entries(object).map(([key, response]) => [ + key, + decodeInputResponse(response, [...path, key]), + ]), + ); +} + +function decodeDetailedTask( + value: JsonValue, + path: DecodePath, +): DetailedTaskV2 { const object = expectRecord(value, path); const task = decodeTask(value, path); switch (task.status) { - case "input_required": return { ...task, status: task.status, inputRequests: decodeInputRequests(object.inputRequests as JsonValue, [...path, "inputRequests"]) }; - case "completed": return { ...task, status: task.status, result: expectRecord(object.result as JsonValue, [...path, "result"]) }; - case "failed": return { ...task, status: task.status, error: decodeError(object.error as JsonValue, [...path, "error"]) }; - case "working": return { ...task, status: task.status }; - case "cancelled": return { ...task, status: task.status }; + case "input_required": + return { + ...task, + status: task.status, + inputRequests: decodeInputRequests(object.inputRequests as JsonValue, [ + ...path, + "inputRequests", + ]), + }; + case "completed": + return { + ...task, + status: task.status, + result: expectRecord(object.result as JsonValue, [...path, "result"]), + }; + case "failed": + return { + ...task, + status: task.status, + error: decodeError(object.error as JsonValue, [...path, "error"]), + }; + case "working": + return { ...task, status: task.status }; + case "cancelled": + return { ...task, status: task.status }; } } @@ -394,7 +596,11 @@ function decodeRpcRequest(value: JsonValue, path: DecodePath, method: string) { const object = expectRecord(value, path); expectConst(object.jsonrpc, "2.0", [...path, "jsonrpc"]); expectConst(object.method, method, [...path, "method"]); - return { object, id: expectRequestId(object.id, [...path, "id"]), params: expectRecord(object.params as JsonValue, [...path, "params"]) }; + return { + object, + id: expectRequestId(object.id, [...path, "id"]), + params: expectRecord(object.params as JsonValue, [...path, "params"]), + }; } function decodeCompleteResult(value: JsonValue, path: DecodePath) { const object = expectRecord(value, path); @@ -403,147 +609,372 @@ function decodeCompleteResult(value: JsonValue, path: DecodePath) { return object; } -export const ToolV2Codec: RuntimeCodec = createRuntimeCodec(decodeTool); -export const CallToolResultV2Codec: RuntimeCodec = createRuntimeCodec(decodeCallToolResult); +export const ToolV2Codec: RuntimeCodec = + createRuntimeCodec(decodeTool); +export const CallToolResultV2Codec: RuntimeCodec = + createRuntimeCodec(decodeCallToolResult); /** @deprecated Use CallToolResultV2Codec. */ -export const ToolCallResultV2Codec: RuntimeCodec = CallToolResultV2Codec; -export const TaskV2Codec: RuntimeCodec = createRuntimeCodec(decodeTask); -export const DetailedTaskV2Codec: RuntimeCodec = createRuntimeCodec(decodeDetailedTask); -export const ErrorV2Codec: RuntimeCodec = createRuntimeCodec(decodeError); -export const InputRequestV2Codec: RuntimeCodec = createRuntimeCodec(decodeInputRequest); -export const InputRequestsV2Codec: RuntimeCodec = createRuntimeCodec(decodeInputRequests); -export const InputResponseV2Codec: RuntimeCodec = createRuntimeCodec(decodeInputResponse); -export const InputResponsesV2Codec: RuntimeCodec = createRuntimeCodec(decodeInputResponses); -export const CreateMessageRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const request = decodeInputRequest(value, path); - if (request.method !== "sampling/createMessage") throw new ProtocolDecodeError("expected sampling/createMessage", [...path, "method"]); - return request; -}); -export const ListRootsRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const request = decodeInputRequest(value, path); - if (request.method !== "roots/list") throw new ProtocolDecodeError("expected roots/list", [...path, "method"]); - return request; -}); -export const ElicitRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const request = decodeInputRequest(value, path); - if (request.method !== "elicitation/create") throw new ProtocolDecodeError("expected elicitation/create", [...path, "method"]); - return request; -}); -export const CreateMessageResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const response = decodeInputResponse(value, path); - if (!("content" in response) || !("model" in response) || !("role" in response)) throw new ProtocolDecodeError("expected sampling result", path); - return response as CreateMessageResultV2; -}); -export const ListRootsResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const response = decodeInputResponse(value, path); - if (!("roots" in response)) throw new ProtocolDecodeError("expected roots result", path); - return response as ListRootsResultV2; -}); -export const ElicitResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const response = decodeInputResponse(value, path); - if (!("action" in response)) throw new ProtocolDecodeError("expected elicitation result", path); - return response as ElicitResultV2; -}); -export const CreateTaskResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - expectConst(object.resultType, "task", [...path, "resultType"]); - optionalRecord(object._meta, [...path, "_meta"]); - return { ...decodeTask(value, path), resultType: "task", ...(object._meta === undefined ? {} : { _meta: expectRecord(object._meta, [...path, "_meta"]) }) }; -}); -export const GetTaskRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const { id, params } = decodeRpcRequest(value, path, "tasks/get"); - return { jsonrpc: "2.0", id, method: "tasks/get", params: { taskId: expectString(params.taskId, [...path, "params", "taskId"]) } }; -}); -export const UpdateTaskRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const { id, params } = decodeRpcRequest(value, path, "tasks/update"); - return { jsonrpc: "2.0", id, method: "tasks/update", params: { taskId: expectString(params.taskId, [...path, "params", "taskId"]), inputResponses: decodeInputResponses(params.inputResponses as JsonValue, [...path, "params", "inputResponses"]) } }; -}); -export const CancelTaskRequestV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const { id, params } = decodeRpcRequest(value, path, "tasks/cancel"); - return { jsonrpc: "2.0", id, method: "tasks/cancel", params: { taskId: expectString(params.taskId, [...path, "params", "taskId"]) } }; -}); -export const GetTaskResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const object = decodeCompleteResult(value, path); - return { ...decodeDetailedTask(value, path), resultType: "complete", ...(object._meta === undefined ? {} : { _meta: expectRecord(object._meta, [...path, "_meta"]) }) }; -}); -export const UpdateTaskResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => decodeCompleteResult(value, path) as UpdateTaskResultV2); -export const CancelTaskResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => decodeCompleteResult(value, path) as CancelTaskResultV2); -export const WorkingTaskV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const task = decodeDetailedTask(value, path); if (task.status !== "working") throw new ProtocolDecodeError("expected working", [...path, "status"]); return task; }); -export const InputRequiredTaskV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const task = decodeDetailedTask(value, path); if (task.status !== "input_required") throw new ProtocolDecodeError("expected input_required", [...path, "status"]); return task; }); -export const CompletedTaskV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const task = decodeDetailedTask(value, path); if (task.status !== "completed") throw new ProtocolDecodeError("expected completed", [...path, "status"]); return task; }); -export const FailedTaskV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const task = decodeDetailedTask(value, path); if (task.status !== "failed") throw new ProtocolDecodeError("expected failed", [...path, "status"]); return task; }); -export const CancelledTaskV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const task = decodeDetailedTask(value, path); if (task.status !== "cancelled") throw new ProtocolDecodeError("expected cancelled", [...path, "status"]); return task; }); -export const TaskStatusNotificationParamsV2Codec: RuntimeCodec = createRuntimeCodec(decodeDetailedTask); -export const TaskSubscriptionNotificationsV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - if (object.taskIds === undefined) return {}; - if (!Array.isArray(object.taskIds) || !object.taskIds.every((id) => typeof id === "string")) throw new ProtocolDecodeError("expected string array", [...path, "taskIds"]); - return { taskIds: object.taskIds }; -}); -export const TaskSubscriptionAcknowledgedNotificationsV2Codec: RuntimeCodec = TaskSubscriptionNotificationsV2Codec; -export const TaskExtensionCapabilitiesV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - if (Object.keys(object).length !== 0) throw new ProtocolDecodeError("expected empty object", path); - return {}; -}); -export const TasksExtensionCapabilityV2Codec: RuntimeCodec = TaskExtensionCapabilitiesV2Codec; -export const TaskStatusNotificationV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - expectConst(object.jsonrpc, "2.0", [...path, "jsonrpc"]); - expectConst(object.method, "notifications/tasks", [...path, "method"]); - return { jsonrpc: "2.0", method: "notifications/tasks", params: decodeDetailedTask(object.params as JsonValue, [...path, "params"]) }; -}); - -function parsed(codec: { parse(value: JsonValue): { success: boolean } }, value: unknown): value is T { +export const ToolCallResultV2Codec: RuntimeCodec = + CallToolResultV2Codec; +export const TaskV2Codec: RuntimeCodec = + createRuntimeCodec(decodeTask); +export const DetailedTaskV2Codec: RuntimeCodec = + createRuntimeCodec(decodeDetailedTask); +export const ErrorV2Codec: RuntimeCodec = + createRuntimeCodec(decodeError); +export const InputRequestV2Codec: RuntimeCodec = + createRuntimeCodec(decodeInputRequest); +export const InputRequestsV2Codec: RuntimeCodec = + createRuntimeCodec(decodeInputRequests); +export const InputResponseV2Codec: RuntimeCodec = + createRuntimeCodec(decodeInputResponse); +export const InputResponsesV2Codec: RuntimeCodec = + createRuntimeCodec(decodeInputResponses); +export const CreateMessageRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const request = decodeInputRequest(value, path); + if (request.method !== "sampling/createMessage") + throw new ProtocolDecodeError("expected sampling/createMessage", [ + ...path, + "method", + ]); + return request; + }); +export const ListRootsRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const request = decodeInputRequest(value, path); + if (request.method !== "roots/list") + throw new ProtocolDecodeError("expected roots/list", [...path, "method"]); + return request; + }); +export const ElicitRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const request = decodeInputRequest(value, path); + if (request.method !== "elicitation/create") + throw new ProtocolDecodeError("expected elicitation/create", [ + ...path, + "method", + ]); + return request; + }); +export const CreateMessageResultV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const response = decodeInputResponse(value, path); + if ( + !("content" in response) || + !("model" in response) || + !("role" in response) + ) + throw new ProtocolDecodeError("expected sampling result", path); + return response as CreateMessageResultV2; + }); +export const ListRootsResultV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const response = decodeInputResponse(value, path); + if (!("roots" in response)) + throw new ProtocolDecodeError("expected roots result", path); + return response as ListRootsResultV2; + }); +export const ElicitResultV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const response = decodeInputResponse(value, path); + if (!("action" in response)) + throw new ProtocolDecodeError("expected elicitation result", path); + return response as ElicitResultV2; + }); +export const CreateTaskResultV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + expectConst(object.resultType, "task", [...path, "resultType"]); + optionalRecord(object._meta, [...path, "_meta"]); + return { + ...decodeTask(value, path), + resultType: "task", + ...(object._meta === undefined + ? {} + : { _meta: expectRecord(object._meta, [...path, "_meta"]) }), + }; + }); +export const GetTaskRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const { id, params } = decodeRpcRequest(value, path, "tasks/get"); + return { + jsonrpc: "2.0", + id, + method: "tasks/get", + params: { + taskId: expectString(params.taskId, [...path, "params", "taskId"]), + }, + }; + }); +export const UpdateTaskRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const { id, params } = decodeRpcRequest(value, path, "tasks/update"); + return { + jsonrpc: "2.0", + id, + method: "tasks/update", + params: { + taskId: expectString(params.taskId, [...path, "params", "taskId"]), + inputResponses: decodeInputResponses( + params.inputResponses as JsonValue, + [...path, "params", "inputResponses"], + ), + }, + }; + }); +export const CancelTaskRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const { id, params } = decodeRpcRequest(value, path, "tasks/cancel"); + return { + jsonrpc: "2.0", + id, + method: "tasks/cancel", + params: { + taskId: expectString(params.taskId, [...path, "params", "taskId"]), + }, + }; + }); +export const GetTaskResultV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const object = decodeCompleteResult(value, path); + return { + ...decodeDetailedTask(value, path), + resultType: "complete", + ...(object._meta === undefined + ? {} + : { _meta: expectRecord(object._meta, [...path, "_meta"]) }), + }; + }); +export const UpdateTaskResultV2Codec: RuntimeCodec = + createRuntimeCodec( + (value, path) => decodeCompleteResult(value, path) as UpdateTaskResultV2, + ); +export const CancelTaskResultV2Codec: RuntimeCodec = + createRuntimeCodec( + (value, path) => decodeCompleteResult(value, path) as CancelTaskResultV2, + ); +export const WorkingTaskV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const task = decodeDetailedTask(value, path); + if (task.status !== "working") + throw new ProtocolDecodeError("expected working", [...path, "status"]); + return task; + }); +export const InputRequiredTaskV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const task = decodeDetailedTask(value, path); + if (task.status !== "input_required") + throw new ProtocolDecodeError("expected input_required", [ + ...path, + "status", + ]); + return task; + }); +export const CompletedTaskV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const task = decodeDetailedTask(value, path); + if (task.status !== "completed") + throw new ProtocolDecodeError("expected completed", [...path, "status"]); + return task; + }); +export const FailedTaskV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const task = decodeDetailedTask(value, path); + if (task.status !== "failed") + throw new ProtocolDecodeError("expected failed", [...path, "status"]); + return task; + }); +export const CancelledTaskV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const task = decodeDetailedTask(value, path); + if (task.status !== "cancelled") + throw new ProtocolDecodeError("expected cancelled", [...path, "status"]); + return task; + }); +export const TaskStatusNotificationParamsV2Codec: RuntimeCodec = + createRuntimeCodec(decodeDetailedTask); +export const TaskSubscriptionNotificationsV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + if (object.taskIds === undefined) return {}; + if ( + !Array.isArray(object.taskIds) || + !object.taskIds.every((id) => typeof id === "string") + ) + throw new ProtocolDecodeError("expected string array", [ + ...path, + "taskIds", + ]); + return { taskIds: object.taskIds }; + }); +export const TaskSubscriptionAcknowledgedNotificationsV2Codec: RuntimeCodec = + TaskSubscriptionNotificationsV2Codec; +export const TaskExtensionCapabilitiesV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + if (Object.keys(object).length !== 0) + throw new ProtocolDecodeError("expected empty object", path); + return {}; + }); +export const TasksExtensionCapabilityV2Codec: RuntimeCodec = + TaskExtensionCapabilitiesV2Codec; +export const TaskStatusNotificationV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + expectConst(object.jsonrpc, "2.0", [...path, "jsonrpc"]); + expectConst(object.method, "notifications/tasks", [...path, "method"]); + return { + jsonrpc: "2.0", + method: "notifications/tasks", + params: decodeDetailedTask(object.params as JsonValue, [ + ...path, + "params", + ]), + }; + }); + +function parsed( + codec: { parse(value: JsonValue): { success: boolean } }, + value: unknown, +): value is T { return value !== undefined && codec.parse(value as JsonValue).success; } -export const isTaskV2: (value: unknown) => value is TaskV2 = (value: unknown): value is TaskV2 => parsed(TaskV2Codec, value); -export const isDetailedTaskV2: (value: unknown) => value is DetailedTaskV2 = (value: unknown): value is DetailedTaskV2 => parsed(DetailedTaskV2Codec, value); -export const isCreateTaskResultV2: (value: unknown) => value is CreateTaskResultV2 = (value: unknown): value is CreateTaskResultV2 => parsed(CreateTaskResultV2Codec, value); -export const isGetTaskRequestV2: (value: unknown) => value is GetTaskRequestV2 = (value: unknown): value is GetTaskRequestV2 => parsed(GetTaskRequestV2Codec, value); -export const isUpdateTaskRequestV2: (value: unknown) => value is UpdateTaskRequestV2 = (value: unknown): value is UpdateTaskRequestV2 => parsed(UpdateTaskRequestV2Codec, value); -export const isCancelTaskRequestV2: (value: unknown) => value is CancelTaskRequestV2 = (value: unknown): value is CancelTaskRequestV2 => parsed(CancelTaskRequestV2Codec, value); -export const isTaskStatusNotificationV2: (value: unknown) => value is TaskStatusNotificationV2 = (value: unknown): value is TaskStatusNotificationV2 => parsed(TaskStatusNotificationV2Codec, value); - -export function isToolCallTaskResultV2(method: string, value: unknown): value is CreateTaskResultV2 { +export const isTaskV2: (value: unknown) => value is TaskV2 = ( + value: unknown, +): value is TaskV2 => parsed(TaskV2Codec, value); +export const isDetailedTaskV2: (value: unknown) => value is DetailedTaskV2 = ( + value: unknown, +): value is DetailedTaskV2 => + parsed(DetailedTaskV2Codec, value); +export const isCreateTaskResultV2: ( + value: unknown, +) => value is CreateTaskResultV2 = ( + value: unknown, +): value is CreateTaskResultV2 => + parsed(CreateTaskResultV2Codec, value); +export const isGetTaskRequestV2: ( + value: unknown, +) => value is GetTaskRequestV2 = (value: unknown): value is GetTaskRequestV2 => + parsed(GetTaskRequestV2Codec, value); +export const isUpdateTaskRequestV2: ( + value: unknown, +) => value is UpdateTaskRequestV2 = ( + value: unknown, +): value is UpdateTaskRequestV2 => + parsed(UpdateTaskRequestV2Codec, value); +export const isCancelTaskRequestV2: ( + value: unknown, +) => value is CancelTaskRequestV2 = ( + value: unknown, +): value is CancelTaskRequestV2 => + parsed(CancelTaskRequestV2Codec, value); +export const isTaskStatusNotificationV2: ( + value: unknown, +) => value is TaskStatusNotificationV2 = ( + value: unknown, +): value is TaskStatusNotificationV2 => + parsed(TaskStatusNotificationV2Codec, value); + +export function isToolCallTaskResultV2( + method: string, + value: unknown, +): value is CreateTaskResultV2 { return method === "tools/call" && isCreateTaskResultV2(value); } -export const isEligibleTaskResultV2: typeof isToolCallTaskResultV2 = isToolCallTaskResultV2; +export const isEligibleTaskResultV2: typeof isToolCallTaskResultV2 = + isToolCallTaskResultV2; export function hasTaskClientCapabilityV2(value: unknown): boolean { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + if (value === null || typeof value !== "object" || Array.isArray(value)) + return false; const meta = (value as { _meta?: unknown })._meta; - if (meta === null || typeof meta !== "object" || Array.isArray(meta)) return false; - const capabilities = (meta as Record)[CLIENT_CAPABILITIES_META_KEY_V2]; - if (capabilities === null || typeof capabilities !== "object" || Array.isArray(capabilities)) return false; + if (meta === null || typeof meta !== "object" || Array.isArray(meta)) + return false; + const capabilities = (meta as Record)[ + CLIENT_CAPABILITIES_META_KEY_V2 + ]; + if ( + capabilities === null || + typeof capabilities !== "object" || + Array.isArray(capabilities) + ) + return false; const extensions = (capabilities as { extensions?: unknown }).extensions; - return extensions !== null && typeof extensions === "object" && !Array.isArray(extensions) && - Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2); -} -export function hasTaskServerCapabilityV2(value: unknown): value is ServerTaskCapabilityEnvelopeV2 { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + return ( + extensions !== null && + typeof extensions === "object" && + !Array.isArray(extensions) && + Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2) + ); +} +export function hasTaskServerCapabilityV2( + value: unknown, +): value is ServerTaskCapabilityEnvelopeV2 { + if (value === null || typeof value !== "object" || Array.isArray(value)) + return false; const extensions = (value as { extensions?: unknown }).extensions; - return extensions !== null && typeof extensions === "object" && !Array.isArray(extensions) && - Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2); -} -export const supportsTasksExtensionV2: typeof hasTaskServerCapabilityV2 = hasTaskServerCapabilityV2; - -export function withTaskCapabilityV2>>(params: T): T & Readonly> { + return ( + extensions !== null && + typeof extensions === "object" && + !Array.isArray(extensions) && + Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2) + ); +} +export const supportsTasksExtensionV2: typeof hasTaskServerCapabilityV2 = + hasTaskServerCapabilityV2; + +export function withTaskCapabilityV2< + T extends Readonly>, +>(params: T): T & Readonly> { const wireMeta = params._meta; - const base = wireMeta !== null && typeof wireMeta === "object" && !Array.isArray(wireMeta) ? wireMeta : {}; + const base = + wireMeta !== null && + typeof wireMeta === "object" && + !Array.isArray(wireMeta) + ? wireMeta + : {}; const capability = { extensions: { [TASKS_EXTENSION_ID_V2]: {} } }; - return { ...params, _meta: { ...base, [CLIENT_CAPABILITIES_META_KEY_V2]: capability } }; + return { + ...params, + _meta: { ...base, [CLIENT_CAPABILITIES_META_KEY_V2]: capability }, + }; } -export function contributeTaskFilterV2>>(filter: T, taskIds: readonly string[]): T & { readonly notifications: Readonly> & { readonly taskIds: readonly string[] } } { +export function contributeTaskFilterV2< + T extends Readonly>, +>( + filter: T, + taskIds: readonly string[], +): T & { + readonly notifications: Readonly> & { + readonly taskIds: readonly string[]; + }; +} { const notifications = filter.notifications; - const prior: Readonly> = notifications !== null && typeof notifications === "object" && !Array.isArray(notifications) ? notifications as Readonly> : {}; - return { ...filter, notifications: { ...prior, taskIds: [...new Set(taskIds)] } }; + const prior: Readonly> = + notifications !== null && + typeof notifications === "object" && + !Array.isArray(notifications) + ? (notifications as Readonly>) + : {}; + return { + ...filter, + notifications: { ...prior, taskIds: [...new Set(taskIds)] }, + }; } export function readAcceptedTaskIdsV2(value: unknown): readonly string[] { - if (value === null || typeof value !== "object" || Array.isArray(value)) return []; + if (value === null || typeof value !== "object" || Array.isArray(value)) + return []; const notifications = (value as { notifications?: unknown }).notifications; - if (notifications === null || typeof notifications !== "object" || Array.isArray(notifications)) return []; + if ( + notifications === null || + typeof notifications !== "object" || + Array.isArray(notifications) + ) + return []; const ids = (notifications as { taskIds?: unknown }).taskIds; - return Array.isArray(ids) && ids.every((id) => typeof id === "string") ? [...ids] : []; + return Array.isArray(ids) && ids.every((id) => typeof id === "string") + ? [...ids] + : []; } diff --git a/packages/ext-tasks/tsconfig.json b/packages/ext-tasks/tsconfig.json index c0f03d0..910154d 100644 --- a/packages/ext-tasks/tsconfig.json +++ b/packages/ext-tasks/tsconfig.json @@ -5,10 +5,6 @@ "rootDir": "src", "tsBuildInfoFile": "node_modules/.cache/ext-tasks.tsbuildinfo" }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "src/**/*.test.ts" - ] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] } diff --git a/packages/ext-tasks/tsconfig.test.json b/packages/ext-tasks/tsconfig.test.json index dc5de34..679649a 100644 --- a/packages/ext-tasks/tsconfig.test.json +++ b/packages/ext-tasks/tsconfig.test.json @@ -7,7 +7,5 @@ "isolatedDeclarations": false, "noEmit": true }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } From 5b181b94e3cf5bd11db1a2dcb5351c21e339a12f Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 4 Sep 2026 18:55:44 -0700 Subject: [PATCH 05/33] feat(client): implement task execution lifecycle - Add V1 and V2 task drivers with polling, notifications, and retries - Implement conflated updates, cancellation, and lifecycle cleanup - Cover protocol envelopes and adversarial races with property tests --- packages/ext-tasks/src/client/index.test.ts | 1069 ++++++++++++++++++- packages/ext-tasks/src/client/index.ts | 825 ++++++++++++-- 2 files changed, 1807 insertions(+), 87 deletions(-) diff --git a/packages/ext-tasks/src/client/index.test.ts b/packages/ext-tasks/src/client/index.test.ts index be89da8..50f9556 100644 --- a/packages/ext-tasks/src/client/index.test.ts +++ b/packages/ext-tasks/src/client/index.test.ts @@ -1,5 +1,5 @@ import fc from "fast-check"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createRuntimeCodec, @@ -12,6 +12,9 @@ import { DispatchError, InputCorrelationError, JsonRpcResponseError, + TaskCancellationUnsupportedError, + TaskExecutionClosedError, + TaskUpdatesAlreadyAcquiredError, withTasks, type ConnectedMcpSessionPort, type IncomingServerRequest, @@ -381,6 +384,30 @@ describe("client tool executions", () => { expect(sessionPort.requests).toEqual([]); await session.close(); }); + + it("cleans up call listeners when declaration lookup fails", async () => { + const port = new FakePort(); + const callController = new AbortController(); + const addListener = vi.spyOn(callController.signal, "addEventListener"); + const removeListener = vi.spyOn( + callController.signal, + "removeEventListener", + ); + const session = withTasks(port, { + tools: { + currentTool: () => { + throw new Error("declaration lookup failed"); + }, + }, + }); + await expect( + session.callTool("x", undefined, { signal: callController.signal }), + ).rejects.toThrow("declaration lookup failed"); + expect(port.requests).toEqual([]); + expect(addListener).toHaveBeenCalledTimes(1); + expect(removeListener).toHaveBeenCalledTimes(1); + await session.close(); + }); it("applies the exhaustive V1 capability-first task augmentation table", async () => { const support = fc.option( fc.constantFrom("forbidden", "optional", "required"), @@ -396,7 +423,29 @@ describe("client tool executions", () => { ? { requests: { tools: { call: {} } } } : {}; const port = new FakePort({ generation: "v1", capabilities }); - port.response = { kind: "result", result: { content: [] } }; + let taskSelected = false; + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/call") { + return taskSelected + ? { + kind: "result", + result: asJson({ + task: { + taskId: "property-task", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }, + }), + } + : { kind: "result", result: { content: [] } }; + } + if (record.method === "tasks/result") + return { kind: "result", result: { content: [] } }; + throw new Error(`unexpected method ${String(record.method)}`); + }; let lookups = 0; const tool: ToolV1 = { name: "x", @@ -411,17 +460,21 @@ describe("client tool executions", () => { }, }, }); - const taskSelected = + taskSelected = present && (taskSupport === "required" || (taskSupport === "optional" && preferTask)); + const execution = await session.callTool("x", undefined, { + preferTask, + }); if (taskSelected) { - await expect( - session.callTool("x", undefined, { preferTask }), - ).rejects.toThrow("Task execution is not supported"); - expect(port.requests).toEqual([]); + expect(execution.kind).toBe("task"); + expect(port.requests).toEqual([ + { method: "tools/call", params: { name: "x", task: {} } }, + { method: "tasks/result", params: { taskId: "property-task" } }, + ]); } else { - await session.callTool("x", undefined, { preferTask }); + expect(execution.kind).toBe("immediate"); expect(port.requests).toEqual([ { method: "tools/call", params: { name: "x" } }, ]); @@ -433,7 +486,7 @@ describe("client tool executions", () => { ); }); - it("does not mistake open ordinary result extensions for task creation", async () => { + it("does not treat an application task field as task creation", async () => { const port = new FakePort({ generation: "v1", capabilities: {} }); port.response = { kind: "result", @@ -528,11 +581,29 @@ describe("client tool executions", () => { let listCount = 0; port.dispatchHandler = (request, options) => { const record = expectRecord(request); + if (record.method === "tools/call") { + const params = expectRecord(record.params as JsonValue); + return Promise.resolve( + params.task === undefined + ? { kind: "result", result: { content: [] } } + : { + kind: "result", + result: asJson({ + task: { + taskId: "newest", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }, + }), + }, + ); + } + if (record.method === "tasks/result") + return Promise.resolve({ kind: "result", result: { content: [] } }); if (record.method !== "tools/list") - return Promise.resolve({ - kind: "result", - result: asJson({ content: [] }), - }); + throw new Error(`unexpected method ${String(record.method)}`); listCount += 1; if (listCount === 1) { return Promise.resolve({ @@ -573,14 +644,12 @@ describe("client tool executions", () => { }), }); await Promise.resolve(); - await expect(session.callTool("x")).rejects.toThrow( - "Task execution is not supported", - ); - expect( - port.requests.every( - (request) => expectRecord(request).method === "tools/list", - ), - ).toBe(true); + const execution = await session.callTool("x"); + expect(execution.kind).toBe("task"); + expect(port.requests.slice(-2)).toEqual([ + { method: "tools/call", params: { name: "x", task: {} } }, + { method: "tasks/result", params: { taskId: "newest" } }, + ]); await session.close(); }); @@ -646,22 +715,974 @@ describe("client tool executions", () => { ); }); const closeSession = withTasks(closePort); - const pendingCall = closeSession.callTool("x"); + const closeCallController = new AbortController(); + const addListener = vi.spyOn( + closeCallController.signal, + "addEventListener", + ); + const removeListener = vi.spyOn( + closeCallController.signal, + "removeEventListener", + ); + const pendingCall = closeSession.callTool("x", undefined, { + signal: closeCallController.signal, + }); await closeSession.close(); await expect(pendingCall).rejects.toThrow(/closed|aborted/i); expect(refreshSignal?.aborted).toBe(true); expect(closePort.requests).toHaveLength(1); + expect(addListener).toHaveBeenCalledTimes(1); + expect(removeListener).toHaveBeenCalledTimes(1); }); - it("rejects an unambiguous generation-mismatched declaration before dispatch", async () => { + it("rejects generation-mismatched declarations without leaking call listeners", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); const v1Tool: ToolV1 = { name: "x", inputSchema: { type: "object" }, execution: { taskSupport: "required" }, }; + const callController = new AbortController(); + const addListener = vi.spyOn(callController.signal, "addEventListener"); + const removeListener = vi.spyOn( + callController.signal, + "removeEventListener", + ); const session = withTasks(port, { tools: { currentTool: () => v1Tool } }); - await expect(session.callTool("x")).rejects.toThrow(/generation/i); + for (let attempt = 0; attempt < 3; attempt += 1) { + await expect( + session.callTool("x", undefined, { signal: callController.signal }), + ).rejects.toThrow( + "V1 tool declaration is incompatible with the V2 session", + ); + } expect(port.requests).toEqual([]); + expect(addListener).toHaveBeenCalledTimes(3); + expect(removeListener).toHaveBeenCalledTimes(3); + await session.close(); + }); + + it("drives a V1 task to a separately retrieved result", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } }, cancel: {} }, + }); + const tool: ToolV1 = { + name: "long", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }; + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/call") { + return { + kind: "result", + result: asJson({ + task: { + taskId: "v1-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }, + }), + }; + } + if (record.method === "tasks/get") { + return { + kind: "result", + result: asJson({ + taskId: "v1-task", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }), + }; + } + if (record.method === "tasks/result") { + return { + kind: "result", + result: asJson({ content: [{ type: "text", text: "done" }] }), + }; + } + if (record.method === "tasks/cancel") { + return { + kind: "result", + result: asJson({ + taskId: "v1-task", + status: "cancelled", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }), + }; + } + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { tools: { currentTool: () => tool } }); + const execution = await session.callTool("long"); + expect(execution.kind).toBe("task"); + expect(execution.handle).toEqual({ + generation: "v1", + taskId: "v1-task", + originalOperation: "tools/call", + }); + const snapshots: unknown[] = []; + for await (const snapshot of execution.updates()) snapshots.push(snapshot); + expect(snapshots).toEqual([ + { + generation: "v1", + task: { + taskId: "v1-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }, + }, + { + generation: "v1", + task: { + taskId: "v1-task", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }, + }, + ]); + const first = execution.result(); + expect(execution.result()).toBe(first); + await expect(first).resolves.toEqual({ + content: [{ type: "text", text: "done" }], + }); + await session.close(); + }); + + it("drives a V2 task to its inline terminal result", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + const tool = { name: "long", inputSchema: { type: "object" } }; + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/call") { + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "v2-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + } + if (record.method === "tasks/get") { + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "v2-task", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { + resultType: "complete", + content: [{ type: "text", text: "done" }], + }, + }), + }; + } + if (record.method === "tasks/cancel") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { tools: { currentTool: () => tool } }); + const execution = await session.callTool("long"); + expect(execution.kind).toBe("task"); + expect(execution.handle).toEqual({ + generation: "v2", + taskId: "v2-task", + originalOperation: "tools/call", + }); + expect(port.requests[0]).toMatchObject({ + method: "tools/call", + params: { + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }, + }); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [{ type: "text", text: "done" }], + }); + await session.close(); + }); + + it("shares cancellation and enforces single-consumer task updates", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "pending", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ); + }); + if (record.method === "tasks/cancel") { + cancelCalls += 1; + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + execution.updates(); + expect(() => execution.updates()).toThrow(TaskUpdatesAlreadyAcquiredError); + const firstCancel = execution.cancel(); + expect(execution.cancel()).toBe(firstCancel); + await firstCancel; + expect(cancelCalls).toBe(1); + expect( + port.requests.find( + (request) => expectRecord(request).method === "tasks/cancel", + ), + ).toMatchObject({ + params: { + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }, + }); + await execution.close(); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + await session.close(); + }); + + it("session close cancels and closes active task executions", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "session-close", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ); + }); + if (record.method === "tasks/cancel") { + cancelCalls += 1; + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await session.close(); + expect(cancelCalls).toBe(1); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + }); + + it("retries task observations once after any DispatchError", async () => { + await fc.assert( + fc.asyncProperty(fc.boolean(), async (retryable) => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "retry-get", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") { + getCalls += 1; + if (getCalls === 1) + throw new DispatchError("observe failed", retryable); + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "retry-get", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }), + }; + } + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(getCalls).toBe(2); + await session.close(); + }), + { numRuns: 10 }, + ); + }); + + it("retries cancellation only for proven retryable dispatch failures", async () => { + await fc.assert( + fc.asyncProperty(fc.boolean(), async (retryable) => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "retry-cancel", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ); + }); + if (record.method === "tasks/cancel") { + cancelCalls += 1; + if (cancelCalls === 1) + throw new DispatchError("cancel failed", retryable); + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + if (retryable) + await expect(execution.cancel()).resolves.toBeUndefined(); + else await expect(execution.cancel()).rejects.toThrow("cancel failed"); + expect(cancelCalls).toBe(retryable ? 2 : 1); + await execution.close(); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + await session.close(); + }), + ); + }); + + it("conflates nonterminal task updates and always delivers terminal", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "conflate", + status: "working", + statusMessage: "initial", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + pollIntervalMs: 1000, + }), + }; + if (record.method === "tasks/get") return new Promise(() => {}); + if (record.method === "tasks/cancel") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const iterator = execution.updates()[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ + value: { task: { statusMessage: "initial" } }, + }); + for (const statusMessage of ["one", "one", "two", "three"]) { + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "conflate", + status: "working", + statusMessage, + createdAt: "a", + lastUpdatedAt: statusMessage, + ttlMs: null, + pollIntervalMs: 1000, + }, + }), + ); + } + await Promise.resolve(); + await expect(iterator.next()).resolves.toMatchObject({ + value: { task: { statusMessage: "three" } }, + }); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "conflate", + status: "completed", + createdAt: "a", + lastUpdatedAt: "z", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + }), + ); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "conflate", + status: "working", + statusMessage: "late", + createdAt: "a", + lastUpdatedAt: "late", + ttlMs: null, + pollIntervalMs: 1000, + }, + }), + ); + await expect(iterator.next()).resolves.toMatchObject({ + value: { task: { status: "completed" } }, + }); + await expect(iterator.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + await session.close(); + }); + + it("does not retry complete JSON-RPC task errors", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "rpc-error", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") { + getCalls += 1; + return { kind: "error", error: { code: -32000, message: "failed" } }; + } + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).rejects.toBeInstanceOf( + JsonRpcResponseError, + ); + expect(getCalls).toBe(1); + await session.close(); + }); + + it("fetches V2 details when task creation is already terminal", async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom("completed", "failed", "cancelled"), + async (status) => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "terminal-at-creation", + status, + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") { + getCalls += 1; + const terminal = { + resultType: "complete", + taskId: "terminal-at-creation", + status, + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + }; + return { + kind: "result", + result: asJson( + status === "completed" + ? { + ...terminal, + result: { resultType: "complete", content: [] }, + } + : status === "failed" + ? { + ...terminal, + error: { code: -32000, message: "task failed" }, + } + : terminal, + ), + }; + } + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ + name: "x", + inputSchema: { type: "object" }, + }), + }, + }); + const execution = await session.callTool("x"); + if (status === "completed") + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + else if (status === "failed") + await expect(execution.result()).rejects.toMatchObject({ + name: "JsonRpcResponseError", + code: -32000, + message: "task failed", + }); + else await expect(execution.result()).rejects.toThrow(/cancel/i); + expect(getCalls).toBe(1); + await session.close(); + }, + ), + { numRuns: 9 }, + ); + }); + + it("routes matching task notifications without cancelling the task", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + let getCalls = 0; + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "notify", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + pollIntervalMs: 1000, + }), + }; + if (record.method === "tasks/get") { + getCalls += 1; + return new Promise(() => {}); + } + if (record.method === "tasks/cancel") { + cancelCalls += 1; + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const observer = new AbortController(); + const iterator = execution.updates(observer.signal)[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ + value: { + generation: "v2", + task: { taskId: "notify", status: "working" }, + }, + }); + const waiting = iterator.next(); + observer.abort(new Error("observer done")); + await expect(waiting).rejects.toThrow("observer done"); + expect(cancelCalls).toBe(0); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "wrong", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + }), + ); + await Promise.resolve(); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "notify", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + }), + ); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(cancelCalls).toBe(0); + expect(getCalls).toBe(0); + await session.close(); + }); + + it("closes promptly when remote cancellation never settles", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "stuck-cancel", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ); + }); + if (record.method === "tasks/cancel") return new Promise(() => {}); + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await expect(execution.close()).resolves.toBeUndefined(); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + await expect(session.close()).resolves.toBeUndefined(); + }); + + it("invalidating a session aborts active task executions", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "invalidate-active", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ); + }); + if (record.method === "tasks/cancel") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + port.invalidate(new Error("session replaced")); + await expect(execution.result()).rejects.toThrow("session replaced"); + await session.close(); + }); + + it("a terminal notification preempts an in-flight observation", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getStarted = false; + let observationSignal: AbortSignal | undefined; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "preempt", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + pollIntervalMs: 10, + }), + }; + if (record.method === "tasks/get") { + getStarted = true; + const signal = options?.signal; + if (signal === undefined) + throw new Error("observation signal is required"); + observationSignal = signal; + return new Promise((_resolve, reject) => + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }), + ); + } + if (record.method === "tasks/cancel") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + while (!getStarted) await new Promise((resolve) => setTimeout(resolve, 1)); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "preempt", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + }), + ); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(observationSignal?.aborted).toBe(true); + await session.close(); + }); + + it("caller abort does not poison the shared cancellation attempt", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + let finishCancel: (() => void) | undefined; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "cancel-waiter", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ), + ); + if (record.method === "tasks/cancel") { + cancelCalls += 1; + await new Promise((resolve) => { + finishCancel = resolve; + }); + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const waiter = new AbortController(); + const first = execution.cancel(waiter.signal); + waiter.abort(new Error("waiter stopped")); + await expect(first).rejects.toThrow("waiter stopped"); + const second = execution.cancel(); + finishCancel?.(); + await expect(second).resolves.toBeUndefined(); + expect(cancelCalls).toBe(1); + await execution.close(); + await session.close(); + }); + + it("identifies unsupported V1 cancellation without dispatching it", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } } }, + }); + const tool: ToolV1 = { + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + task: { + taskId: "no-cancel", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true }, + ), + ); + throw new Error(`unexpected method ${String(record.method)}`); + }; + const session = withTasks(port, { tools: { currentTool: () => tool } }); + const execution = await session.callTool("x"); + await expect(execution.cancel()).rejects.toBeInstanceOf( + TaskCancellationUnsupportedError, + ); + expect( + port.requests.some( + (request) => expectRecord(request).method === "tasks/cancel", + ), + ).toBe(false); + await execution.close(); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); await session.close(); }); }); diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts index a2f79c2..f7a121b 100644 --- a/packages/ext-tasks/src/client/index.ts +++ b/packages/ext-tasks/src/client/index.ts @@ -10,19 +10,30 @@ import { } from "../core/index.js"; import { CallToolResultV1Codec, + CancelTaskResultV1Codec, CreateTaskResultV1Codec, + GetTaskResultV1Codec, + TaskResultV1Codec, + TaskStatusNotificationV1Codec, ToolV1Codec, shouldCallToolAsTaskV1, type CallToolResultV1, type ServerTaskCapabilitiesV1, type TaskEligibleMethodV1, + type TaskV1, type ToolV1, } from "../core/v1/index.js"; import { CallToolResultV2Codec, + CancelTaskResultV2Codec, + CreateTaskResultV2Codec, + GetTaskResultV2Codec, + TaskStatusNotificationV2Codec, ToolV2Codec, isCreateTaskResultV2, + withTaskCapabilityV2, type CallToolResultV2, + type DetailedTaskV2, type ErrorV2, type TaskExtensionCapabilitiesV2, type TaskEligibleMethodV2, @@ -88,7 +99,15 @@ export class JsonRpcResponseError extends Error { } export interface ToolDeclarationProvider { - currentTool(name: string): ToolV1 | ToolV2 | undefined; + currentTool(name: string): + | { + readonly name: string; + readonly inputSchema: Readonly>; + readonly execution?: { + readonly taskSupport?: "forbidden" | "optional" | "required"; + }; + } + | undefined; } export type ApplicationInputRequest = @@ -238,6 +257,13 @@ export class TaskExecutionClosedError extends Error { } } +export class TaskCancellationUnsupportedError extends Error { + constructor() { + super("Task cancellation is not supported"); + this.name = "TaskCancellationUnsupportedError"; + } +} + export interface TaskEnabledSession { callTool( name: string, @@ -293,6 +319,382 @@ function unsupported(feature: string): Error { return new Error(`${feature} is not supported`); } +const DEFAULT_TASK_POLL_INTERVAL_MS = 10; +const V2_INPUT_REQUIRED_UNSUPPORTED_MESSAGE = + "V2 input_required tasks are not supported until tasks/update is available"; + +type TaskTurn = + { readonly sequence: number; readonly snapshot: TaskSnapshot } | undefined; + +type TaskDriver = ( + accept: (snapshot: TaskSnapshot) => void, + waitForTurn: ( + afterSequence: number, + delayMs: number | undefined, + ) => Promise, + observe: ( + afterSequence: number, + observation: (signal: AbortSignal) => Promise, + ) => Promise, + signal: AbortSignal, + cancelledError: Error, + closedError: Error, + isClosed: () => boolean, +) => Promise; + +class TaskExecution< + TResult, + TApplicationContext, +> implements ToolExecutionCommon { + readonly kind = "task" as const; + private readonly controller = new AbortController(); + private readonly cancellationController = new AbortController(); + private readonly resultPromise: Promise; + private readonly cancelledError = new Error("Task was cancelled"); + private readonly closedError = new TaskExecutionClosedError(); + private readonly notificationWaiters = new Set<() => void>(); + private readonly updateWaiters = new Set<() => void>(); + private initialSnapshot: TaskSnapshot | undefined; + private pendingSnapshot: TaskSnapshot | undefined; + private terminalSnapshot: TaskSnapshot | undefined; + private terminalSnapshotBytes: string | undefined; + private lastAcceptedBytes: string; + private notificationSequence = 0; + private latestNotification: TaskSnapshot | undefined; + private updatesAcquired = false; + private cancelPromise: Promise | undefined; + private closed = false; + + constructor( + readonly applicationContext: TApplicationContext, + readonly handle: TaskHandle, + initialSnapshot: TaskSnapshot, + driver: TaskDriver, + private readonly cancelTask: (signal?: AbortSignal) => Promise, + lifecycleSignal?: AbortSignal, + ) { + this.initialSnapshot = initialSnapshot; + this.lastAcceptedBytes = deterministicJson(initialSnapshot); + if (lifecycleSignal !== undefined) { + const abort = (): void => this.controller.abort(lifecycleSignal.reason); + if (lifecycleSignal.aborted) abort(); + else lifecycleSignal.addEventListener("abort", abort, { once: true }); + } + this.resultPromise = driver( + (snapshot) => this.accept(snapshot), + (afterSequence, delayMs) => this.waitForTurn(afterSequence, delayMs), + (afterSequence, observation) => + this.observeOrNotification(afterSequence, observation), + this.controller.signal, + this.cancelledError, + this.closedError, + () => this.closed, + ); + } + + onNotification(snapshot: TaskSnapshot): void { + if (this.closed || snapshot.generation !== this.handle.generation) return; + if (snapshot.task.taskId !== this.handle.taskId) return; + const bytes = deterministicJson(snapshot); + if (terminalStatus(snapshot.task.status)) { + if (this.terminalSnapshotBytes === undefined) { + this.terminalSnapshot = snapshot; + this.terminalSnapshotBytes = bytes; + } + } else if (this.terminalSnapshotBytes !== undefined) { + return; + } + this.latestNotification = snapshot; + this.notificationSequence += 1; + for (const wake of this.notificationWaiters) wake(); + this.notificationWaiters.clear(); + for (const wake of this.updateWaiters) wake(); + this.updateWaiters.clear(); + } + + updates(signal?: AbortSignal): AsyncIterable { + if (this.updatesAcquired) throw new TaskUpdatesAlreadyAcquiredError(); + this.updatesAcquired = true; + const execution = this; + return { + async *[Symbol.asyncIterator]() { + while (true) { + throwIfAborted(signal); + if (execution.initialSnapshot !== undefined) { + const snapshot = execution.initialSnapshot; + execution.initialSnapshot = undefined; + yield snapshot; + continue; + } + if (execution.pendingSnapshot !== undefined) { + const snapshot = execution.pendingSnapshot; + execution.pendingSnapshot = undefined; + yield snapshot; + continue; + } + if (execution.terminalSnapshot !== undefined) { + const snapshot = execution.terminalSnapshot; + execution.terminalSnapshot = undefined; + yield snapshot; + continue; + } + const settled = await execution.waitForUpdateOrResult(signal); + if ( + !settled && + execution.initialSnapshot === undefined && + execution.pendingSnapshot === undefined && + execution.terminalSnapshot === undefined + ) + return; + } + }, + }; + } + + private accept(snapshot: TaskSnapshot): void { + if (this.closed) return; + const bytes = deterministicJson(snapshot); + if (bytes === this.lastAcceptedBytes) return; + this.lastAcceptedBytes = bytes; + if (terminalStatus(snapshot.task.status)) { + if (bytes !== this.terminalSnapshotBytes) { + this.terminalSnapshot ??= snapshot; + this.terminalSnapshotBytes ??= bytes; + } + } else if (this.terminalSnapshotBytes === undefined) { + this.pendingSnapshot = snapshot; + } + for (const wake of this.updateWaiters) wake(); + this.updateWaiters.clear(); + } + + private async waitForUpdateOrResult(signal?: AbortSignal): Promise { + if ( + this.pendingSnapshot !== undefined || + this.terminalSnapshot !== undefined + ) + return true; + let wake: (() => void) | undefined; + const updated = new Promise((resolve) => { + wake = () => resolve(true); + this.updateWaiters.add(wake); + }); + try { + return await withAbort( + Promise.race([ + updated, + this.resultPromise.then( + () => false, + () => false, + ), + ]), + signal, + ); + } finally { + if (wake !== undefined) this.updateWaiters.delete(wake); + } + } + + private currentTurn(afterSequence: number): TaskTurn { + if ( + this.notificationSequence > afterSequence && + this.latestNotification !== undefined + ) { + return { + sequence: this.notificationSequence, + snapshot: this.latestNotification, + }; + } + return undefined; + } + + private async waitForTurn( + afterSequence: number, + delayMs: number | undefined, + ): Promise { + const current = this.currentTurn(afterSequence); + if (current !== undefined) return current; + if (delayMs === undefined) { + await Promise.resolve(); + throwIfAborted(this.controller.signal); + return this.currentTurn(afterSequence); + } + await new Promise((resolve, reject) => { + let timeout: ReturnType; + const finish = (error?: unknown): void => { + clearTimeout(timeout); + this.notificationWaiters.delete(onNotification); + this.controller.signal.removeEventListener("abort", onAbort); + if (error === undefined) resolve(); + else reject(error); + }; + const onNotification = (): void => finish(); + const onAbort = (): void => finish(this.controller.signal.reason); + timeout = setTimeout(onNotification, Math.max(0, delayMs)); + this.notificationWaiters.add(onNotification); + this.controller.signal.addEventListener("abort", onAbort, { once: true }); + }); + return this.currentTurn(afterSequence); + } + + private async observeOrNotification( + afterSequence: number, + observation: (signal: AbortSignal) => Promise, + ): Promise { + const current = this.currentTurn(afterSequence); + if (current !== undefined) return current; + const observationLifecycle = linkAbortSignals(this.controller.signal); + const observationPromise = observation(observationLifecycle.signal); + void observationPromise.catch(() => {}); + let wake: (() => void) | undefined; + const notified = new Promise((resolve) => { + wake = () => resolve(this.currentTurn(afterSequence)); + this.notificationWaiters.add(wake); + }); + try { + return await withAbort( + Promise.race([ + observationPromise.then((snapshot) => ({ + sequence: afterSequence, + snapshot, + })), + notified, + ]), + this.controller.signal, + ); + } finally { + if (!observationLifecycle.signal.aborted) observationLifecycle.abort(); + observationLifecycle.dispose(); + if (wake !== undefined) this.notificationWaiters.delete(wake); + } + } + + result(): Promise { + return this.resultPromise; + } + + cancel(signal?: AbortSignal): Promise { + throwIfAborted(signal); + this.cancelPromise ??= this.cancelTask(this.cancellationController.signal); + return signal === undefined + ? this.cancelPromise + : withAbort(this.cancelPromise, signal); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.controller.abort(this.closedError); + void this.cancel().catch(() => { + // Cooperative cancellation is best effort during close. + }); + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } +} + +function deterministicJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) + return `[${value.map(deterministicJson).join(",")}]`; + const record = value as Readonly>; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${deterministicJson(record[key])}`) + .join(",")}}`; +} + +async function withAbort( + promise: Promise, + signal?: AbortSignal, +): Promise { + if (signal === undefined) return promise; + throwIfAborted(signal); + let onAbort: (() => void) | undefined; + const aborted = new Promise((_, reject) => { + onAbort = () => + reject( + signal.reason instanceof Error + ? signal.reason + : new DOMException("The operation was aborted", "AbortError"), + ); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([promise, aborted]); + } finally { + if (onAbort !== undefined) signal.removeEventListener("abort", onAbort); + } +} + +function linkAbortSignals(...signals: readonly (AbortSignal | undefined)[]): { + readonly signal: AbortSignal; + readonly abort: (reason?: unknown) => void; + readonly dispose: () => void; +} { + const controller = new AbortController(); + const listeners: (() => void)[] = []; + for (const signal of signals) { + if (signal === undefined) continue; + const abort = (): void => controller.abort(signal.reason); + if (signal.aborted) { + abort(); + break; + } + signal.addEventListener("abort", abort, { once: true }); + listeners.push(() => signal.removeEventListener("abort", abort)); + } + return { + signal: controller.signal, + abort: (reason) => controller.abort(reason), + dispose: () => { + for (const remove of listeners) remove(); + }, + }; +} + +async function dispatchWithRetry( + port: ConnectedMcpSessionPort, + request: JsonValue, + signal: AbortSignal | undefined, + retry: "observe" | "mutate", +): Promise { + const options = signal === undefined ? undefined : { signal }; + try { + return await port.dispatch(request, options); + } catch (error) { + throwIfAborted(signal); + if ( + !(error instanceof DispatchError) || + (retry === "mutate" && !error.retryable) + ) { + throw error; + } + return port.dispatch(request, options); + } +} + +function decodeResult(codec: RuntimeCodec, value: JsonValue): T { + const decoded = codec.parse(value); + if (!decoded.success) throw decoded.error; + return decoded.value; +} + +function responseResult(response: JsonRpcResponse): JsonValue { + if (response.kind === "error") throw new JsonRpcResponseError(response.error); + return response.result; +} + +function terminalStatus( + status: TaskV1["status"] | DetailedTaskV2["status"], +): boolean { + return ( + status === "completed" || status === "failed" || status === "cancelled" + ); +} + class ImmediateExecution< TResult, TApplicationContext, @@ -511,6 +913,7 @@ class PortTaskEnabledSession< TApplicationContext, > implements TaskEnabledSession { private closed = false; + private readonly lifecycleController = new AbortController(); private invalidationError: Error | undefined; private readonly disposeListeners: readonly (() => void)[]; private readonly declarations: ToolDeclarationProvider; @@ -519,6 +922,9 @@ class PortTaskEnabledSession< string, OrdinaryInputCandidate >(); + private readonly activeTaskExecutions = new Set< + TaskExecution + >(); constructor( private readonly port: ConnectedMcpSessionPort, @@ -537,12 +943,12 @@ class PortTaskEnabledSession< : undefined; this.declarations = options.tools ?? this.managedDeclarations!; const onSessionAbort = (): void => { - if (this.invalidationError === undefined) { - this.invalidationError = - options.signal?.reason instanceof Error - ? options.signal.reason - : new DOMException("The session was aborted", "AbortError"); - } + const error = + options.signal?.reason instanceof Error + ? options.signal.reason + : new DOMException("The session was aborted", "AbortError"); + this.invalidationError ??= error; + this.lifecycleController.abort(error); this.managedDeclarations?.close(); }; options.signal?.addEventListener("abort", onSessionAbort, { once: true }); @@ -551,11 +957,12 @@ class PortTaskEnabledSession< this.handleServerRequest(incoming), ), port.onNotification((notification) => - this.managedDeclarations?.onNotification(notification), + this.handleNotification(notification), ), port.onInvalidated((reason) => { - if (this.invalidationError === undefined) - this.invalidationError = reasonAsError(reason); + const error = reasonAsError(reason); + this.invalidationError ??= error; + this.lifecycleController.abort(error); this.managedDeclarations?.close(); }), () => options.signal?.removeEventListener("abort", onSessionAbort), @@ -563,7 +970,9 @@ class PortTaskEnabledSession< ]; if (options.signal?.aborted === true) onSessionAbort(); if (port.invalidated) { - this.invalidationError = new Error("MCP session was invalidated"); + const error = new Error("MCP session was invalidated"); + this.invalidationError = error; + this.lifecycleController.abort(error); this.managedDeclarations?.close(); } } @@ -579,74 +988,295 @@ class PortTaskEnabledSession< } = {}, ): Promise> { this.assertUsable(); - throwIfAborted(options.signal); - await this.managedDeclarations?.ensureReady(options.signal); - this.assertUsable(); - const declaration = this.declarations.currentTool(name); + const callLifecycle = linkAbortSignals( + this.lifecycleController.signal, + options.signal, + ); + const callSignal = callLifecycle.signal; + let declaration: ReturnType; + try { + throwIfAborted(callSignal); + await this.managedDeclarations?.ensureReady(callSignal); + this.assertUsable(); + declaration = this.declarations.currentTool(name); + } catch (error) { + callLifecycle.dispose(); + throw error; + } if ( this.port.taskCapabilities.generation === "v2" && declaration !== undefined && "execution" in declaration ) { + callLifecycle.dispose(); throw new Error( - "Tool declaration generation does not match the V2 session", + "V1 tool declaration is incompatible with the V2 session", ); } const requestParams: Record = { name }; if (params !== undefined) requestParams.arguments = params; - if ( - this.port.taskCapabilities.generation === "v1" && + const generation = this.port.taskCapabilities.generation; + const callAsTaskV1 = + generation === "v1" && declaration !== undefined && "execution" in declaration && shouldCallToolAsTaskV1( this.port.taskCapabilities.capabilities, declaration as ToolV1, options.preferTask, - ) - ) { - throw unsupported("Task execution"); - } + ); + if (callAsTaskV1) requestParams.task = {}; const executionId = `execution-${++nextExecutionId}`; - this.ordinaryInputCandidates.set(executionId, { - generation: - this.port.taskCapabilities.generation === "none" - ? "v1" - : this.port.taskCapabilities.generation, - toolName: name, - executionId, - applicationContext: options.applicationContext as TApplicationContext, - ...(options.signal === undefined ? {} : { signal: options.signal }), - }); + if (!callAsTaskV1) { + this.ordinaryInputCandidates.set(executionId, { + generation: generation === "none" ? "v1" : generation, + toolName: name, + executionId, + applicationContext: options.applicationContext as TApplicationContext, + signal: callSignal, + }); + } + const dispatchPromise = dispatchWithRetry( + this.port, + { + method: "tools/call", + params: + generation === "v2" + ? withTaskCapabilityV2(requestParams) + : requestParams, + }, + callSignal, + "mutate", + ); let response: JsonRpcResponse; try { - response = await this.port.dispatch( - { method: "tools/call", params: requestParams }, - options.signal === undefined ? undefined : { signal: options.signal }, + response = await withAbort(dispatchPromise, callSignal); + } catch (error) { + void dispatchPromise.then( + (lateResponse) => + this.cleanupLateTaskCreation(lateResponse, generation, callAsTaskV1), + () => {}, ); + throw error; } finally { this.ordinaryInputCandidates.delete(executionId); + callLifecycle.dispose(); } - this.assertUsable(); - throwIfAborted(options.signal); - if (response.kind === "error") - throw new JsonRpcResponseError(response.error); - if ( - isTaskResultForGeneration( - this.port.taskCapabilities.generation, - response.result, - ) - ) { - throw unsupported("Task-result execution"); + try { + this.assertUsable(); + throwIfAborted(callSignal); + } catch (error) { + this.cleanupLateTaskCreation(response, generation, callAsTaskV1); + throw error; } - + const wireResult = responseResult(response); const codec = options.resultCodec ?? - (defaultResultCodec( - this.port.taskCapabilities.generation, - ) as RuntimeCodec); - const decoded = codec.parse(response.result); - if (!decoded.success) throw decoded.error; - const resultPromise = Promise.resolve(decoded.value); + (defaultResultCodec(generation) as RuntimeCodec); + + if (generation === "v1" && callAsTaskV1) { + const created = decodeResult(CreateTaskResultV1Codec, wireResult); + const handle: TaskHandle = { + generation: "v1", + taskId: created.task.taskId as TaskId, + originalOperation: "tools/call", + }; + const initial: TaskSnapshot = { generation: "v1", task: created.task }; + return this.trackTaskExecution( + new TaskExecution( + options.applicationContext as TApplicationContext, + handle, + initial, + async ( + accept, + waitForTurn, + observe, + signal, + cancelledError, + closedError, + isClosed, + ) => { + let task = created.task; + let notificationSequence = 0; + while (!terminalStatus(task.status)) { + const turn = await waitForTurn( + notificationSequence, + Math.max( + DEFAULT_TASK_POLL_INTERVAL_MS, + task.pollInterval ?? DEFAULT_TASK_POLL_INTERVAL_MS, + ), + ); + const observed = + turn ?? + (await observe(notificationSequence, (observationSignal) => + dispatchWithRetry( + this.port, + { method: "tasks/get", params: { taskId: task.taskId } }, + observationSignal, + "observe", + ).then((response) => ({ + generation: "v1" as const, + task: decodeResult( + GetTaskResultV1Codec, + responseResult(response), + ), + })), + )); + if (observed?.snapshot.generation !== "v1") continue; + notificationSequence = observed.sequence; + task = observed.snapshot.task; + if (!isClosed()) accept({ generation: "v1", task }); + } + if (isClosed()) throw closedError; + if (task.status === "cancelled") throw cancelledError; + if (task.status === "failed") + throw new Error(task.statusMessage ?? "Task failed"); + const taskResult = responseResult( + await dispatchWithRetry( + this.port, + { method: "tasks/result", params: { taskId: task.taskId } }, + signal, + "observe", + ), + ); + decodeResult(TaskResultV1Codec, taskResult); + return decodeResult(codec, taskResult); + }, + async (signal) => { + const capabilities = this.port.taskCapabilities; + if ( + capabilities.generation !== "v1" || + capabilities.capabilities.cancel === undefined + ) + throw new TaskCancellationUnsupportedError(); + const cancelled = responseResult( + await dispatchWithRetry( + this.port, + { + method: "tasks/cancel", + params: { taskId: created.task.taskId }, + }, + signal, + "mutate", + ), + ); + decodeResult(CancelTaskResultV1Codec, cancelled); + }, + this.lifecycleController.signal, + ), + ); + } + + if (generation === "v2" && isCreateTaskResultV2(wireResult)) { + const created = decodeResult(CreateTaskResultV2Codec, wireResult); + const handle: TaskHandle = { + generation: "v2", + taskId: created.taskId as TaskId, + originalOperation: "tools/call", + }; + const initial: TaskSnapshot = { generation: "v2", task: created }; + return this.trackTaskExecution( + new TaskExecution( + options.applicationContext as TApplicationContext, + handle, + initial, + async ( + accept, + waitForTurn, + observe, + signal, + cancelledError, + closedError, + isClosed, + ) => { + let status = created.status; + let current: DetailedTaskV2 | undefined; + let notificationSequence = 0; + if (status === "input_required") + throw new Error(V2_INPUT_REQUIRED_UNSUPPORTED_MESSAGE); + while (!terminalStatus(status)) { + const delayMs = Math.max( + DEFAULT_TASK_POLL_INTERVAL_MS, + current?.pollIntervalMs ?? + created.pollIntervalMs ?? + DEFAULT_TASK_POLL_INTERVAL_MS, + ); + const turn = await waitForTurn(notificationSequence, delayMs); + const observed = + turn ?? + (await observe(notificationSequence, (observationSignal) => + dispatchWithRetry( + this.port, + { + method: "tasks/get", + params: withTaskCapabilityV2({ taskId: created.taskId }), + }, + observationSignal, + "observe", + ).then((response) => ({ + generation: "v2" as const, + task: decodeResult( + GetTaskResultV2Codec, + responseResult(response), + ), + })), + )); + if (observed?.snapshot.generation !== "v2") continue; + notificationSequence = observed.sequence; + const next = observed.snapshot.task as DetailedTaskV2; + current = next; + status = next.status; + if (!isClosed()) accept({ generation: "v2", task: next }); + if (status === "input_required") + throw new Error(V2_INPUT_REQUIRED_UNSUPPORTED_MESSAGE); + } + if (isClosed()) throw closedError; + if (current === undefined) { + current = decodeResult( + GetTaskResultV2Codec, + responseResult( + await dispatchWithRetry( + this.port, + { + method: "tasks/get", + params: withTaskCapabilityV2({ taskId: created.taskId }), + }, + signal, + "observe", + ), + ), + ); + if (!isClosed()) accept({ generation: "v2", task: current }); + } + if (current.status === "cancelled") throw cancelledError; + if (current.status === "failed") + throw new JsonRpcResponseError(current.error); + if (current.status !== "completed") + throw new Error( + `Unsupported terminal task status: ${current.status}`, + ); + return decodeResult(codec, current.result); + }, + async (signal) => { + const cancelled = responseResult( + await dispatchWithRetry( + this.port, + { + method: "tasks/cancel", + params: withTaskCapabilityV2({ taskId: created.taskId }), + }, + signal, + "mutate", + ), + ); + decodeResult(CancelTaskResultV2Codec, cancelled); + }, + this.lifecycleController.signal, + ), + ); + } + + const resultPromise = Promise.resolve(decodeResult(codec, wireResult)); return new ImmediateExecution( options.applicationContext as TApplicationContext, resultPromise, @@ -662,12 +1292,48 @@ class PortTaskEnabledSession< }, ): Promise> { this.assertUsable(); - throw unsupported("resumeTask"); + throw unsupported("Task resumption"); + } + + private cleanupLateTaskCreation( + response: JsonRpcResponse, + generation: SessionTaskCapabilities["generation"], + callAsTaskV1: boolean, + ): void { + if (response.kind !== "result") return; + let taskId: TaskId | undefined; + let params: JsonValue | undefined; + if (generation === "v1" && callAsTaskV1) { + const parsed = CreateTaskResultV1Codec.parse(response.result); + if (parsed.success) { + taskId = parsed.value.task.taskId as TaskId; + params = { taskId }; + } + } else if (generation === "v2" && isCreateTaskResultV2(response.result)) { + const parsed = CreateTaskResultV2Codec.parse(response.result); + if (parsed.success) { + taskId = parsed.value.taskId as TaskId; + params = withTaskCapabilityV2({ taskId }); + } + } + if (taskId === undefined || params === undefined) return; + void dispatchWithRetry( + this.port, + { method: "tasks/cancel", params }, + undefined, + "mutate", + ).catch(() => { + // A task returned after call abort is cleaned up on a best-effort basis. + }); } async close(): Promise { if (this.closed) return; this.closed = true; + for (const execution of this.activeTaskExecutions) { + void execution.close().catch(() => {}); + } + this.lifecycleController.abort(new Error("Task-enabled session is closed")); for (const dispose of this.disposeListeners) dispose(); } @@ -675,6 +1341,48 @@ class PortTaskEnabledSession< return this.close(); } + private trackTaskExecution( + execution: TaskExecution, + ): TaskExecution { + const tracked = execution as TaskExecution; + this.activeTaskExecutions.add(tracked); + void execution + .result() + .catch(() => {}) + .finally(() => this.activeTaskExecutions.delete(tracked)); + return execution; + } + + private handleNotification(notification: JsonValue): void { + this.managedDeclarations?.onNotification(notification); + if ( + notification === null || + Array.isArray(notification) || + typeof notification !== "object" + ) + return; + const method = (notification as Readonly>).method; + const generation = this.port.taskCapabilities.generation; + const parsed = + generation === "v1" && method === "notifications/tasks/status" + ? TaskStatusNotificationV1Codec.parse(notification) + : generation === "v2" && method === "notifications/tasks" + ? TaskStatusNotificationV2Codec.parse(notification) + : undefined; + if (parsed === undefined) return; + if (!parsed.success) { + this.reportBackgroundError(parsed.error); + return; + } + const snapshot: TaskSnapshot = + generation === "v1" + ? { generation: "v1", task: parsed.value.params as TaskV1 } + : { generation: "v2", task: parsed.value.params as DetailedTaskV2 }; + for (const execution of this.activeTaskExecutions) { + execution.onNotification(snapshot); + } + } + private async handleServerRequest( incoming: IncomingServerRequest, ): Promise { @@ -756,15 +1464,6 @@ class PortTaskEnabledSession< } } -function isTaskResultForGeneration( - generation: SessionTaskCapabilities["generation"], - value: JsonValue, -): boolean { - if (generation === "v1") return CreateTaskResultV1Codec.parse(value).success; - if (generation === "v2") return isCreateTaskResultV2(value); - return false; -} - export function withTasks( session: ConnectedMcpSessionPort, options: WithTasksOptions = {}, From 7030cc94c154faaf888127d406e1fae4ca8667e7 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 4 Sep 2026 19:27:54 -0700 Subject: [PATCH 06/33] chore: add ESLint checks - Add type-aware ESLint configuration for the ext-tasks package - Integrate linting into package checks and prepack verification - Fix type-safety, lifecycle, and test diagnostics without suppressions --- package-lock.json | 1126 +++++++++++++++++- packages/ext-tasks/eslint.config.mjs | 28 + packages/ext-tasks/package.json | 9 +- packages/ext-tasks/src/client/index.test.ts | 80 +- packages/ext-tasks/src/client/index.ts | 148 ++- packages/ext-tasks/src/core/index.ts | 8 +- packages/ext-tasks/src/core/v1/index.test.ts | 10 +- packages/ext-tasks/src/core/v1/index.ts | 25 +- packages/ext-tasks/src/core/v2/index.ts | 35 +- packages/ext-tasks/tsconfig.eslint.json | 11 + 10 files changed, 1333 insertions(+), 147 deletions(-) create mode 100644 packages/ext-tasks/eslint.config.mjs create mode 100644 packages/ext-tasks/tsconfig.eslint.json diff --git a/package-lock.json b/package-lock.json index ebc075c..46a6a5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -351,6 +351,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@chevrotain/types": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", @@ -874,6 +898,200 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@iconify-json/simple-icons": { "version": "1.2.86", "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.86.tgz", @@ -931,6 +1149,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@mermaid-js/mermaid-mindmap": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@mermaid-js/mermaid-mindmap/-/mermaid-mindmap-9.3.0.tgz", @@ -2137,6 +2379,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2161,6 +2410,13 @@ "@types/unist": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -2218,6 +2474,236 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", + "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/type-utils": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.69.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", + "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", + "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.69.0", + "@typescript-eslint/types": "^8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", + "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", + "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", + "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", + "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", + "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.69.0", + "@typescript-eslint/tsconfig-utils": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", + "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", + "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript/vfs": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", @@ -2558,6 +3044,46 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/algoliasearch": { "version": "5.53.0", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.53.0.tgz", @@ -2686,6 +3212,20 @@ "node": "20 || >=22" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -3499,6 +4039,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delaunator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", @@ -3670,17 +4217,162 @@ "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4.0" } }, "node_modules/estree-walker": { @@ -3690,6 +4382,16 @@ "dev": true, "license": "MIT" }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -3753,6 +4455,27 @@ "node": ">=12.17.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fastdom": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", @@ -3781,6 +4504,16 @@ } } }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -3821,6 +4554,42 @@ "node": ">=10" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/focus-trap": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", @@ -3882,6 +4651,32 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -3899,6 +4694,19 @@ "node": ">=8" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", @@ -3944,6 +4752,13 @@ "dev": true, "license": "MIT" }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -3955,6 +4770,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -3966,6 +4791,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -4002,6 +4837,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -4018,6 +4863,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", @@ -4079,6 +4937,20 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/katex": { "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", @@ -4106,6 +4978,16 @@ "node": ">= 12" } }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/khroma": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", @@ -4119,6 +5001,20 @@ "dev": true, "license": "MIT" }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -4478,6 +5374,22 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -4827,6 +5739,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/non-layered-tidy-tree-layout": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", @@ -4877,6 +5796,56 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -4891,6 +5860,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4996,6 +5975,16 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.9.6", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", @@ -5023,6 +6012,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", @@ -5040,6 +6039,26 @@ ], "license": "MIT" }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5782,6 +6801,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-dedent": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", @@ -5865,6 +6897,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", @@ -5892,6 +6937,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", + "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.69.0", + "@typescript-eslint/parser": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -5965,6 +7034,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/uuid": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", @@ -6809,6 +7888,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -6873,6 +7962,19 @@ "node": ">=8" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -6899,9 +8001,13 @@ "version": "0.1.0", "license": "Apache-2.0", "devDependencies": { + "@eslint/js": "^10.0.1", "@modelcontextprotocol/client": "^2.0.0", + "eslint": "^10.10.0", "fast-check": "^4.9.0", - "prettier": "^3.9.6" + "globals": "^17.12.0", + "prettier": "^3.9.6", + "typescript-eslint": "^8.69.0" }, "peerDependencies": { "@modelcontextprotocol/client": "^2.0.0" diff --git a/packages/ext-tasks/eslint.config.mjs b/packages/ext-tasks/eslint.config.mjs new file mode 100644 index 0000000..e4298a6 --- /dev/null +++ b/packages/ext-tasks/eslint.config.mjs @@ -0,0 +1,28 @@ +import eslint from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["dist/**", "coverage/**", "schema/**"], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + { + files: ["src/**/*.ts", "vitest.config.ts"], + languageOptions: { + globals: globals.browser, + parserOptions: { + project: "./tsconfig.eslint.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + files: ["scripts/**/*.mjs", "*.config.{js,mjs}"], + extends: [tseslint.configs.disableTypeChecked], + languageOptions: { + globals: globals.node, + }, + }, +); diff --git a/packages/ext-tasks/package.json b/packages/ext-tasks/package.json index a7bcba1..4f9e702 100644 --- a/packages/ext-tasks/package.json +++ b/packages/ext-tasks/package.json @@ -79,14 +79,19 @@ "test:watch": "vitest", "format": "prettier --write .", "format:check": "prettier --check .", + "lint": "eslint .", "check:exports": "node scripts/check-exports.mjs", "check:provenance": "node scripts/check-schema-provenance.mjs", - "check": "npm run check:provenance && npm run format:check && npm run typecheck && npm run test && npm run build && npm run check:exports", + "check": "npm run check:provenance && npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build && npm run check:exports", "prepack": "npm run check" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@modelcontextprotocol/client": "^2.0.0", + "eslint": "^10.10.0", "fast-check": "^4.9.0", - "prettier": "^3.9.6" + "globals": "^17.12.0", + "prettier": "^3.9.6", + "typescript-eslint": "^8.69.0" } } diff --git a/packages/ext-tasks/src/client/index.test.ts b/packages/ext-tasks/src/client/index.test.ts index 50f9556..98b81ec 100644 --- a/packages/ext-tasks/src/client/index.test.ts +++ b/packages/ext-tasks/src/client/index.test.ts @@ -25,6 +25,12 @@ import { const asJson = (value: unknown): JsonValue => JSON.parse(JSON.stringify(value)) as JsonValue; +const formatJson = (value: unknown): string => + JSON.stringify(value) ?? "undefined"; + +const asError = (reason: unknown): Error => + reason instanceof Error ? reason : new Error(formatJson(reason)); + class FakePort implements ConnectedMcpSessionPort { readonly requests: JsonValue[] = []; readonly taskCapabilities: SessionTaskCapabilities; @@ -271,6 +277,7 @@ describe("client tool executions", () => { const session = withTasks<{ readonly marker: string }>(port, { tools: { currentTool: () => undefined }, onInputRequest: async (request, context) => { + await Promise.resolve(); observed.push({ request, context }); return input.result as never; }, @@ -304,6 +311,7 @@ describe("client tool executions", () => { const session = withTasks(port, { tools: { currentTool: () => undefined }, onInputRequest: async () => { + await Promise.resolve(); throw new Error("declined"); }, }); @@ -325,6 +333,7 @@ describe("client tool executions", () => { const session = withTasks(port, { tools: { currentTool: () => undefined }, onInputRequest: async () => { + await Promise.resolve(); handlerCalls += 1; return { action: "accept" } as never; }, @@ -425,6 +434,7 @@ describe("client tool executions", () => { const port = new FakePort({ generation: "v1", capabilities }); let taskSelected = false; port.dispatchHandler = async (request) => { + await Promise.resolve(); const record = expectRecord(request); if (record.method === "tools/call") { return taskSelected @@ -444,7 +454,7 @@ describe("client tool executions", () => { } if (record.method === "tasks/result") return { kind: "result", result: { content: [] } }; - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; let lookups = 0; const tool: ToolV1 = { @@ -506,6 +516,7 @@ describe("client tool executions", () => { it("manages initial tool declarations only when no provider is supplied", async () => { const managed = new FakePort({ generation: "v1", capabilities: {} }); managed.dispatchHandler = async (request) => { + await Promise.resolve(); const record = expectRecord(request); if (record.method === "tools/list") { return { @@ -540,12 +551,13 @@ describe("client tool executions", () => { const port = new FakePort({ generation: "v1", capabilities: {} }); let attempts = 0; port.dispatchHandler = async (request) => { + await Promise.resolve(); const record = expectRecord(request); if (record.method !== "tools/list") return { kind: "result", result: asJson({ content: [] }) }; attempts += 1; if (attempts === 1) throw new DispatchError("temporary", true); - const params = expectRecord(record.params as JsonValue); + const params = expectRecord(record.params); if (params.cursor === undefined) { return { kind: "result", @@ -582,7 +594,7 @@ describe("client tool executions", () => { port.dispatchHandler = (request, options) => { const record = expectRecord(request); if (record.method === "tools/call") { - const params = expectRecord(record.params as JsonValue); + const params = expectRecord(record.params); return Promise.resolve( params.task === undefined ? { kind: "result", result: { content: [] } } @@ -603,7 +615,7 @@ describe("client tool executions", () => { if (record.method === "tasks/result") return Promise.resolve({ kind: "result", result: { content: [] } }); if (record.method !== "tools/list") - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); listCount += 1; if (listCount === 1) { return Promise.resolve({ @@ -657,6 +669,7 @@ describe("client tool executions", () => { const errors: Error[] = []; const duplicatePort = new FakePort({ generation: "v1", capabilities: {} }); duplicatePort.dispatchHandler = async (request) => { + await Promise.resolve(); const record = expectRecord(request); if (record.method === "tools/list") { return { @@ -772,6 +785,7 @@ describe("client tool executions", () => { execution: { taskSupport: "required" }, }; port.dispatchHandler = async (request) => { + await Promise.resolve(); const record = expectRecord(request); if (record.method === "tools/call") { return { @@ -817,7 +831,7 @@ describe("client tool executions", () => { }), }; } - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { currentTool: () => tool } }); const execution = await session.callTool("long"); @@ -863,6 +877,7 @@ describe("client tool executions", () => { const port = new FakePort({ generation: "v2", capabilities: {} }); const tool = { name: "long", inputSchema: { type: "object" } }; port.dispatchHandler = async (request) => { + await Promise.resolve(); const record = expectRecord(request); if (record.method === "tools/call") { return { @@ -896,7 +911,7 @@ describe("client tool executions", () => { } if (record.method === "tasks/cancel") return { kind: "result", result: { resultType: "complete" } }; - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { currentTool: () => tool } }); const execution = await session.callTool("long"); @@ -944,7 +959,7 @@ describe("client tool executions", () => { return new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(options.signal?.reason), + () => reject(asError(options.signal?.reason)), { once: true }, ); }); @@ -952,7 +967,7 @@ describe("client tool executions", () => { cancelCalls += 1; return { kind: "result", result: { resultType: "complete" } }; } - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1007,7 +1022,7 @@ describe("client tool executions", () => { return new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(options.signal?.reason), + () => reject(asError(options.signal?.reason)), { once: true }, ); }); @@ -1015,7 +1030,7 @@ describe("client tool executions", () => { cancelCalls += 1; return { kind: "result", result: { resultType: "complete" } }; } - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1036,6 +1051,7 @@ describe("client tool executions", () => { const port = new FakePort({ generation: "v2", capabilities: {} }); let getCalls = 0; port.dispatchHandler = async (request) => { + await Promise.resolve(); const record = expectRecord(request); if (record.method === "tools/call") return { @@ -1066,7 +1082,7 @@ describe("client tool executions", () => { }), }; } - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1108,7 +1124,7 @@ describe("client tool executions", () => { return new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(options.signal?.reason), + () => reject(asError(options.signal?.reason)), { once: true }, ); }); @@ -1118,7 +1134,7 @@ describe("client tool executions", () => { throw new DispatchError("cancel failed", retryable); return { kind: "result", result: { resultType: "complete" } }; } - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1160,7 +1176,7 @@ describe("client tool executions", () => { if (record.method === "tasks/get") return new Promise(() => {}); if (record.method === "tasks/cancel") return { kind: "result", result: { resultType: "complete" } }; - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1239,6 +1255,7 @@ describe("client tool executions", () => { const port = new FakePort({ generation: "v2", capabilities: {} }); let getCalls = 0; port.dispatchHandler = async (request) => { + await Promise.resolve(); const record = expectRecord(request); if (record.method === "tools/call") return { @@ -1256,7 +1273,7 @@ describe("client tool executions", () => { getCalls += 1; return { kind: "error", error: { code: -32000, message: "failed" } }; } - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1279,6 +1296,7 @@ describe("client tool executions", () => { const port = new FakePort({ generation: "v2", capabilities: {} }); let getCalls = 0; port.dispatchHandler = async (request) => { + await Promise.resolve(); const record = expectRecord(request); if (record.method === "tools/call") return { @@ -1319,7 +1337,7 @@ describe("client tool executions", () => { ), }; } - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1377,7 +1395,7 @@ describe("client tool executions", () => { cancelCalls += 1; return { kind: "result", result: { resultType: "complete" } }; } - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1457,12 +1475,12 @@ describe("client tool executions", () => { return new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(options.signal?.reason), + () => reject(asError(options.signal?.reason)), { once: true }, ); }); if (record.method === "tasks/cancel") return new Promise(() => {}); - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1497,13 +1515,13 @@ describe("client tool executions", () => { return new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(options.signal?.reason), + () => reject(asError(options.signal?.reason)), { once: true }, ); }); if (record.method === "tasks/cancel") return { kind: "result", result: { resultType: "complete" } }; - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1542,14 +1560,18 @@ describe("client tool executions", () => { throw new Error("observation signal is required"); observationSignal = signal; return new Promise((_resolve, reject) => - signal.addEventListener("abort", () => reject(signal.reason), { - once: true, - }), + signal.addEventListener( + "abort", + () => reject(asError(signal.reason)), + { + once: true, + }, + ), ); } if (record.method === "tasks/cancel") return { kind: "result", result: { resultType: "complete" } }; - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1603,7 +1625,7 @@ describe("client tool executions", () => { return new Promise((_resolve, reject) => options?.signal?.addEventListener( "abort", - () => reject(options.signal?.reason), + () => reject(asError(options.signal?.reason)), { once: true }, ), ); @@ -1614,7 +1636,7 @@ describe("client tool executions", () => { }); return { kind: "result", result: { resultType: "complete" } }; } - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { @@ -1663,11 +1685,11 @@ describe("client tool executions", () => { return new Promise((_resolve, reject) => options?.signal?.addEventListener( "abort", - () => reject(options.signal?.reason), + () => reject(asError(options.signal?.reason)), { once: true }, ), ); - throw new Error(`unexpected method ${String(record.method)}`); + throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { tools: { currentTool: () => tool } }); const execution = await session.callTool("x"); diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts index f7a121b..d6084cf 100644 --- a/packages/ext-tasks/src/client/index.ts +++ b/packages/ext-tasks/src/client/index.ts @@ -2,6 +2,7 @@ import { isJsonValue, + isJsonArray, type JsonValue, type RuntimeCodec, type TaskGeneration, @@ -415,40 +416,41 @@ class TaskExecution< updates(signal?: AbortSignal): AsyncIterable { if (this.updatesAcquired) throw new TaskUpdatesAlreadyAcquiredError(); this.updatesAcquired = true; - const execution = this; - return { - async *[Symbol.asyncIterator]() { - while (true) { - throwIfAborted(signal); - if (execution.initialSnapshot !== undefined) { - const snapshot = execution.initialSnapshot; - execution.initialSnapshot = undefined; - yield snapshot; - continue; - } - if (execution.pendingSnapshot !== undefined) { - const snapshot = execution.pendingSnapshot; - execution.pendingSnapshot = undefined; - yield snapshot; - continue; - } - if (execution.terminalSnapshot !== undefined) { - const snapshot = execution.terminalSnapshot; - execution.terminalSnapshot = undefined; - yield snapshot; - continue; - } - const settled = await execution.waitForUpdateOrResult(signal); - if ( - !settled && - execution.initialSnapshot === undefined && - execution.pendingSnapshot === undefined && - execution.terminalSnapshot === undefined - ) - return; - } - }, - }; + return this.iterateUpdates(signal); + } + + private async *iterateUpdates( + signal?: AbortSignal, + ): AsyncIterable { + while (true) { + throwIfAborted(signal); + if (this.initialSnapshot !== undefined) { + const snapshot = this.initialSnapshot; + this.initialSnapshot = undefined; + yield snapshot; + continue; + } + if (this.pendingSnapshot !== undefined) { + const snapshot = this.pendingSnapshot; + this.pendingSnapshot = undefined; + yield snapshot; + continue; + } + if (this.terminalSnapshot !== undefined) { + const snapshot = this.terminalSnapshot; + this.terminalSnapshot = undefined; + yield snapshot; + continue; + } + const settled = await this.waitForUpdateOrResult(signal); + if ( + !settled && + this.initialSnapshot === undefined && + this.pendingSnapshot === undefined && + this.terminalSnapshot === undefined + ) + return; + } } private accept(snapshot: TaskSnapshot): void { @@ -520,17 +522,16 @@ class TaskExecution< return this.currentTurn(afterSequence); } await new Promise((resolve, reject) => { - let timeout: ReturnType; const finish = (error?: unknown): void => { clearTimeout(timeout); this.notificationWaiters.delete(onNotification); this.controller.signal.removeEventListener("abort", onAbort); if (error === undefined) resolve(); - else reject(error); + else reject(reasonAsError(error)); }; const onNotification = (): void => finish(); const onAbort = (): void => finish(this.controller.signal.reason); - timeout = setTimeout(onNotification, Math.max(0, delayMs)); + const timeout = setTimeout(onNotification, Math.max(0, delayMs)); this.notificationWaiters.add(onNotification); this.controller.signal.addEventListener("abort", onAbort, { once: true }); }); @@ -581,13 +582,15 @@ class TaskExecution< : withAbort(this.cancelPromise, signal); } - async close(): Promise { - if (this.closed) return; - this.closed = true; - this.controller.abort(this.closedError); - void this.cancel().catch(() => { - // Cooperative cancellation is best effort during close. - }); + close(): Promise { + if (!this.closed) { + this.closed = true; + this.controller.abort(this.closedError); + void this.cancel().catch(() => { + // Cooperative cancellation is best effort during close. + }); + } + return Promise.resolve(); } [Symbol.asyncDispose](): Promise { @@ -687,9 +690,7 @@ function responseResult(response: JsonRpcResponse): JsonValue { return response.result; } -function terminalStatus( - status: TaskV1["status"] | DetailedTaskV2["status"], -): boolean { +function terminalStatus(status: TaskV1["status"]): boolean { return ( status === "completed" || status === "failed" || status === "cancelled" ); @@ -701,24 +702,35 @@ class ImmediateExecution< > implements ToolExecutionCommon { readonly kind = "immediate" as const; readonly handle = undefined; - private closed = false; constructor( readonly applicationContext: TApplicationContext, private readonly resultPromise: Promise, ) {} - async *updates(_signal?: AbortSignal): AsyncIterable {} + updates(signal?: AbortSignal): AsyncIterable { + throwIfAborted(signal); + return { + [Symbol.asyncIterator]() { + return { + next: () => + Promise.resolve({ done: true as const, value: undefined }), + }; + }, + }; + } result(): Promise { return this.resultPromise; } - async cancel(_signal?: AbortSignal): Promise {} + cancel(signal?: AbortSignal): Promise { + throwIfAborted(signal); + return Promise.resolve(); + } - async close(): Promise { - if (this.closed) return; - this.closed = true; + close(): Promise { + return Promise.resolve(); } [Symbol.asyncDispose](): Promise { @@ -835,7 +847,7 @@ class ManagedToolDeclarations implements ToolDeclarationProvider { } const result = response.result as Readonly>; const listed = result.tools; - if (!Array.isArray(listed)) + if (!isJsonArray(listed)) throw new Error("tools/list result must contain tools"); for (const value of listed) { const parsed = @@ -1283,16 +1295,18 @@ class PortTaskEnabledSession< ); } - async resumeTask( - _reference: SerializedTaskReference, - _options?: { + resumeTask( + reference: SerializedTaskReference, + options?: { readonly resultCodec?: RuntimeCodec; readonly applicationContext?: TApplicationContext; readonly signal?: AbortSignal; }, ): Promise> { + void reference; + void options; this.assertUsable(); - throw unsupported("Task resumption"); + return Promise.reject(unsupported("Task resumption")); } private cleanupLateTaskCreation( @@ -1327,14 +1341,18 @@ class PortTaskEnabledSession< }); } - async close(): Promise { - if (this.closed) return; - this.closed = true; - for (const execution of this.activeTaskExecutions) { - void execution.close().catch(() => {}); + close(): Promise { + if (!this.closed) { + this.closed = true; + for (const execution of this.activeTaskExecutions) { + void execution.close().catch(() => {}); + } + this.lifecycleController.abort( + new Error("Task-enabled session is closed"), + ); + for (const dispose of this.disposeListeners) dispose(); } - this.lifecycleController.abort(new Error("Task-enabled session is closed")); - for (const dispose of this.disposeListeners) dispose(); + return Promise.resolve(); } [Symbol.asyncDispose](): Promise { diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts index 9c5ed1a..6032c26 100644 --- a/packages/ext-tasks/src/core/index.ts +++ b/packages/ext-tasks/src/core/index.ts @@ -60,7 +60,7 @@ export function isJsonValue(value: unknown): value is JsonValue { candidate.length === Object.keys(candidate).length && candidate.every(visit); } else { - const prototype = Object.getPrototypeOf(candidate); + const prototype = Reflect.getPrototypeOf(candidate); valid = (prototype === Object.prototype || prototype === null) && Object.values(candidate).every(visit); @@ -71,6 +71,12 @@ export function isJsonValue(value: unknown): value is JsonValue { return visit(value); } +export function isJsonArray( + value: JsonValue | undefined, +): value is readonly JsonValue[] { + return Array.isArray(value); +} + export function createRuntimeCodec( decode: (value: JsonValue, path: DecodePath) => T, ): RuntimeCodec { diff --git a/packages/ext-tasks/src/core/v1/index.test.ts b/packages/ext-tasks/src/core/v1/index.test.ts index 611672b..6cf4075 100644 --- a/packages/ext-tasks/src/core/v1/index.test.ts +++ b/packages/ext-tasks/src/core/v1/index.test.ts @@ -125,13 +125,13 @@ describe("V1 generated wire contracts", () => { for (const [codec, arbitrary] of cases) fc.assert( fc.property(arbitrary, (request) => { - expectRoundTrip(codec as never, request); + expectRoundTrip(codec, request); expect( - codec.parse({ ...request, method: "tasks/nope" } as never).success, - ).toBe(false); - expect( - codec.parse({ ...request, jsonrpc: "1.0" } as never).success, + codec.parse({ ...request, method: "tasks/nope" }).success, ).toBe(false); + expect(codec.parse({ ...request, jsonrpc: "1.0" }).success).toBe( + false, + ); const { params: ignored, ...withoutParams } = request; void ignored; expect(codec.parse(withoutParams as never).success).toBe(false); diff --git a/packages/ext-tasks/src/core/v1/index.ts b/packages/ext-tasks/src/core/v1/index.ts index 392cebb..0377b33 100644 --- a/packages/ext-tasks/src/core/v1/index.ts +++ b/packages/ext-tasks/src/core/v1/index.ts @@ -6,6 +6,7 @@ import { expectNumber, expectRecord, expectString, + isJsonArray, type DecodePath, type JsonValue, type RuntimeCodec, @@ -300,21 +301,11 @@ export const ToolV1Codec: RuntimeCodec = createRuntimeCodec( (value, path) => { const record = expectRecord(value, path); const inputSchema = jsonRecord(record.inputSchema, at(path, "inputSchema")); - literal( - inputSchema as Record, - "type", - "object", - at(path, "inputSchema"), - ); + literal(inputSchema, "type", "object", at(path, "inputSchema")); let outputSchema: ToolV1["outputSchema"]; if (record.outputSchema !== undefined) { const decoded = jsonRecord(record.outputSchema, at(path, "outputSchema")); - literal( - decoded as Record, - "type", - "object", - at(path, "outputSchema"), - ); + literal(decoded, "type", "object", at(path, "outputSchema")); outputSchema = decoded as ToolV1["outputSchema"]; } let execution: ToolExecutionV1 | undefined; @@ -333,7 +324,7 @@ export const ToolV1Codec: RuntimeCodec = createRuntimeCodec( } let icons: ToolV1["icons"]; if (record.icons !== undefined) { - if (!Array.isArray(record.icons)) + if (!isJsonArray(record.icons)) throw new ProtocolDecodeError("expected array", at(path, "icons")); icons = record.icons.map((icon, index) => jsonRecord(icon, at(at(path, "icons"), index)), @@ -448,7 +439,7 @@ export const CreateTaskResultV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const record = expectRecord(value, path); return { - task: decodeTask(record.task as JsonValue, at(path, "task")), + task: decodeTask(record.task, at(path, "task")), ...(record._meta === undefined ? {} : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), @@ -457,7 +448,7 @@ export const CreateTaskResultV1Codec: RuntimeCodec = export const CallToolResultV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const record = expectRecord(value, path); - if (!Array.isArray(record.content)) + if (!isJsonArray(record.content)) throw new ProtocolDecodeError("expected array", at(path, "content")); record.content.forEach((item, index) => decodeContentBlock(item, at(at(path, "content"), index)), @@ -531,7 +522,7 @@ export const ListTasksRequestV1Codec: RuntimeCodec = export const ListTasksResultV1Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const record = expectRecord(value, path); - if (!Array.isArray(record.tasks)) + if (!isJsonArray(record.tasks)) throw new ProtocolDecodeError("expected array", at(path, "tasks")); return { tasks: record.tasks.map((task, index) => @@ -557,7 +548,7 @@ export const TaskStatusNotificationV1Codec: RuntimeCodec decodeIcon(icon, [...path, "icons", index]), @@ -354,7 +355,7 @@ function decodeContentBlock( if (object.size !== undefined) expectInteger(object.size, [...path, "size"]); if (object.icons !== undefined) { - if (!Array.isArray(object.icons)) + if (!isJsonArray(object.icons)) throw new ProtocolDecodeError("expected array", [...path, "icons"]); object.icons.forEach((icon, index) => decodeIcon(icon, [...path, "icons", index]), @@ -414,7 +415,7 @@ function decodeTool(value: JsonValue, path: DecodePath): ToolV2 { optionalBoolean(annotations, key, [...path, "annotations"]); } if (object.icons !== undefined) { - if (!Array.isArray(object.icons)) + if (!isJsonArray(object.icons)) throw new ProtocolDecodeError("expected array", [...path, "icons"]); object.icons.forEach((icon, index) => decodeIcon(icon, [...path, "icons", index]), @@ -430,7 +431,7 @@ function decodeCallToolResult( ): CallToolResultV2 { const object = expectRecord(value, path); expectString(object.resultType, [...path, "resultType"]); - if (!Array.isArray(object.content)) + if (!isJsonArray(object.content)) throw new ProtocolDecodeError("expected array", [...path, "content"]); object.content.forEach((block, index) => decodeContentBlock(block, [...path, "content", index]), @@ -486,7 +487,7 @@ function decodeError(value: JsonValue, path: DecodePath): ErrorV2 { return { code: expectInteger(object.code, [...path, "code"]), message: expectString(object.message, [...path, "message"]), - ...(has(object, "data") ? { data: object.data as JsonValue } : {}), + ...(has(object, "data") ? { data: object.data } : {}), }; } @@ -506,7 +507,7 @@ function decodeInputRequest( } return { method, - params: expectRecord(object.params as JsonValue, [...path, "params"]), + params: expectRecord(object.params, [...path, "params"]), }; } function decodeInputRequests( @@ -568,7 +569,7 @@ function decodeDetailedTask( return { ...task, status: task.status, - inputRequests: decodeInputRequests(object.inputRequests as JsonValue, [ + inputRequests: decodeInputRequests(object.inputRequests, [ ...path, "inputRequests", ]), @@ -577,13 +578,13 @@ function decodeDetailedTask( return { ...task, status: task.status, - result: expectRecord(object.result as JsonValue, [...path, "result"]), + result: expectRecord(object.result, [...path, "result"]), }; case "failed": return { ...task, status: task.status, - error: decodeError(object.error as JsonValue, [...path, "error"]), + error: decodeError(object.error, [...path, "error"]), }; case "working": return { ...task, status: task.status }; @@ -599,7 +600,7 @@ function decodeRpcRequest(value: JsonValue, path: DecodePath, method: string) { return { object, id: expectRequestId(object.id, [...path, "id"]), - params: expectRecord(object.params as JsonValue, [...path, "params"]), + params: expectRecord(object.params, [...path, "params"]), }; } function decodeCompleteResult(value: JsonValue, path: DecodePath) { @@ -716,10 +717,11 @@ export const UpdateTaskRequestV2Codec: RuntimeCodec = method: "tasks/update", params: { taskId: expectString(params.taskId, [...path, "params", "taskId"]), - inputResponses: decodeInputResponses( - params.inputResponses as JsonValue, - [...path, "params", "inputResponses"], - ), + inputResponses: decodeInputResponses(params.inputResponses, [ + ...path, + "params", + "inputResponses", + ]), }, }; }); @@ -827,10 +829,7 @@ export const TaskStatusNotificationV2Codec: RuntimeCodec Date: Fri, 4 Sep 2026 20:18:23 -0700 Subject: [PATCH 07/33] feat(client): implement task input exchange - Correlate V1 task input with related-task metadata - Handle keyed V2 input through validated partial updates - Add fail-closed and lifecycle race coverage --- packages/ext-tasks/src/client/index.test.ts | 681 +++++++++++++++++++- packages/ext-tasks/src/client/index.ts | 428 +++++++++--- 2 files changed, 1001 insertions(+), 108 deletions(-) diff --git a/packages/ext-tasks/src/client/index.test.ts b/packages/ext-tasks/src/client/index.test.ts index 98b81ec..0aa8514 100644 --- a/packages/ext-tasks/src/client/index.test.ts +++ b/packages/ext-tasks/src/client/index.test.ts @@ -231,8 +231,10 @@ describe("client tool executions", () => { it("settles default V1 input declines with method-specific protocol values", async () => { const port = new FakePort({ generation: "v1", capabilities: {} }); + const errors: Error[] = []; const session = withTasks(port, { tools: { currentTool: () => undefined }, + onError: (error) => errors.push(error), }); await expect( port.serve({ method: "elicitation/create", params: {} }), @@ -246,6 +248,13 @@ describe("client tool executions", () => { error: { code: -32603, message: "Internal error" }, }); } + expect(errors).toHaveLength(3); + expect( + errors.every((error) => error instanceof InputCorrelationError), + ).toBe(true); + expect( + errors.map((error) => (error as InputCorrelationError).reason), + ).toEqual(["missing-evidence", "missing-evidence", "missing-evidence"]); await session.close(); }); @@ -298,7 +307,7 @@ describe("client tool executions", () => { } }); - it("settles configured input-handler rejection fail-closed", async () => { + it("fails closed when the input handler rejects", async () => { const port = new FakePort({ generation: "v1", capabilities: {} }); let settlement: JsonRpcResponse | undefined; port.dispatchHandler = async () => { @@ -369,6 +378,294 @@ describe("client tool executions", () => { await Promise.all([first, second]); await session.close(); }); + + it("correlates V1 task inputs across candidate counts and evidence states", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 0, max: 3 }), + fc.constantFrom("absent", "invalid", "matching", "missing"), + fc.constantFrom( + "elicitation/create", + "sampling/createMessage", + "roots/list", + ), + async (candidateCount, evidenceState, method) => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } }, cancel: {} }, + }); + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + const params = expectRecord(record.params); + if (record.method === "tools/call") { + if (typeof params.name !== "string") + throw new Error("tool name required"); + const name = params.name; + return { + kind: "result", + result: asJson({ + task: { + taskId: `task-${name}`, + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }, + }), + }; + } + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ), + ); + if (record.method === "tasks/cancel") + return { + kind: "result", + result: asJson({ + taskId: params.taskId, + status: "cancelled", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }), + }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const errors: Error[] = []; + const observed: unknown[] = []; + const session = withTasks(port, { + tools: { + currentTool: (name) => ({ + name, + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }), + }, + onInputRequest: async (request, context) => { + await Promise.resolve(); + observed.push({ request, context }); + return ( + request.kind === "elicitation" + ? { action: "accept" } + : request.kind === "sampling" + ? { + model: "m", + role: "assistant", + content: { type: "text", text: "ok" }, + } + : { roots: [] } + ) as never; + }, + onError: (error) => errors.push(error), + }); + const executions = await Promise.all( + Array.from({ length: candidateCount }, (_, index) => + session.callTool(String(index), undefined, { + applicationContext: `context-${index}`, + }), + ), + ); + const relatedTask: JsonValue = + evidenceState === "absent" + ? {} + : evidenceState === "invalid" + ? { + _meta: { + "io.modelcontextprotocol/related-task": { taskId: 1 }, + }, + } + : { + _meta: { + "io.modelcontextprotocol/related-task": { + taskId: + evidenceState === "matching" ? "task-0" : "other", + }, + }, + }; + const settlement = await port.serve({ method, params: relatedTask }); + const succeeds = + (evidenceState === "absent" && candidateCount === 1) || + (evidenceState === "matching" && candidateCount > 0); + expect(observed).toHaveLength(succeeds ? 1 : 0); + expect(errors).toHaveLength(succeeds ? 0 : 1); + if (succeeds) { + const entry = expectRecord(asJson(observed[0])); + expect(entry.context).toMatchObject({ + lifetime: "task-v1", + taskId: "task-0", + applicationContext: "context-0", + }); + expect( + (observed[0] as { context: { signal: AbortSignal } }).context + .signal, + ).toBeInstanceOf(AbortSignal); + expect(settlement.kind).toBe("result"); + } else { + const expectedReason = + evidenceState === "invalid" + ? "invalid-evidence" + : evidenceState === "absent" && candidateCount === 0 + ? "missing-evidence" + : evidenceState === "missing" || candidateCount === 0 + ? "zero-matches" + : "ambiguous-matches"; + expect(errors[0]).toBeInstanceOf(InputCorrelationError); + expect(errors[0]).toMatchObject({ reason: expectedReason }); + if (evidenceState === "invalid") { + const candidates = (errors[0] as InputCorrelationError) + .candidates; + expect(candidates).toHaveLength(candidateCount); + expect( + candidates.every((candidate) => !("taskId" in candidate)), + ).toBe(true); + } + expect(settlement).toEqual( + method === "elicitation/create" + ? { kind: "result", result: { action: "cancel" } } + : { + kind: "error", + error: { code: -32603, message: "Internal error" }, + }, + ); + } + await Promise.all(executions.map((execution) => execution.close())); + await session.close(); + }, + ), + { numRuns: 40 }, + ); + }); + + it("conforms exactly to the V1 related-task metadata key", async () => { + const malformedValues: JsonValue[] = [ + null, + [], + "task-0", + {}, + { taskId: null }, + ]; + for (const relatedTask of malformedValues) { + const port = new FakePort({ generation: "v1", capabilities: {} }); + const errors: Error[] = []; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async () => { + await Promise.resolve(); + return { action: "accept" } as never; + }, + onError: (error) => errors.push(error), + }); + await port.serve({ + method: "elicitation/create", + params: { + _meta: { "io.modelcontextprotocol/related-task": relatedTask }, + }, + }); + expect(errors[0]).toMatchObject({ reason: "invalid-evidence" }); + await session.close(); + } + const port = new FakePort({ generation: "v1", capabilities: {} }); + const errors: Error[] = []; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async () => { + await Promise.resolve(); + return { action: "accept" } as never; + }, + onError: (error) => errors.push(error), + }); + await port.serve({ + method: "elicitation/create", + params: { + _meta: { + "modelcontextprotocol.io/related-task": { taskId: "wrong-key" }, + unrelated: true, + }, + }, + }); + expect(errors[0]).toMatchObject({ reason: "missing-evidence" }); + await session.close(); + }); + + it("unregisters a closed V1 task candidate and aborts its handler signal", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } }, cancel: {} }, + }); + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + task: { + taskId: "lifecycle", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ), + ); + return { + kind: "result", + result: asJson({ + taskId: "lifecycle", + status: "cancelled", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }), + }; + }; + const errors: Error[] = []; + let handlerSignal: AbortSignal | undefined; + const session = withTasks(port, { + tools: { + currentTool: () => ({ + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }), + }, + onInputRequest: async (_request, context) => { + await Promise.resolve(); + handlerSignal = context.signal; + return { action: "accept" } as never; + }, + onError: (error) => errors.push(error), + }); + const execution = await session.callTool("x"); + await port.serve({ method: "elicitation/create", params: {} }); + expect(handlerSignal?.aborted).toBe(false); + await execution.close(); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + expect(handlerSignal?.aborted).toBe(true); + await port.serve({ + method: "elicitation/create", + params: { + _meta: { + "io.modelcontextprotocol/related-task": { taskId: "lifecycle" }, + }, + }, + }); + expect(errors.at(-1)).toMatchObject({ reason: "zero-matches" }); + await session.close(); + }); it("honors already-aborted call and session signals before dispatch", async () => { const callPort = new FakePort(); const callSession = withTasks(callPort, { @@ -938,6 +1235,388 @@ describe("client tool executions", () => { await session.close(); }); + it("acquires distinct V2 input keys once and submits one valid subset", async () => { + await fc.assert( + fc.asyncProperty( + fc.uniqueArray( + fc.record({ + key: fc.stringMatching(/^[a-z][a-z0-9]{0,7}$/), + kind: fc.constantFrom("sampling", "roots", "elicitation"), + }), + { minLength: 1, maxLength: 8, selector: ({ key }) => key }, + ), + async (inputs) => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + const observed: unknown[] = []; + let getCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "input-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") { + getCalls += 1; + if (getCalls === 1) + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "input-task", + status: "input_required", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + inputRequests: Object.fromEntries( + inputs.map(({ key, kind }) => [ + key, + kind === "sampling" + ? { + method: "sampling/createMessage", + params: { key }, + } + : kind === "roots" + ? { method: "roots/list" } + : { method: "elicitation/create", params: { key } }, + ]), + ), + }), + }; + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "input-task", + status: "completed", + createdAt: "a", + lastUpdatedAt: "c", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }), + }; + } + if (record.method === "tasks/update") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks<{ marker: string }>(port, { + tools: { + currentTool: () => ({ + name: "x", + inputSchema: { type: "object" }, + }), + }, + onInputRequest: async (request, context) => { + await Promise.resolve(); + observed.push({ request, context }); + return ( + request.kind === "sampling" + ? { model: "m", role: "assistant", content: { type: "text" } } + : request.kind === "roots" + ? { roots: [{ uri: "file:///root" }] } + : { action: "cancel" } + ) as never; + }, + }); + const execution = await session.callTool( + "x", + {}, + { + applicationContext: { marker: "context" }, + }, + ); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(observed).toHaveLength(inputs.length); + expect( + observed.map((value) => { + const entry = expectRecord(asJson(value)); + return expectRecord(entry.context).inputKey; + }), + ).toEqual(inputs.map(({ key }) => key)); + const updates = port.requests.filter( + (request) => expectRecord(request).method === "tasks/update", + ); + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ + params: { + taskId: "input-task", + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }, + }); + expect( + Object.keys( + expectRecord(expectRecord(updates[0]).params) + .inputResponses as object, + ), + ).toEqual(inputs.map(({ key }) => key)); + await session.close(); + }, + ), + { numRuns: 25 }, + ); + }); + + it("reports incompatible repeated V2 keys without reacquiring or updating", async () => { + const errors: Error[] = []; + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + let handlerCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "repeat", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (method === "tasks/get") { + getCalls += 1; + if (getCalls <= 2) + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "repeat", + status: "input_required", + createdAt: "a", + lastUpdatedAt: String(getCalls), + ttlMs: null, + inputRequests: { + same: + getCalls === 1 + ? { method: "roots/list" } + : { method: "sampling/createMessage", params: {} }, + }, + }), + }; + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "repeat", + status: "completed", + createdAt: "a", + lastUpdatedAt: "z", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }), + }; + } + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + onInputRequest: async () => { + await Promise.resolve(); + handlerCalls += 1; + throw new Error("declined"); + }, + onError: (error) => errors.push(error), + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toMatchObject({ + resultType: "complete", + }); + expect(handlerCalls).toBe(1); + expect( + errors.some((error) => error.message.includes("reused incompatibly")), + ).toBe(true); + expect( + port.requests.filter( + (request) => expectRecord(request).method === "tasks/update", + ), + ).toEqual([]); + await session.close(); + }); + + it("declines keyed V2 elicitation while withholding sampling and roots", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "decline-input", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (method === "tasks/get") { + getCalls += 1; + return { + kind: "result", + result: asJson( + getCalls === 1 + ? { + resultType: "complete", + taskId: "decline-input", + status: "input_required", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + inputRequests: { + elicit: { method: "elicitation/create", params: {} }, + sample: { method: "sampling/createMessage", params: {} }, + roots: { method: "roots/list" }, + }, + } + : { + resultType: "complete", + taskId: "decline-input", + status: "completed", + createdAt: "a", + lastUpdatedAt: "c", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + ), + }; + } + if (method === "tasks/update") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + onInputRequest: async () => { + await Promise.resolve(); + throw new Error("declined"); + }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toMatchObject({ + resultType: "complete", + }); + const updates = port.requests.filter( + (request) => expectRecord(request).method === "tasks/update", + ); + expect(updates).toHaveLength(1); + expect( + expectRecord(expectRecord(updates[0]).params).inputResponses, + ).toEqual({ + elicit: { action: "cancel" }, + }); + await session.close(); + }); + + it("aborts V2 input handling when a terminal notification arrives", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + let handlerSignal: AbortSignal | undefined; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "terminal-input", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (method === "tasks/get") { + getCalls += 1; + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "terminal-input", + status: "input_required", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + inputRequests: { + key: { method: "elicitation/create", params: {} }, + }, + }), + }; + } + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + onInputRequest: (_request, context) => { + handlerSignal = context.signal; + return new Promise((_resolve, reject) => + context.signal?.addEventListener( + "abort", + () => reject(asError(context.signal?.reason)), + { once: true }, + ), + ); + }, + }); + const execution = await session.callTool("x"); + while (handlerSignal === undefined) + await new Promise((resolve) => setTimeout(resolve, 1)); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "terminal-input", + status: "completed", + createdAt: "a", + lastUpdatedAt: "c", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + }), + ); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(handlerSignal.aborted).toBe(true); + expect(getCalls).toBe(1); + expect( + port.requests.some( + (request) => expectRecord(request).method === "tasks/update", + ), + ).toBe(false); + await session.close(); + }); + it("shares cancellation and enforces single-consumer task updates", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); let cancelCalls = 0; diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts index d6084cf..abe1eee 100644 --- a/packages/ext-tasks/src/client/index.ts +++ b/packages/ext-tasks/src/client/index.ts @@ -27,15 +27,21 @@ import { import { CallToolResultV2Codec, CancelTaskResultV2Codec, + CreateMessageResultV2Codec, CreateTaskResultV2Codec, + ElicitResultV2Codec, GetTaskResultV2Codec, + ListRootsResultV2Codec, TaskStatusNotificationV2Codec, ToolV2Codec, + UpdateTaskResultV2Codec, isCreateTaskResultV2, withTaskCapabilityV2, type CallToolResultV2, type DetailedTaskV2, type ErrorV2, + type InputRequestV2, + type InputResponseV2, type TaskExtensionCapabilitiesV2, type TaskEligibleMethodV2, type ToolV2, @@ -321,8 +327,6 @@ function unsupported(feature: string): Error { } const DEFAULT_TASK_POLL_INTERVAL_MS = 10; -const V2_INPUT_REQUIRED_UNSUPPORTED_MESSAGE = - "V2 input_required tasks are not supported until tasks/update is available"; type TaskTurn = { readonly sequence: number; readonly snapshot: TaskSnapshot } | undefined; @@ -341,6 +345,7 @@ type TaskDriver = ( cancelledError: Error, closedError: Error, isClosed: () => boolean, + inputSignal: AbortSignal, ) => Promise; class TaskExecution< @@ -349,6 +354,7 @@ class TaskExecution< > implements ToolExecutionCommon { readonly kind = "task" as const; private readonly controller = new AbortController(); + private readonly inputController = new AbortController(); private readonly cancellationController = new AbortController(); private readonly resultPromise: Promise; private readonly cancelledError = new Error("Task was cancelled"); @@ -375,6 +381,8 @@ class TaskExecution< lifecycleSignal?: AbortSignal, ) { this.initialSnapshot = initialSnapshot; + if (terminalStatus(initialSnapshot.task.status)) + this.inputController.abort(); this.lastAcceptedBytes = deterministicJson(initialSnapshot); if (lifecycleSignal !== undefined) { const abort = (): void => this.controller.abort(lifecycleSignal.reason); @@ -390,6 +398,7 @@ class TaskExecution< this.cancelledError, this.closedError, () => this.closed, + this.inputController.signal, ); } @@ -398,6 +407,7 @@ class TaskExecution< if (snapshot.task.taskId !== this.handle.taskId) return; const bytes = deterministicJson(snapshot); if (terminalStatus(snapshot.task.status)) { + this.inputController.abort(); if (this.terminalSnapshotBytes === undefined) { this.terminalSnapshot = snapshot; this.terminalSnapshotBytes = bytes; @@ -459,6 +469,7 @@ class TaskExecution< if (bytes === this.lastAcceptedBytes) return; this.lastAcceptedBytes = bytes; if (terminalStatus(snapshot.task.status)) { + this.inputController.abort(); if (bytes !== this.terminalSnapshotBytes) { this.terminalSnapshot ??= snapshot; this.terminalSnapshotBytes ??= bytes; @@ -574,6 +585,14 @@ class TaskExecution< return this.resultPromise; } + inputSignal(): AbortSignal { + return this.inputController.signal; + } + + endInputLifetime(): void { + this.inputController.abort(); + } + cancel(signal?: AbortSignal): Promise { throwIfAborted(signal); this.cancelPromise ??= this.cancelTask(this.cancellationController.signal); @@ -586,6 +605,7 @@ class TaskExecution< if (!this.closed) { this.closed = true; this.controller.abort(this.closedError); + this.inputController.abort(this.closedError); void this.cancel().catch(() => { // Cooperative cancellation is best effort during close. }); @@ -889,6 +909,7 @@ function requestParams( } interface OrdinaryInputCandidate { + readonly lifetime: "basic"; readonly generation: TaskGeneration; readonly toolName: string; readonly executionId: string; @@ -896,6 +917,16 @@ interface OrdinaryInputCandidate { readonly signal?: AbortSignal; } +interface V1TaskInputCandidate { + readonly lifetime: "task-v1"; + readonly generation: "v1"; + readonly taskId: TaskId; + readonly toolName: string; + readonly executionId: string; + readonly applicationContext: TApplicationContext; + readonly signal?: AbortSignal; +} + let nextExecutionId = 0; function defaultServerRequestResponse( @@ -934,6 +965,10 @@ class PortTaskEnabledSession< string, OrdinaryInputCandidate >(); + private readonly v1TaskInputCandidates = new Map< + string, + V1TaskInputCandidate + >(); private readonly activeTaskExecutions = new Set< TaskExecution >(); @@ -1041,6 +1076,7 @@ class PortTaskEnabledSession< const executionId = `execution-${++nextExecutionId}`; if (!callAsTaskV1) { this.ordinaryInputCandidates.set(executionId, { + lifetime: "basic", generation: generation === "none" ? "v1" : generation, toolName: name, executionId, @@ -1094,89 +1130,96 @@ class PortTaskEnabledSession< originalOperation: "tools/call", }; const initial: TaskSnapshot = { generation: "v1", task: created.task }; - return this.trackTaskExecution( - new TaskExecution( - options.applicationContext as TApplicationContext, - handle, - initial, - async ( - accept, - waitForTurn, - observe, - signal, - cancelledError, - closedError, - isClosed, - ) => { - let task = created.task; - let notificationSequence = 0; - while (!terminalStatus(task.status)) { - const turn = await waitForTurn( - notificationSequence, - Math.max( - DEFAULT_TASK_POLL_INTERVAL_MS, - task.pollInterval ?? DEFAULT_TASK_POLL_INTERVAL_MS, - ), - ); - const observed = - turn ?? - (await observe(notificationSequence, (observationSignal) => - dispatchWithRetry( - this.port, - { method: "tasks/get", params: { taskId: task.taskId } }, - observationSignal, - "observe", - ).then((response) => ({ - generation: "v1" as const, - task: decodeResult( - GetTaskResultV1Codec, - responseResult(response), - ), - })), - )); - if (observed?.snapshot.generation !== "v1") continue; - notificationSequence = observed.sequence; - task = observed.snapshot.task; - if (!isClosed()) accept({ generation: "v1", task }); - } - if (isClosed()) throw closedError; - if (task.status === "cancelled") throw cancelledError; - if (task.status === "failed") - throw new Error(task.statusMessage ?? "Task failed"); - const taskResult = responseResult( - await dispatchWithRetry( - this.port, - { method: "tasks/result", params: { taskId: task.taskId } }, - signal, - "observe", - ), - ); - decodeResult(TaskResultV1Codec, taskResult); - return decodeResult(codec, taskResult); - }, - async (signal) => { - const capabilities = this.port.taskCapabilities; - if ( - capabilities.generation !== "v1" || - capabilities.capabilities.cancel === undefined - ) - throw new TaskCancellationUnsupportedError(); - const cancelled = responseResult( - await dispatchWithRetry( - this.port, - { - method: "tasks/cancel", - params: { taskId: created.task.taskId }, - }, - signal, - "mutate", + const execution = new TaskExecution( + options.applicationContext as TApplicationContext, + handle, + initial, + async ( + accept, + waitForTurn, + observe, + signal, + cancelledError, + closedError, + isClosed, + ) => { + let task = created.task; + let notificationSequence = 0; + while (!terminalStatus(task.status)) { + const turn = await waitForTurn( + notificationSequence, + Math.max( + DEFAULT_TASK_POLL_INTERVAL_MS, + task.pollInterval ?? DEFAULT_TASK_POLL_INTERVAL_MS, ), ); - decodeResult(CancelTaskResultV1Codec, cancelled); - }, - this.lifecycleController.signal, - ), + const observed = + turn ?? + (await observe(notificationSequence, (observationSignal) => + dispatchWithRetry( + this.port, + { method: "tasks/get", params: { taskId: task.taskId } }, + observationSignal, + "observe", + ).then((response) => ({ + generation: "v1" as const, + task: decodeResult( + GetTaskResultV1Codec, + responseResult(response), + ), + })), + )); + if (observed?.snapshot.generation !== "v1") continue; + notificationSequence = observed.sequence; + task = observed.snapshot.task; + if (!isClosed()) accept({ generation: "v1", task }); + } + if (isClosed()) throw closedError; + if (task.status === "cancelled") throw cancelledError; + if (task.status === "failed") + throw new Error(task.statusMessage ?? "Task failed"); + const taskResult = responseResult( + await dispatchWithRetry( + this.port, + { method: "tasks/result", params: { taskId: task.taskId } }, + signal, + "observe", + ), + ); + decodeResult(TaskResultV1Codec, taskResult); + return decodeResult(codec, taskResult); + }, + async (signal) => { + const capabilities = this.port.taskCapabilities; + if ( + capabilities.generation !== "v1" || + capabilities.capabilities.cancel === undefined + ) + throw new TaskCancellationUnsupportedError(); + const cancelled = responseResult( + await dispatchWithRetry( + this.port, + { + method: "tasks/cancel", + params: { taskId: created.task.taskId }, + }, + signal, + "mutate", + ), + ); + decodeResult(CancelTaskResultV1Codec, cancelled); + }, + this.lifecycleController.signal, ); + return this.trackTaskExecution(execution, { + lifetime: "task-v1", + generation: "v1", + taskId: created.task.taskId as TaskId, + toolName: name, + executionId, + applicationContext: options.applicationContext as TApplicationContext, + signal: execution.inputSignal(), + }); } if (generation === "v2" && isCreateTaskResultV2(wireResult)) { @@ -1200,12 +1243,109 @@ class PortTaskEnabledSession< cancelledError, closedError, isClosed, + inputSignal, ) => { let status = created.status; let current: DetailedTaskV2 | undefined; let notificationSequence = 0; - if (status === "input_required") - throw new Error(V2_INPUT_REQUIRED_UNSUPPORTED_MESSAGE); + const acquiredInputs = new Map(); + const acquireInputs = async ( + task: DetailedTaskV2, + ): Promise => { + if (task.status !== "input_required") return; + const inputResponses: Record = {}; + for (const [inputKey, inputRequest] of Object.entries( + task.inputRequests, + )) { + const signature = deterministicJson(inputRequest); + const acquiredSignature = acquiredInputs.get(inputKey); + if (acquiredSignature !== undefined) { + if (acquiredSignature !== signature) + this.reportBackgroundError( + new Error( + `V2 task input key ${inputKey} was reused incompatibly`, + ), + ); + continue; + } + acquiredInputs.set(inputKey, signature); + const request: InputRequestV2 = inputRequest; + const projected: ApplicationInputRequest | undefined = + request.method === "sampling/createMessage" + ? { kind: "sampling", params: request.params } + : request.method === "roots/list" + ? { + kind: "roots", + ...(request.params === undefined + ? {} + : { params: request.params }), + } + : request.method === "elicitation/create" + ? { kind: "elicitation", params: request.params } + : undefined; + if (projected === undefined) { + this.reportBackgroundError( + new Error( + `Unknown V2 task input method for key ${inputKey}`, + ), + ); + continue; + } + let result: unknown; + if (this.options.onInputRequest === undefined) { + if (request.method !== "elicitation/create") continue; + result = { action: "cancel" }; + } else { + try { + result = await this.options.onInputRequest(projected, { + lifetime: "task-v2", + taskId: task.taskId, + inputKey, + applicationContext: + options.applicationContext as TApplicationContext, + signal: inputSignal, + }); + } catch { + if (inputSignal.aborted) return; + if (request.method !== "elicitation/create") continue; + result = { action: "cancel" }; + } + } + try { + const codec = + request.method === "sampling/createMessage" + ? CreateMessageResultV2Codec + : request.method === "roots/list" + ? ListRootsResultV2Codec + : ElicitResultV2Codec; + inputResponses[inputKey] = decodeResult( + codec as RuntimeCodec, + result as JsonValue, + ); + } catch (error) { + this.reportBackgroundError( + error instanceof Error ? error : new Error(String(error)), + ); + } + } + if (inputSignal.aborted) return; + if (Object.keys(inputResponses).length === 0) return; + const updated = responseResult( + await dispatchWithRetry( + this.port, + { + method: "tasks/update", + params: withTaskCapabilityV2({ + taskId: task.taskId, + inputResponses, + }), + }, + signal, + "mutate", + ), + ); + decodeResult(UpdateTaskResultV2Codec, updated); + }; while (!terminalStatus(status)) { const delayMs = Math.max( DEFAULT_TASK_POLL_INTERVAL_MS, @@ -1239,8 +1379,7 @@ class PortTaskEnabledSession< current = next; status = next.status; if (!isClosed()) accept({ generation: "v2", task: next }); - if (status === "input_required") - throw new Error(V2_INPUT_REQUIRED_UNSUPPORTED_MESSAGE); + await acquireInputs(next); } if (isClosed()) throw closedError; if (current === undefined) { @@ -1361,13 +1500,33 @@ class PortTaskEnabledSession< private trackTaskExecution( execution: TaskExecution, + v1InputCandidate?: V1TaskInputCandidate, ): TaskExecution { const tracked = execution as TaskExecution; this.activeTaskExecutions.add(tracked); + if ( + v1InputCandidate !== undefined && + v1InputCandidate.signal?.aborted !== true + ) { + this.v1TaskInputCandidates.set( + v1InputCandidate.executionId, + v1InputCandidate, + ); + v1InputCandidate.signal?.addEventListener( + "abort", + () => this.v1TaskInputCandidates.delete(v1InputCandidate.executionId), + { once: true }, + ); + } void execution .result() .catch(() => {}) - .finally(() => this.activeTaskExecutions.delete(tracked)); + .finally(() => { + execution.endInputLifetime(); + this.activeTaskExecutions.delete(tracked); + if (v1InputCandidate !== undefined) + this.v1TaskInputCandidates.delete(v1InputCandidate.executionId); + }); return execution; } @@ -1404,8 +1563,6 @@ class PortTaskEnabledSession< private async handleServerRequest( incoming: IncomingServerRequest, ): Promise { - if (this.options.onInputRequest === undefined) - return defaultServerRequestResponse(incoming); if ( incoming.request === null || Array.isArray(incoming.request) || @@ -1429,15 +1586,58 @@ class PortTaskEnabledSession< } : undefined; if (request === undefined) return defaultServerRequestResponse(incoming); - if (this.ordinaryInputCandidates.size !== 1) { - const candidates = [...this.ordinaryInputCandidates.values()].map( - (candidate) => ({ - generation: candidate.generation, - toolName: candidate.toolName, - executionId: candidate.executionId, - applicationContext: candidate.applicationContext, - }), - ); + const taskCandidates = [...this.v1TaskInputCandidates.values()]; + const ordinaryCandidates = [...this.ordinaryInputCandidates.values()]; + const params = request.params; + const meta = params?._meta; + const relatedTaskKey = "io.modelcontextprotocol/related-task"; + let evidence: "absent" | "invalid" | { readonly taskId: string }; + if (meta === undefined) evidence = "absent"; + else if (meta === null || Array.isArray(meta) || typeof meta !== "object") + evidence = "invalid"; + else { + const relatedTask = (meta as Readonly>)[ + relatedTaskKey + ]; + if (relatedTask === undefined) evidence = "absent"; + else if ( + relatedTask === null || + Array.isArray(relatedTask) || + typeof relatedTask !== "object" || + typeof (relatedTask as Readonly>).taskId !== + "string" + ) + evidence = "invalid"; + else + evidence = { + taskId: (relatedTask as Readonly>) + .taskId as string, + }; + } + const allCandidates = [...ordinaryCandidates, ...taskCandidates]; + const matches = + evidence === "absent" || evidence === "invalid" + ? allCandidates + : taskCandidates.filter( + (candidate) => candidate.taskId === evidence.taskId, + ); + const failureReason: InputCorrelationFailureReason | undefined = + evidence === "invalid" + ? "invalid-evidence" + : evidence === "absent" && matches.length === 0 + ? "missing-evidence" + : matches.length === 0 + ? "zero-matches" + : matches.length > 1 + ? "ambiguous-matches" + : undefined; + if (failureReason !== undefined) { + const candidates = matches.map((candidate) => ({ + generation: candidate.generation, + toolName: candidate.toolName, + executionId: candidate.executionId, + applicationContext: candidate.applicationContext, + })); this.reportBackgroundError( new InputCorrelationError( this.port.taskCapabilities.generation === "none" @@ -1445,20 +1645,34 @@ class PortTaskEnabledSession< : this.port.taskCapabilities.generation, request.kind, candidates, - candidates.length === 0 ? "zero-matches" : "ambiguous-matches", + failureReason, ), ); return defaultServerRequestResponse(incoming); } - const candidate = this.ordinaryInputCandidates.values().next() - .value as OrdinaryInputCandidate; + const candidate = matches[0]; + if (this.options.onInputRequest === undefined) + return defaultServerRequestResponse(incoming); try { - const result = await this.options.onInputRequest(request, { - lifetime: "basic", - executionId: candidate.executionId, - applicationContext: candidate.applicationContext, - ...(candidate.signal === undefined ? {} : { signal: candidate.signal }), - }); + const context: ResolvedInputExchangeContext = + candidate.lifetime === "task-v1" + ? { + lifetime: "task-v1", + taskId: candidate.taskId, + applicationContext: candidate.applicationContext, + ...(candidate.signal === undefined + ? {} + : { signal: candidate.signal }), + } + : { + lifetime: "basic", + executionId: candidate.executionId, + applicationContext: candidate.applicationContext, + ...(candidate.signal === undefined + ? {} + : { signal: candidate.signal }), + }; + const result = await this.options.onInputRequest(request, context); if (!isJsonValue(result)) throw new Error("Input handler returned a non-JSON value"); return { kind: "result", result }; From e15ec5d1ca810d2618ffb63850c6497c2c44b9e2 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 4 Sep 2026 21:21:05 -0700 Subject: [PATCH 08/33] feat(client): add task resume and Client adapter - Bind serialized references to durable endpoint identities - Resume V1 and V2 tasks with generation-specific lifecycles - Adapt Client 2.0 through public request and handler seams --- packages/ext-tasks/src/client/index.test.ts | 646 ++++++++++++++++++ packages/ext-tasks/src/client/index.ts | 683 +++++++++++++++++++- 2 files changed, 1315 insertions(+), 14 deletions(-) diff --git a/packages/ext-tasks/src/client/index.test.ts b/packages/ext-tasks/src/client/index.test.ts index 0aa8514..00b1e19 100644 --- a/packages/ext-tasks/src/client/index.test.ts +++ b/packages/ext-tasks/src/client/index.test.ts @@ -1,3 +1,10 @@ +import { + Client, + ProtocolError, + SdkError, + SdkErrorCode, + type ClientContext, +} from "@modelcontextprotocol/client"; import fc from "fast-check"; import { describe, expect, it, vi } from "vitest"; @@ -5,11 +12,13 @@ import { createRuntimeCodec, expectRecord, type JsonValue, + type TaskId, } from "../core/index.js"; import type { ServerTaskCapabilitiesV1, ToolV1 } from "../core/v1/index.js"; import { DispatchError, + createSessionPortFromClient, InputCorrelationError, JsonRpcResponseError, TaskCancellationUnsupportedError, @@ -20,6 +29,7 @@ import { type IncomingServerRequest, type JsonRpcResponse, type SessionTaskCapabilities, + type SerializedTaskReference, } from "./index.js"; const asJson = (value: unknown): JsonValue => @@ -32,6 +42,7 @@ const asError = (reason: unknown): Error => reason instanceof Error ? reason : new Error(formatJson(reason)); class FakePort implements ConnectedMcpSessionPort { + readonly endpointId: string; readonly requests: JsonValue[] = []; readonly taskCapabilities: SessionTaskCapabilities; invalidated = false; @@ -49,8 +60,10 @@ class FakePort implements ConnectedMcpSessionPort { constructor( taskCapabilities: SessionTaskCapabilities = { generation: "none" }, + endpointId = "fake-endpoint", ) { this.taskCapabilities = taskCapabilities; + this.endpointId = endpointId; } async dispatch( @@ -2387,3 +2400,636 @@ describe("client tool executions", () => { await session.close(); }); }); + +describe("task reference resumption", () => { + it("does not expose reference serialization on immediate executions", async () => { + const port = new FakePort(); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("immediate"); + expect(execution.kind).toBe("immediate"); + expect("serializeReference" in execution).toBe(false); + await session.close(); + }); + + it("rejects endpoint, generation, and operation mismatches before dispatch", async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom("endpoint", "generation", "operation"), + fc.string({ minLength: 1 }), + async (mismatch, suffix) => { + const port = new FakePort( + { generation: "v2", capabilities: {} }, + "endpoint-a", + ); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const reference = { + endpointId: + mismatch === "endpoint" ? `other-${suffix}` : "endpoint-a", + generation: mismatch === "generation" ? "v1" : "v2", + taskId: `task-${suffix}`, + originalOperation: + mismatch === "operation" ? "unsupported/operation" : "tools/call", + } as SerializedTaskReference; + await expect(session.resumeTask(reference)).rejects.toThrow(); + expect(port.requests).toHaveLength(0); + await session.close(); + }, + ), + { numRuns: 20 }, + ); + }); + + it("labels resumed V1 candidates without inventing a tool name", async () => { + const port = new FakePort( + { + generation: "v1", + capabilities: { requests: { tools: { call: {} } }, cancel: {} }, + }, + "resume-endpoint", + ); + let finishOrdinary: ((response: JsonRpcResponse) => void) | undefined; + let getCalls = 0; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tasks/get") { + getCalls += 1; + if (getCalls === 1) + return { + kind: "result", + result: asJson({ + taskId: "resumed-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }), + }; + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ), + ); + } + if (record.method === "tools/call") + return new Promise((resolve) => { + finishOrdinary = resolve; + }); + if (record.method === "tasks/cancel") + return { + kind: "result", + result: asJson({ + taskId: "resumed-task", + status: "cancelled", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }), + }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const errors: Error[] = []; + const session = withTasks(port, { + tools: { + currentTool: (name) => + name === "ordinary" + ? { name, inputSchema: { type: "object" } } + : undefined, + }, + onError: (error) => errors.push(error), + }); + const resumed = await session.resumeTask({ + endpointId: port.endpointId, + generation: "v1", + taskId: "resumed-task" as TaskId, + originalOperation: "tools/call", + }); + const ordinary = session.callTool("ordinary"); + while (finishOrdinary === undefined) await Promise.resolve(); + await port.serve({ method: "elicitation/create", params: {} }); + expect(errors).toHaveLength(1); + const candidates = (errors[0] as InputCorrelationError).candidates; + expect(candidates.map((candidate) => candidate.toolName)).toEqual([ + "ordinary", + "", + ]); + expect(candidates.every((candidate) => !("taskId" in candidate))).toBe( + true, + ); + finishOrdinary({ kind: "result", result: { content: [] } }); + await ordinary; + await resumed.close(); + await session.close(); + }); + + it("roundtrips serialized task references across V1/V2 terminal and nonterminal tasks", async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom("v1", "v2"), + fc.boolean(), + fc.stringMatching(/^[a-z0-9]{1,12}$/), + async (generation, initiallyTerminal, taskSuffix) => { + const taskId = `task-${taskSuffix}`; + const endpointId = `endpoint-${taskSuffix}`; + const capabilities: SessionTaskCapabilities = + generation === "v1" + ? { + generation: "v1", + capabilities: { + requests: { tools: { call: {} } }, + cancel: {}, + }, + } + : { generation: "v2", capabilities: {} }; + const sourcePort = new FakePort(capabilities, endpointId); + sourcePort.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tools/call") + return generation === "v1" + ? { + kind: "result", + result: asJson({ + task: { + taskId, + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + pollInterval: 1000, + }, + }), + } + : { + kind: "result", + result: asJson({ + resultType: "task", + taskId, + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + pollIntervalMs: 1000, + }), + }; + if (method === "tasks/cancel") + return { + kind: "result", + result: asJson( + generation === "v2" ? { resultType: "complete" } : {}, + ), + }; + throw new Error(`unexpected source method ${formatJson(method)}`); + }; + const sourceSession = withTasks(sourcePort, { + tools: { + currentTool: () => + generation === "v1" + ? { + name: "roundtrip", + inputSchema: {}, + execution: { taskSupport: "required" }, + } + : { name: "roundtrip", inputSchema: {} }, + }, + }); + const sourceExecution = await sourceSession.callTool("roundtrip"); + expect(sourceExecution.kind).toBe("task"); + if (sourceExecution.kind !== "task") throw new Error("expected task"); + const reference = sourceExecution.serializeReference(); + expect(reference).toEqual({ + endpointId, + generation, + taskId, + originalOperation: "tools/call", + }); + + const resumedPort = new FakePort(capabilities, endpointId); + let getCalls = 0; + resumedPort.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tasks/get") { + getCalls += 1; + const terminal = initiallyTerminal || getCalls > 1; + return generation === "v1" + ? { + kind: "result", + result: asJson({ + taskId, + status: terminal ? "completed" : "working", + createdAt: "a", + lastUpdatedAt: terminal ? "b" : "a", + ttl: null, + pollInterval: 0, + }), + } + : { + kind: "result", + result: asJson({ + resultType: "complete", + taskId, + status: terminal ? "completed" : "working", + createdAt: "a", + lastUpdatedAt: terminal ? "b" : "a", + ttlMs: null, + pollIntervalMs: 0, + ...(terminal + ? { result: { resultType: "complete", content: [] } } + : {}), + }), + }; + } + if (method === "tasks/result") + return { + kind: "result", + result: asJson({ + content: [{ type: "text", text: taskSuffix }], + }), + }; + if (method === "tasks/cancel") + return { + kind: "result", + result: asJson( + generation === "v2" ? { resultType: "complete" } : {}, + ), + }; + throw new Error(`unexpected resumed method ${formatJson(method)}`); + }; + const applicationContext = { taskSuffix }; + const resumedSession = withTasks( + resumedPort, + { + tools: { currentTool: () => undefined }, + }, + ); + const resumed = await resumedSession.resumeTask(reference, { + applicationContext, + }); + expect(resumed.kind).toBe("task"); + if (resumed.kind !== "task") throw new Error("expected resumed task"); + expect(resumed.applicationContext).toBe(applicationContext); + expect(resumed.serializeReference()).toEqual(reference); + await expect(resumed.result()).resolves.toEqual( + generation === "v1" + ? { content: [{ type: "text", text: taskSuffix }] } + : { resultType: "complete", content: [] }, + ); + expect(getCalls).toBe(initiallyTerminal ? 1 : 2); + const firstRequest = expectRecord(resumedPort.requests[0]); + expect(firstRequest.method).toBe("tasks/get"); + if (generation === "v2") + expect(firstRequest.params).toMatchObject({ + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }); + expect( + resumedPort.requests.some( + (request) => expectRecord(request).method === "tasks/result", + ), + ).toBe(generation === "v1"); + await resumedSession.close(); + await sourceSession.close(); + }, + ), + { numRuns: 12 }, + ); + }); + + it("retries the initial resumed observation once for any DispatchError", async () => { + await fc.assert( + fc.asyncProperty(fc.boolean(), async (retryable) => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let calls = 0; + port.dispatchHandler = async () => { + await Promise.resolve(); + calls += 1; + if (calls === 1) + throw new DispatchError("initial get failed", retryable); + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "retry-resume", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }), + }; + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.resumeTask({ + endpointId: port.endpointId, + generation: "v2", + taskId: "retry-resume" as TaskId, + originalOperation: "tools/call", + }); + await expect(execution.result()).resolves.toMatchObject({ + content: [], + }); + expect(calls).toBe(2); + await session.close(); + }), + { numRuns: 10 }, + ); + }); +}); + +describe("Client session integration", () => { + const client = () => new Client({ name: "test", version: "1" }); + const context = { + mcpReq: { + id: 1, + method: "custom/request", + requestState: () => undefined, + signal: new AbortController().signal, + send: vi.fn(), + notify: vi.fn(), + }, + } satisfies ClientContext; + + it("dispatches with an explicit schema and signal, preserving full protocol errors", async () => { + const sdk = client(); + const request = vi.spyOn(sdk, "request"); + const port = createSessionPortFromClient(sdk, "endpoint-sdk"); + const controller = new AbortController(); + request.mockResolvedValueOnce({ ok: true }); + await expect( + port.dispatch( + { method: "custom/method", params: { value: 1 } }, + { signal: controller.signal }, + ), + ).resolves.toEqual({ kind: "result", result: { ok: true } }); + const schema: unknown = request.mock.calls[0]?.[1]; + expect(schema).toBeTypeOf("object"); + expect(schema).toHaveProperty("~standard"); + expect(request.mock.calls[0]?.[2]).toEqual({ signal: controller.signal }); + request.mockRejectedValueOnce( + new ProtocolError(-32001, "denied", { retry: false }), + ); + await expect(port.dispatch({ method: "custom/method" })).resolves.toEqual({ + kind: "error", + error: { code: -32001, message: "denied", data: { retry: false } }, + }); + }); + + it("wraps cancellation and local SDK failures as non-retryable DispatchError", async () => { + const sdk = client(); + const request = vi.spyOn(sdk, "request"); + const port = createSessionPortFromClient(sdk, "endpoint-sdk"); + for (const failure of [ + new DOMException("cancelled", "AbortError"), + new SdkError(SdkErrorCode.ConnectionClosed, "closed"), + ]) { + request.mockRejectedValueOnce(failure); + await expect( + port.dispatch({ method: "custom/method" }), + ).rejects.toMatchObject({ + name: "DispatchError", + retryable: false, + cause: failure, + }); + } + }); + + it("derives immutable legacy, modern, and absent task capabilities", () => { + const legacy = client(); + const legacyCapabilities = { tasks: { cancel: {}, list: {} } }; + vi.spyOn(legacy, "getProtocolEra").mockReturnValue("legacy"); + vi.spyOn(legacy, "getServerCapabilities").mockReturnValue( + legacyCapabilities, + ); + const legacyPort = createSessionPortFromClient(legacy, "legacy"); + expect(legacyPort.endpointId).toBe("legacy"); + expect(legacyPort.taskCapabilities).toEqual({ + generation: "v1", + capabilities: { cancel: {}, list: {} }, + }); + legacyCapabilities.tasks.cancel = { changed: true }; + expect(legacyPort.taskCapabilities).toEqual({ + generation: "v1", + capabilities: { cancel: {}, list: {} }, + }); + const modern = client(); + vi.spyOn(modern, "getProtocolEra").mockReturnValue("modern"); + vi.spyOn(modern, "getServerCapabilities").mockReturnValue({ + extensions: { "io.modelcontextprotocol/tasks": {} }, + }); + expect( + createSessionPortFromClient(modern, "modern").taskCapabilities, + ).toEqual({ generation: "v2", capabilities: {} }); + const absent = client(); + vi.spyOn(absent, "getProtocolEra").mockReturnValue("modern"); + vi.spyOn(absent, "getServerCapabilities").mockReturnValue({ + extensions: {}, + }); + expect( + createSessionPortFromClient(absent, "none").taskCapabilities, + ).toEqual({ generation: "none" }); + }); + + it("forwards inbound requests and settles results and full errors", async () => { + const sdk = client(); + const port = createSessionPortFromClient(sdk, "endpoint-sdk"); + const disposeResult = port.onServerRequest((incoming) => + Promise.resolve({ + kind: "result", + result: { echoed: incoming.request }, + }), + ); + await expect( + sdk.fallbackRequestHandler?.( + { + jsonrpc: "2.0", + id: 1, + method: "elicitation/create", + params: {}, + }, + context, + ), + ).resolves.toEqual({ + echoed: { + jsonrpc: "2.0", + id: 1, + method: "elicitation/create", + params: {}, + }, + }); + disposeResult(); + const disposeError = port.onServerRequest(() => + Promise.resolve({ + kind: "error", + error: { code: -32002, message: "failed", data: { reason: "x" } }, + }), + ); + await expect( + sdk.fallbackRequestHandler?.( + { jsonrpc: "2.0", id: 2, method: "elicitation/create", params: {} }, + context, + ), + ).rejects.toMatchObject({ + code: -32002, + message: "failed", + data: { reason: "x" }, + }); + disposeError(); + }); + + it("chains prior fallbacks, forwards notifications, invalidates on close, and cleans up", async () => { + const sdk = client(); + const priorRequest = vi.fn(() => Promise.resolve({ prior: true })); + const priorNotification = vi.fn(() => Promise.resolve()); + const priorClose = vi.fn(); + sdk.fallbackRequestHandler = priorRequest; + sdk.fallbackNotificationHandler = priorNotification; + sdk.onclose = priorClose; + const port = createSessionPortFromClient(sdk, "endpoint-sdk"); + const installedRequest = sdk.fallbackRequestHandler; + const installedNotification = sdk.fallbackNotificationHandler; + const installedClose = sdk.onclose; + const notifications: JsonValue[] = []; + const invalidations: unknown[] = []; + const removeNotification = port.onNotification((value) => + notifications.push(value), + ); + const removeInvalidation = port.onInvalidated((reason) => + invalidations.push(reason), + ); + await expect( + installedRequest?.({ jsonrpc: "2.0", id: 1, method: "other" }, context), + ).resolves.toEqual({ prior: true }); + await installedNotification?.({ + method: "custom/notification", + params: { value: 1 }, + }); + expect(priorNotification).toHaveBeenCalledOnce(); + expect(notifications).toEqual([ + { method: "custom/notification", params: { value: 1 } }, + ]); + removeNotification(); + await installedNotification?.({ + method: "custom/notification", + params: { value: 2 }, + }); + expect(notifications).toHaveLength(1); + installedClose?.(); + expect(priorClose).toHaveBeenCalledOnce(); + expect(port.invalidated).toBe(true); + expect(invalidations).toHaveLength(1); + removeInvalidation(); + port[Symbol.dispose](); + expect(sdk.fallbackRequestHandler).toBe(priorRequest); + expect(sdk.fallbackNotificationHandler).toBe(priorNotification); + expect(sdk.onclose).toBe(priorClose); + }); + + it("does not overwrite callbacks installed after adaptation", () => { + const sdk = client(); + const port = createSessionPortFromClient(sdk, "endpoint-sdk"); + const replacement = vi.fn(() => Promise.resolve({ replacement: true })); + sdk.fallbackRequestHandler = replacement; + port[Symbol.dispose](); + expect(sdk.fallbackRequestHandler).toBe(replacement); + }); + + it("rejects concurrent adapters and permits reuse after disposal", () => { + const sdk = client(); + const first = createSessionPortFromClient(sdk, "endpoint-sdk"); + expect(() => createSessionPortFromClient(sdk, "endpoint-sdk")).toThrow( + "already active", + ); + first[Symbol.dispose](); + const replacement = createSessionPortFromClient(sdk, "endpoint-sdk"); + replacement[Symbol.dispose](); + }); + + it("accepts Client-compatible objects from another constructor", async () => { + class ForeignClient { + fallbackRequestHandler: Client["fallbackRequestHandler"]; + fallbackNotificationHandler: Client["fallbackNotificationHandler"]; + onclose: Client["onclose"]; + readonly request = vi.fn(() => Promise.resolve({ content: [] })); + getProtocolEra(): ReturnType { + return "legacy"; + } + getServerCapabilities(): ReturnType { + return {}; + } + } + const foreign = new ForeignClient(); + const session = withTasks(foreign as unknown as Client, { + endpointId: "foreign-client", + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toEqual({ content: [] }); + expect(foreign.request).toHaveBeenCalled(); + await session.close(); + }); + + it("supports Client sessions through withTasks and restores callbacks", async () => { + const sdk = client(); + const request = vi.spyOn(sdk, "request").mockResolvedValue({ content: [] }); + const prior = vi.fn(() => Promise.resolve({ prior: true })); + sdk.fallbackRequestHandler = prior; + const session = withTasks(sdk, { + endpointId: "raw-client", + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toEqual({ content: [] }); + expect(request).toHaveBeenCalledWith( + { method: "tools/call", params: { name: "x" } }, + expect.any(Object), + expect.any(Object), + ); + await expect( + sdk.fallbackRequestHandler?.( + { jsonrpc: "2.0", id: 9, method: "custom/unrelated" }, + context, + ), + ).resolves.toEqual({ prior: true }); + expect(prior).toHaveBeenCalledWith( + { jsonrpc: "2.0", id: 9, method: "custom/unrelated" }, + context, + ); + await session.close(); + expect(sdk.fallbackRequestHandler).toBe(prior); + expect(sdk.transport).toBeUndefined(); + }); + + it("restores Client ownership when an earlier close disposer fails", async () => { + const sdk = client(); + const prior = vi.fn(() => Promise.resolve({ prior: true })); + sdk.fallbackRequestHandler = prior; + const controller = new AbortController(); + const sentinel = new Error("listener cleanup failed"); + vi.spyOn(controller.signal, "removeEventListener").mockImplementation( + () => { + throw sentinel; + }, + ); + const session = withTasks(sdk, { + endpointId: "close-failure", + signal: controller.signal, + tools: { currentTool: () => undefined }, + }); + await expect(session.close()).rejects.toBe(sentinel); + await expect(session.close()).rejects.toBe(sentinel); + expect(sdk.fallbackRequestHandler).toBe(prior); + const replacement = createSessionPortFromClient(sdk, "close-failure"); + replacement[Symbol.dispose](); + }); +}); diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts index abe1eee..da62ab4 100644 --- a/packages/ext-tasks/src/client/index.ts +++ b/packages/ext-tasks/src/client/index.ts @@ -1,5 +1,10 @@ /** Requester-side MCP Tasks session and execution support. */ +import { + Client, + ProtocolError, + type StandardSchemaV1, +} from "@modelcontextprotocol/client"; import { isJsonValue, isJsonArray, @@ -68,6 +73,7 @@ export interface IncomingServerRequest { } export interface ConnectedMcpSessionPort { + readonly endpointId: string; readonly taskCapabilities: SessionTaskCapabilities; dispatch( request: JsonValue, @@ -81,6 +87,278 @@ export interface ConnectedMcpSessionPort { readonly invalidated: boolean; } +const jsonValueSchema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "@modelcontextprotocol/ext-tasks", + validate(value) { + return isJsonValue(value) + ? { value } + : { issues: [{ message: "Expected a JSON value" }] }; + }, + }, +}; + +function isJsonRecord( + value: unknown, +): value is Readonly> { + return ( + isJsonValue(value) && + value !== null && + !Array.isArray(value) && + typeof value === "object" + ); +} + +function clientTaskCapabilities( + client: ClientPublicSurface, +): SessionTaskCapabilities { + const capabilities = client.getServerCapabilities(); + if (client.getProtocolEra() === "modern") { + const extension = + capabilities?.extensions?.["io.modelcontextprotocol/tasks"]; + if ( + extension !== null && + typeof extension === "object" && + !Array.isArray(extension) && + Object.keys(extension).length === 0 + ) + return { generation: "v2", capabilities: {} }; + return { generation: "none" }; + } + const tasks = capabilities?.tasks; + return tasks === undefined + ? { generation: "none" } + : { generation: "v1", capabilities: structuredClone(tasks) }; +} + +function asClientRequest(request: JsonValue): { + readonly method: string; + readonly params?: Readonly>; +} { + if (!isJsonRecord(request)) + throw new DispatchError("MCP request must be a JSON object"); + const method = request.method; + if (typeof method !== "string") + throw new DispatchError("MCP request method must be a string"); + const params = request.params; + if (params === undefined) return { method }; + if (!isJsonRecord(params)) + throw new DispatchError("MCP request params must be a JSON object"); + return { method, params }; +} + +function isTaskInputMethod(method: string): boolean { + return ( + method === "elicitation/create" || + method === "sampling/createMessage" || + method === "roots/list" + ); +} + +type ClientPublicSurface = Pick< + Client, + | "request" + | "getProtocolEra" + | "getServerCapabilities" + | "fallbackRequestHandler" + | "fallbackNotificationHandler" + | "onclose" +>; + +const adaptedClients = new WeakSet(); + +function isConnectedMcpSessionPort( + value: unknown, +): value is ConnectedMcpSessionPort { + if (value === null || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.endpointId === "string" && + candidate.taskCapabilities !== undefined && + typeof candidate.dispatch === "function" && + typeof candidate.onServerRequest === "function" && + typeof candidate.onNotification === "function" && + typeof candidate.onInvalidated === "function" && + typeof candidate.invalidated === "boolean" + ); +} + +function isClientPublicSurface(value: unknown): value is ClientPublicSurface { + if (value === null || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.request === "function" && + typeof candidate.getProtocolEra === "function" && + typeof candidate.getServerCapabilities === "function" + ); +} + +class ClientSessionPort implements ConnectedMcpSessionPort { + readonly taskCapabilities: SessionTaskCapabilities; + private readonly serverRequestListeners = new Set< + (incoming: IncomingServerRequest) => Promise + >(); + private readonly notificationListeners = new Set< + (notification: JsonValue) => void + >(); + private readonly invalidationListeners = new Set<(reason: unknown) => void>(); + private readonly previousFallbackRequestHandler: ClientPublicSurface["fallbackRequestHandler"]; + private readonly previousFallbackNotificationHandler: ClientPublicSurface["fallbackNotificationHandler"]; + private readonly previousOnclose: ClientPublicSurface["onclose"]; + private disposed = false; + private isInvalidated = false; + + private readonly fallbackRequestHandler: NonNullable< + ClientPublicSurface["fallbackRequestHandler"] + > = async (request, context) => { + if (!isTaskInputMethod(request.method)) { + if (this.previousFallbackRequestHandler !== undefined) + return this.previousFallbackRequestHandler(request, context); + throw new ProtocolError(-32601, `Method not found: ${request.method}`); + } + const listener = this.serverRequestListeners.values().next().value; + if (listener === undefined) { + if (this.previousFallbackRequestHandler !== undefined) + return this.previousFallbackRequestHandler(request, context); + throw new ProtocolError(-32601, `Method not found: ${request.method}`); + } + if (!isJsonValue(request)) + throw new ProtocolError(-32600, "Inbound request is not JSON"); + const response = await listener({ request, requestContext: context }); + if (response.kind === "error") + throw new ProtocolError( + response.error.code, + response.error.message, + response.error.data, + ); + if (!isJsonRecord(response.result)) + throw new ProtocolError( + -32603, + "Inbound handler returned a non-object result", + ); + return response.result; + }; + + private readonly fallbackNotificationHandler: NonNullable< + ClientPublicSurface["fallbackNotificationHandler"] + > = async (notification) => { + await this.previousFallbackNotificationHandler?.(notification); + if (!isJsonValue(notification)) return; + for (const listener of [...this.notificationListeners]) + listener(notification); + }; + + private readonly onclose = (): void => { + try { + this.previousOnclose?.(); + } finally { + this.invalidate(new Error("MCP client connection closed")); + } + }; + + constructor( + private readonly client: ClientPublicSurface, + readonly endpointId: string, + ) { + if (adaptedClients.has(client)) + throw new TypeError( + "An ext-tasks adapter is already active for this Client", + ); + this.previousFallbackRequestHandler = client.fallbackRequestHandler; + this.previousFallbackNotificationHandler = + client.fallbackNotificationHandler; + this.previousOnclose = client.onclose; + this.taskCapabilities = clientTaskCapabilities(client); + adaptedClients.add(client); + client.fallbackRequestHandler = this.fallbackRequestHandler; + client.fallbackNotificationHandler = this.fallbackNotificationHandler; + client.onclose = this.onclose; + } + + get invalidated(): boolean { + return this.isInvalidated; + } + + async dispatch( + request: JsonValue, + options: { readonly signal?: AbortSignal } = {}, + ): Promise { + try { + const result = await this.client.request( + asClientRequest(request), + jsonValueSchema, + options.signal === undefined ? {} : { signal: options.signal }, + ); + return { kind: "result", result }; + } catch (error) { + if (error instanceof ProtocolError) { + const data = error.data; + return { + kind: "error", + error: { + code: error.code, + message: error.message, + ...(data === undefined || !isJsonValue(data) ? {} : { data }), + }, + }; + } + throw new DispatchError("MCP client request failed", false, { + cause: error, + }); + } + } + + onServerRequest( + handler: (incoming: IncomingServerRequest) => Promise, + ): () => void { + this.serverRequestListeners.add(handler); + return () => this.serverRequestListeners.delete(handler); + } + + onNotification(listener: (notification: JsonValue) => void): () => void { + this.notificationListeners.add(listener); + return () => this.notificationListeners.delete(listener); + } + + onInvalidated(listener: (reason: unknown) => void): () => void { + this.invalidationListeners.add(listener); + return () => this.invalidationListeners.delete(listener); + } + + [Symbol.dispose](): void { + if (this.disposed) return; + this.disposed = true; + if (this.client.fallbackRequestHandler === this.fallbackRequestHandler) + this.client.fallbackRequestHandler = this.previousFallbackRequestHandler; + if ( + this.client.fallbackNotificationHandler === + this.fallbackNotificationHandler + ) + this.client.fallbackNotificationHandler = + this.previousFallbackNotificationHandler; + if (this.client.onclose === this.onclose) + this.client.onclose = this.previousOnclose; + adaptedClients.delete(this.client); + this.serverRequestListeners.clear(); + this.notificationListeners.clear(); + this.invalidationListeners.clear(); + } + + private invalidate(reason: unknown): void { + if (this.isInvalidated) return; + this.isInvalidated = true; + for (const listener of [...this.invalidationListeners]) listener(reason); + } +} + +export function createSessionPortFromClient( + client: Client, + endpointId: string, +): ConnectedMcpSessionPort & Disposable { + return new ClientSessionPort(client, endpointId); +} + export class DispatchError extends Error { readonly retryable: boolean; @@ -248,6 +526,7 @@ export type ToolExecution = | (ToolExecutionCommon & { readonly kind: "task"; readonly handle: TaskHandle; + serializeReference(): SerializedTaskReference; }); export class TaskUpdatesAlreadyAcquiredError extends Error { @@ -322,10 +601,6 @@ function reasonAsError(reason: unknown): Error { ); } -function unsupported(feature: string): Error { - return new Error(`${feature} is not supported`); -} - const DEFAULT_TASK_POLL_INTERVAL_MS = 10; type TaskTurn = @@ -375,6 +650,7 @@ class TaskExecution< constructor( readonly applicationContext: TApplicationContext, readonly handle: TaskHandle, + private readonly endpointId: string, initialSnapshot: TaskSnapshot, driver: TaskDriver, private readonly cancelTask: (signal?: AbortSignal) => Promise, @@ -402,6 +678,10 @@ class TaskExecution< ); } + serializeReference(): SerializedTaskReference { + return { endpointId: this.endpointId, ...this.handle }; + } + onNotification(snapshot: TaskSnapshot): void { if (this.closed || snapshot.generation !== this.handle.generation) return; if (snapshot.task.taskId !== this.handle.taskId) return; @@ -956,6 +1236,7 @@ class PortTaskEnabledSession< TApplicationContext, > implements TaskEnabledSession { private closed = false; + private closeError: Error | undefined; private readonly lifecycleController = new AbortController(); private invalidationError: Error | undefined; private readonly disposeListeners: readonly (() => void)[]; @@ -976,6 +1257,7 @@ class PortTaskEnabledSession< constructor( private readonly port: ConnectedMcpSessionPort, private readonly options: WithTasksOptions, + disposePort?: () => void, ) { const reportError = (error: Error): void => { try { @@ -1014,6 +1296,7 @@ class PortTaskEnabledSession< }), () => options.signal?.removeEventListener("abort", onSessionAbort), () => this.managedDeclarations?.close(), + ...(disposePort === undefined ? [] : [disposePort]), ]; if (options.signal?.aborted === true) onSessionAbort(); if (port.invalidated) { @@ -1133,6 +1416,7 @@ class PortTaskEnabledSession< const execution = new TaskExecution( options.applicationContext as TApplicationContext, handle, + this.port.endpointId, initial, async ( accept, @@ -1234,6 +1518,7 @@ class PortTaskEnabledSession< new TaskExecution( options.applicationContext as TApplicationContext, handle, + this.port.endpointId, initial, async ( accept, @@ -1434,18 +1719,351 @@ class PortTaskEnabledSession< ); } - resumeTask( + async resumeTask( reference: SerializedTaskReference, - options?: { + options: { readonly resultCodec?: RuntimeCodec; readonly applicationContext?: TApplicationContext; readonly signal?: AbortSignal; - }, + } = {}, ): Promise> { - void reference; - void options; this.assertUsable(); - return Promise.reject(unsupported("Task resumption")); + const capabilities = this.port.taskCapabilities; + if (reference.endpointId !== this.port.endpointId) + throw new Error("Task reference belongs to a different endpoint"); + if (reference.generation !== capabilities.generation) + throw new Error("Task reference generation does not match this session"); + if (reference.originalOperation !== "tools/call") + throw new Error("Task reference operation is not supported"); + + const resumeLifecycle = linkAbortSignals( + this.lifecycleController.signal, + options.signal, + ); + const resumeSignal = resumeLifecycle.signal; + const executionId = `execution-${++nextExecutionId}`; + const codec = + options.resultCodec ?? + (defaultResultCodec(reference.generation) as RuntimeCodec); + try { + throwIfAborted(resumeSignal); + const response = await dispatchWithRetry( + this.port, + { + method: "tasks/get", + params: + reference.generation === "v2" + ? withTaskCapabilityV2({ taskId: reference.taskId }) + : { taskId: reference.taskId }, + }, + resumeSignal, + "observe", + ); + this.assertUsable(); + throwIfAborted(resumeSignal); + + if (reference.generation === "v1") { + const task = decodeResult( + GetTaskResultV1Codec, + responseResult(response), + ); + const execution = new TaskExecution( + options.applicationContext as TApplicationContext, + reference, + this.port.endpointId, + { generation: "v1", task }, + async ( + accept, + waitForTurn, + observe, + signal, + cancelledError, + closedError, + isClosed, + ) => { + let current = task; + let notificationSequence = 0; + while (!terminalStatus(current.status)) { + const turn = await waitForTurn( + notificationSequence, + Math.max( + DEFAULT_TASK_POLL_INTERVAL_MS, + current.pollInterval ?? DEFAULT_TASK_POLL_INTERVAL_MS, + ), + ); + const observed = + turn ?? + (await observe(notificationSequence, (observationSignal) => + dispatchWithRetry( + this.port, + { + method: "tasks/get", + params: { taskId: reference.taskId }, + }, + observationSignal, + "observe", + ).then((nextResponse) => ({ + generation: "v1" as const, + task: decodeResult( + GetTaskResultV1Codec, + responseResult(nextResponse), + ), + })), + )); + if (observed?.snapshot.generation !== "v1") continue; + notificationSequence = observed.sequence; + current = observed.snapshot.task; + if (!isClosed()) accept({ generation: "v1", task: current }); + } + if (isClosed()) throw closedError; + if (current.status === "cancelled") throw cancelledError; + if (current.status === "failed") + throw new Error(current.statusMessage ?? "Task failed"); + const taskResult = responseResult( + await dispatchWithRetry( + this.port, + { + method: "tasks/result", + params: { taskId: reference.taskId }, + }, + signal, + "observe", + ), + ); + decodeResult(TaskResultV1Codec, taskResult); + return decodeResult(codec, taskResult); + }, + async (signal) => { + const currentCapabilities = this.port.taskCapabilities; + if ( + currentCapabilities.generation !== "v1" || + currentCapabilities.capabilities.cancel === undefined + ) + throw new TaskCancellationUnsupportedError(); + decodeResult( + CancelTaskResultV1Codec, + responseResult( + await dispatchWithRetry( + this.port, + { + method: "tasks/cancel", + params: { taskId: reference.taskId }, + }, + signal, + "mutate", + ), + ), + ); + }, + this.lifecycleController.signal, + ); + return this.trackTaskExecution(execution, { + lifetime: "task-v1", + generation: "v1", + taskId: reference.taskId, + toolName: "", + executionId, + applicationContext: options.applicationContext as TApplicationContext, + signal: execution.inputSignal(), + }); + } + + const task = decodeResult(GetTaskResultV2Codec, responseResult(response)); + return this.trackTaskExecution( + new TaskExecution( + options.applicationContext as TApplicationContext, + reference, + this.port.endpointId, + { generation: "v2", task }, + async ( + accept, + waitForTurn, + observe, + signal, + cancelledError, + closedError, + isClosed, + inputSignal, + ) => { + let current: DetailedTaskV2 = task; + let notificationSequence = 0; + const acquiredInputs = new Map(); + await this.acquireV2TaskInputs( + current, + options.applicationContext as TApplicationContext, + inputSignal, + signal, + acquiredInputs, + ); + while (!terminalStatus(current.status)) { + const turn = await waitForTurn( + notificationSequence, + Math.max( + DEFAULT_TASK_POLL_INTERVAL_MS, + current.pollIntervalMs ?? DEFAULT_TASK_POLL_INTERVAL_MS, + ), + ); + const observed = + turn ?? + (await observe(notificationSequence, (observationSignal) => + dispatchWithRetry( + this.port, + { + method: "tasks/get", + params: withTaskCapabilityV2({ + taskId: reference.taskId, + }), + }, + observationSignal, + "observe", + ).then((nextResponse) => ({ + generation: "v2" as const, + task: decodeResult( + GetTaskResultV2Codec, + responseResult(nextResponse), + ), + })), + )); + if (observed?.snapshot.generation !== "v2") continue; + notificationSequence = observed.sequence; + current = observed.snapshot.task as DetailedTaskV2; + if (!isClosed()) accept({ generation: "v2", task: current }); + await this.acquireV2TaskInputs( + current, + options.applicationContext as TApplicationContext, + inputSignal, + signal, + acquiredInputs, + ); + } + if (isClosed()) throw closedError; + if (current.status === "cancelled") throw cancelledError; + if (current.status === "failed") + throw new JsonRpcResponseError(current.error); + if (current.status !== "completed") + throw new Error( + `Unsupported terminal task status: ${current.status}`, + ); + return decodeResult(codec, current.result); + }, + async (signal) => { + decodeResult( + CancelTaskResultV2Codec, + responseResult( + await dispatchWithRetry( + this.port, + { + method: "tasks/cancel", + params: withTaskCapabilityV2({ taskId: reference.taskId }), + }, + signal, + "mutate", + ), + ), + ); + }, + this.lifecycleController.signal, + ), + ); + } finally { + resumeLifecycle.dispose(); + } + } + + private async acquireV2TaskInputs( + task: DetailedTaskV2, + applicationContext: TApplicationContext, + inputSignal: AbortSignal, + signal: AbortSignal, + acquiredInputs: Map, + ): Promise { + if (task.status !== "input_required") return; + const inputResponses: Record = {}; + for (const [inputKey, inputRequest] of Object.entries(task.inputRequests)) { + const signature = deterministicJson(inputRequest); + const acquiredSignature = acquiredInputs.get(inputKey); + if (acquiredSignature !== undefined) { + if (acquiredSignature !== signature) + this.reportBackgroundError( + new Error(`V2 task input key ${inputKey} was reused incompatibly`), + ); + continue; + } + acquiredInputs.set(inputKey, signature); + const request: InputRequestV2 = inputRequest; + const projected: ApplicationInputRequest | undefined = + request.method === "sampling/createMessage" + ? { kind: "sampling", params: request.params } + : request.method === "roots/list" + ? { + kind: "roots", + ...(request.params === undefined + ? {} + : { params: request.params }), + } + : request.method === "elicitation/create" + ? { kind: "elicitation", params: request.params } + : undefined; + if (projected === undefined) { + this.reportBackgroundError( + new Error(`Unknown V2 task input method for key ${inputKey}`), + ); + continue; + } + let result: unknown; + if (this.options.onInputRequest === undefined) { + if (request.method !== "elicitation/create") continue; + result = { action: "cancel" }; + } else { + try { + result = await this.options.onInputRequest(projected, { + lifetime: "task-v2", + taskId: task.taskId, + inputKey, + applicationContext, + signal: inputSignal, + }); + } catch { + if (inputSignal.aborted) return; + if (request.method !== "elicitation/create") continue; + result = { action: "cancel" }; + } + } + try { + const responseCodec = + request.method === "sampling/createMessage" + ? CreateMessageResultV2Codec + : request.method === "roots/list" + ? ListRootsResultV2Codec + : ElicitResultV2Codec; + inputResponses[inputKey] = decodeResult( + responseCodec as RuntimeCodec, + result as JsonValue, + ); + } catch (error) { + this.reportBackgroundError( + error instanceof Error ? error : new Error(String(error)), + ); + } + } + if (inputSignal.aborted || Object.keys(inputResponses).length === 0) return; + decodeResult( + UpdateTaskResultV2Codec, + responseResult( + await dispatchWithRetry( + this.port, + { + method: "tasks/update", + params: withTaskCapabilityV2({ + taskId: task.taskId, + inputResponses, + }), + }, + signal, + "mutate", + ), + ), + ); } private cleanupLateTaskCreation( @@ -1489,9 +2107,17 @@ class PortTaskEnabledSession< this.lifecycleController.abort( new Error("Task-enabled session is closed"), ); - for (const dispose of this.disposeListeners) dispose(); + for (const dispose of this.disposeListeners) { + try { + dispose(); + } catch (error) { + this.closeError ??= reasonAsError(error); + } + } } - return Promise.resolve(); + return this.closeError === undefined + ? Promise.resolve() + : Promise.reject(this.closeError); } [Symbol.asyncDispose](): Promise { @@ -1698,7 +2324,36 @@ class PortTaskEnabledSession< export function withTasks( session: ConnectedMcpSessionPort, - options: WithTasksOptions = {}, + options?: WithTasksOptions, +): TaskEnabledSession; +export function withTasks( + client: Client, + options: WithTasksOptions & { + readonly endpointId: string; + }, +): TaskEnabledSession; +export function withTasks( + session: ConnectedMcpSessionPort | Client, + options: WithTasksOptions & { + readonly endpointId?: string; + } = {}, ): TaskEnabledSession { - return new PortTaskEnabledSession(session, options); + if (isConnectedMcpSessionPort(session)) + return new PortTaskEnabledSession(session, options); + if (!isClientPublicSurface(session)) + throw new TypeError( + "withTasks requires a ConnectedMcpSessionPort or Client-compatible object", + ); + const endpointId = options.endpointId; + if (endpointId === undefined) + throw new TypeError("withTasks(Client) requires options.endpointId"); + const port = new ClientSessionPort(session, endpointId); + try { + return new PortTaskEnabledSession(port, options, () => + port[Symbol.dispose](), + ); + } catch (error) { + port[Symbol.dispose](); + throw error; + } } From ca888621ffc4e9f25849b7fabc4f66e7e5734975 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 4 Sep 2026 22:29:00 -0700 Subject: [PATCH 09/33] refactor: modularize and harden ext-tasks SDK - Split client and core monoliths into cohesive internal modules - Consolidate generation-specific task drivers and remove pre-release aliases - Strengthen codec, export, packed-consumer, and CI contracts --- .github/workflows/ci.yml | 4 +- README.md | 6 +- package.json | 3 +- packages/ext-tasks/README.md | 1 - packages/ext-tasks/eslint.config.mjs | 2 +- packages/ext-tasks/package.json | 10 +- packages/ext-tasks/scripts/check-exports.mjs | 399 ++- packages/ext-tasks/src/client/api.ts | 234 ++ .../src/client/client-adapter.test.ts | 297 ++ .../client/declarations-capabilities.test.ts | 298 ++ packages/ext-tasks/src/client/execution.ts | 394 +++ .../client/immediate-session-basics.test.ts | 176 + packages/ext-tasks/src/client/index.test.ts | 3035 ----------------- packages/ext-tasks/src/client/index.ts | 2390 +------------ .../ext-tasks/src/client/input-routing.ts | 64 + packages/ext-tasks/src/client/port.ts | 132 + .../src/client/sdk-client-adapter.ts | 287 ++ packages/ext-tasks/src/client/session.ts | 676 ++++ .../src/client/task-lifecycle-races.test.ts | 661 ++++ .../ext-tasks/src/client/task-protocol-v1.ts | 117 + .../ext-tasks/src/client/task-protocol-v2.ts | 263 ++ .../src/client/task-resumption.test.ts | 363 ++ .../ext-tasks/src/client/tool-declarations.ts | 142 + .../src/client/v1-input-task.test.ts | 690 ++++ .../src/client/v2-input-task.test.ts | 539 +++ packages/ext-tasks/src/core/index.test.ts | 89 +- packages/ext-tasks/src/core/index.ts | 92 +- packages/ext-tasks/src/core/internal/codec.ts | 83 + packages/ext-tasks/src/core/v1/codecs.ts | 434 +++ packages/ext-tasks/src/core/v1/index.test.ts | 18 +- packages/ext-tasks/src/core/v1/index.ts | 661 +--- packages/ext-tasks/src/core/v1/negotiation.ts | 52 + packages/ext-tasks/src/core/v1/wire.ts | 157 + packages/ext-tasks/src/core/v2/codecs.ts | 680 ++++ packages/ext-tasks/src/core/v2/index.test.ts | 128 +- packages/ext-tasks/src/core/v2/index.ts | 1070 +----- packages/ext-tasks/src/core/v2/integration.ts | 163 + packages/ext-tasks/src/core/v2/wire.ts | 200 ++ packages/ext-tasks/src/server/index.test.ts | 9 - packages/ext-tasks/src/server/index.ts | 4 - .../test-support/client/fake-port.ts | 93 + packages/ext-tasks/tsconfig.eslint.json | 2 +- packages/ext-tasks/tsconfig.test.json | 2 +- 43 files changed, 7990 insertions(+), 7130 deletions(-) create mode 100644 packages/ext-tasks/src/client/api.ts create mode 100644 packages/ext-tasks/src/client/client-adapter.test.ts create mode 100644 packages/ext-tasks/src/client/declarations-capabilities.test.ts create mode 100644 packages/ext-tasks/src/client/execution.ts create mode 100644 packages/ext-tasks/src/client/immediate-session-basics.test.ts delete mode 100644 packages/ext-tasks/src/client/index.test.ts create mode 100644 packages/ext-tasks/src/client/input-routing.ts create mode 100644 packages/ext-tasks/src/client/port.ts create mode 100644 packages/ext-tasks/src/client/sdk-client-adapter.ts create mode 100644 packages/ext-tasks/src/client/session.ts create mode 100644 packages/ext-tasks/src/client/task-lifecycle-races.test.ts create mode 100644 packages/ext-tasks/src/client/task-protocol-v1.ts create mode 100644 packages/ext-tasks/src/client/task-protocol-v2.ts create mode 100644 packages/ext-tasks/src/client/task-resumption.test.ts create mode 100644 packages/ext-tasks/src/client/tool-declarations.ts create mode 100644 packages/ext-tasks/src/client/v1-input-task.test.ts create mode 100644 packages/ext-tasks/src/client/v2-input-task.test.ts create mode 100644 packages/ext-tasks/src/core/internal/codec.ts create mode 100644 packages/ext-tasks/src/core/v1/codecs.ts create mode 100644 packages/ext-tasks/src/core/v1/negotiation.ts create mode 100644 packages/ext-tasks/src/core/v1/wire.ts create mode 100644 packages/ext-tasks/src/core/v2/codecs.ts create mode 100644 packages/ext-tasks/src/core/v2/integration.ts create mode 100644 packages/ext-tasks/src/core/v2/wire.ts delete mode 100644 packages/ext-tasks/src/server/index.test.ts delete mode 100644 packages/ext-tasks/src/server/index.ts create mode 100644 packages/ext-tasks/test-support/client/fake-port.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2ae9f6..73b5c3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,5 @@ jobs: with: node-version: "24" - run: npm ci - - run: npm run check:schema:ts - - run: npm run check:schema:json - - run: npm run check:package + - run: npm run check - run: npm run docs:build diff --git a/README.md b/README.md index 223619f..c9ff0e1 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ The redistributable TypeScript package lives in `packages/ext-tasks`. It is an npm workspace that publishes as `@modelcontextprotocol/ext-tasks`. ```bash -# Type-check and build the package -npm run check:package +# Run schema, package, and packed-consumer checks +npm run check # Run the package tests in watch mode npm run test:watch @@ -45,7 +45,7 @@ npm run pack:package ``` The package intentionally has no root export. Consumers import `/core`, -`/core/v1`, `/core/v2`, `/client`, or the reserved `/server` subpath. +`/core/v1`, `/core/v2`, or `/client`. ### Schema Generation diff --git a/package.json b/package.json index d2b87b4..ecbcff9 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "scripts": { "build": "npm run build:package", "build:package": "npm run build --workspace @modelcontextprotocol/ext-tasks", - "check": "npm run check:schema && npm run check:package", + "check": "npm run check:schema && npm run check:package && npm run check:pack", "check:package": "npm run check --workspace @modelcontextprotocol/ext-tasks", + "check:pack": "npm run check:package --workspace @modelcontextprotocol/ext-tasks", "pack:package": "npm pack --workspace @modelcontextprotocol/ext-tasks", "test": "npm run test --workspace @modelcontextprotocol/ext-tasks", "test:watch": "npm run test:watch --workspace @modelcontextprotocol/ext-tasks", diff --git a/packages/ext-tasks/README.md b/packages/ext-tasks/README.md index fc80822..e0d6c6f 100644 --- a/packages/ext-tasks/README.md +++ b/packages/ext-tasks/README.md @@ -15,7 +15,6 @@ The public package subpaths are: - `@modelcontextprotocol/ext-tasks/core/v1` - `@modelcontextprotocol/ext-tasks/core/v2` - `@modelcontextprotocol/ext-tasks/client` -- `@modelcontextprotocol/ext-tasks/server` (reserved for receiver-side support) Source is emitted as ESM JavaScript, TypeScript declarations, and source maps in `dist/`. diff --git a/packages/ext-tasks/eslint.config.mjs b/packages/ext-tasks/eslint.config.mjs index e4298a6..0c2ba9a 100644 --- a/packages/ext-tasks/eslint.config.mjs +++ b/packages/ext-tasks/eslint.config.mjs @@ -9,7 +9,7 @@ export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, { - files: ["src/**/*.ts", "vitest.config.ts"], + files: ["src/**/*.ts", "test-support/**/*.ts", "vitest.config.ts"], languageOptions: { globals: globals.browser, parserOptions: { diff --git a/packages/ext-tasks/package.json b/packages/ext-tasks/package.json index 4f9e702..ca99a7d 100644 --- a/packages/ext-tasks/package.json +++ b/packages/ext-tasks/package.json @@ -26,10 +26,6 @@ "./client": { "types": "./dist/client/index.d.ts", "import": "./dist/client/index.js" - }, - "./server": { - "types": "./dist/server/index.d.ts", - "import": "./dist/server/index.js" } }, "typesVersions": { @@ -45,9 +41,6 @@ ], "client": [ "dist/client/index.d.ts" - ], - "server": [ - "dist/server/index.d.ts" ] } }, @@ -81,9 +74,10 @@ "format:check": "prettier --check .", "lint": "eslint .", "check:exports": "node scripts/check-exports.mjs", + "check:package": "node scripts/check-exports.mjs --pack", "check:provenance": "node scripts/check-schema-provenance.mjs", "check": "npm run check:provenance && npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build && npm run check:exports", - "prepack": "npm run check" + "prepack": "npm run check && npm run check:package" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/packages/ext-tasks/scripts/check-exports.mjs b/packages/ext-tasks/scripts/check-exports.mjs index 7c4d568..ab96a2f 100644 --- a/packages/ext-tasks/scripts/check-exports.mjs +++ b/packages/ext-tasks/scripts/check-exports.mjs @@ -1,36 +1,391 @@ -import { access, readFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + access, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; const packageDirectory = fileURLToPath(new URL("../", import.meta.url)); const manifest = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ); +const packageName = manifest.name; +const publicSubpaths = ["core", "core/v1", "core/v2", "client"]; +const expectedRuntimeExports = { + core: [ + "ProtocolDecodeError", + "createRuntimeCodec", + "expectEnum", + "expectNumber", + "expectRecord", + "expectString", + "isJsonArray", + "isJsonValue", + "taskId", + ], + "core/v1": [ + "CallToolRequestV1Codec", + "CallToolResultV1Codec", + "CancelTaskRequestV1Codec", + "CancelTaskResultV1Codec", + "CreateTaskResultV1Codec", + "GetTaskRequestV1Codec", + "GetTaskResultRequestV1Codec", + "GetTaskResultV1Codec", + "ListTasksRequestV1Codec", + "ListTasksResultV1Codec", + "ServerTaskCapabilitiesV1Codec", + "TaskResultV1Codec", + "TaskStatusNotificationV1Codec", + "TaskStatusV1Codec", + "TaskStatusesV1", + "TaskV1Codec", + "ToolV1Codec", + "callToolAsTaskV1", + "hasTaskCancelCapabilityV1", + "hasTaskListCapabilityV1", + "hasTaskToolCallCapabilityV1", + "isTaskEligibleMethodV1", + "shouldCallToolAsTaskV1", + ], + "core/v2": [ + "CLIENT_CAPABILITIES_META_KEY_V2", + "CallToolResultV2Codec", + "CancelTaskRequestV2Codec", + "CancelTaskResultV2Codec", + "CancelledTaskV2Codec", + "CompletedTaskV2Codec", + "CreateMessageRequestV2Codec", + "CreateMessageResultV2Codec", + "CreateTaskResultV2Codec", + "DetailedTaskV2Codec", + "ElicitRequestV2Codec", + "ElicitResultV2Codec", + "ErrorV2Codec", + "FailedTaskV2Codec", + "GetTaskRequestV2Codec", + "GetTaskResultV2Codec", + "InputRequestV2Codec", + "InputRequestsV2Codec", + "InputRequiredTaskV2Codec", + "InputResponseV2Codec", + "InputResponsesV2Codec", + "ListRootsRequestV2Codec", + "ListRootsResultV2Codec", + "TASKS_EXTENSION_ID_V2", + "TaskStatusNotificationParamsV2Codec", + "TaskStatusNotificationV2Codec", + "TaskSubscriptionAcknowledgedNotificationsV2Codec", + "TaskSubscriptionNotificationsV2Codec", + "TaskV2Codec", + "TasksExtensionCapabilityV2Codec", + "ToolV2Codec", + "UpdateTaskRequestV2Codec", + "UpdateTaskResultV2Codec", + "WorkingTaskV2Codec", + "contributeTaskFilterV2", + "hasTaskClientCapabilityV2", + "hasTaskServerCapabilityV2", + "isCancelTaskRequestV2", + "isCreateTaskResultV2", + "isDetailedTaskV2", + "isGetTaskRequestV2", + "isTaskStatusNotificationV2", + "isTaskV2", + "isToolCallTaskResultV2", + "isUpdateTaskRequestV2", + "readAcceptedTaskIdsV2", + "withTaskCapabilityV2", + ], + client: [ + "DispatchError", + "InputCorrelationError", + "JsonRpcResponseError", + "TaskCancellationUnsupportedError", + "TaskExecutionClosedError", + "TaskUpdatesAlreadyAcquiredError", + "createSessionPortFromClient", + "withTasks", + ], +}; +const removedPublicAliasesV2 = [ + "EligibleTaskResultV2", + "TaskExtensionCapabilitiesV2", + "TaskExtensionCapabilitiesV2Codec", + "ToolCallResultV2", + "ToolCallResultV2Codec", + "isEligibleTaskResultV2", + "supportsTasksExtensionV2", +]; +const removedRuntimeAliasesV2 = [ + "TaskExtensionCapabilitiesV2Codec", + "ToolCallResultV2Codec", + "isEligibleTaskResultV2", + "supportsTasksExtensionV2", +]; +const unbarreledInternalTypesV2 = [ + "ContentBlockV2", + "IconV2", + "JsonRpcRequestV2", + "OpenObjectV2", + "ToolAnnotationsV2", +]; +const unavailableV2Names = [ + ...removedPublicAliasesV2, + ...unbarreledInternalTypesV2, +]; -const expectedExports = new Set([ - "./core", - "./core/v1", - "./core/v2", - "./client", - "./server", -]); -const actualExports = new Set(Object.keys(manifest.exports ?? {})); - -if (actualExports.has(".")) { - throw new Error("The package must not expose a root entry point."); +function sorted(values) { + return [...values].sort(); } -for (const subpath of expectedExports) { - if (!actualExports.delete(subpath)) { - throw new Error(`Missing package export: ${subpath}`); +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: packageDirectory, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); +} + +function expectFailure(command, args, description, options = {}) { + const result = spawnSync(command, args, { + cwd: packageDirectory, + encoding: "utf8", + stdio: "pipe", + ...options, + }); + assert.notEqual(result.status, 0, `${description} unexpectedly succeeded`); +} + +async function checkBuiltContract() { + const expectedSubpaths = publicSubpaths.map((subpath) => `./${subpath}`); + assert.deepEqual( + sorted(Object.keys(manifest.exports ?? {})), + sorted(expectedSubpaths), + "exports must contain exactly the four public subpaths and no root export", + ); + + const typeMappings = manifest.typesVersions?.["*"] ?? {}; + assert.deepEqual( + sorted(Object.keys(typeMappings)), + sorted(publicSubpaths), + "typesVersions keys must exactly match exports", + ); + + for (const subpath of publicSubpaths) { + const conditions = manifest.exports[`./${subpath}`]; + assert.deepEqual( + Object.keys(conditions), + ["types", "import"], + `Unexpected export conditions for ./${subpath}`, + ); + assert.deepEqual( + typeMappings[subpath], + [conditions.types.replace(/^\.\//, "")], + `typesVersions does not match exports for ./${subpath}`, + ); + await access(resolve(packageDirectory, conditions.import)); + await access(resolve(packageDirectory, conditions.types)); + + const namespace = await import( + `${pathToFileURL(resolve(packageDirectory, conditions.import)).href}?contract-check` + ); + assert.deepEqual( + Object.keys(namespace).sort(), + [...expectedRuntimeExports[subpath]].sort(), + `Runtime export snapshot changed for ${packageName}/${subpath}`, + ); + if (subpath === "core/v2") { + for (const alias of removedRuntimeAliasesV2) + assert.equal( + alias in namespace, + false, + `Removed V2 alias returned: ${alias}`, + ); + } } +} - const conditions = manifest.exports[subpath]; - await access(new URL(conditions.import, `file://${packageDirectory}/`)); - await access(new URL(conditions.types, `file://${packageDirectory}/`)); +async function listFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...(await listFiles(path))); + else files.push(path); + } + return files; } -if (actualExports.size > 0) { - throw new Error( - `Unexpected package exports: ${[...actualExports].sort().join(", ")}`, +async function checkPackedContract() { + const temporaryDirectory = await mkdtemp( + join(tmpdir(), "ext-tasks-contract-"), ); + try { + const packDirectory = join(temporaryDirectory, "pack"); + const consumerDirectory = join(temporaryDirectory, "consumer"); + await import("node:fs/promises").then(({ mkdir }) => + Promise.all([mkdir(packDirectory), mkdir(consumerDirectory)]), + ); + + const packOutput = run(process.platform === "win32" ? "npm.cmd" : "npm", [ + "pack", + "--ignore-scripts", + "--json", + "--pack-destination", + packDirectory, + ]); + const [{ filename, files }] = JSON.parse(packOutput); + const packedPaths = files.map(({ path }) => path); + assert.equal( + packedPaths.some((path) => + /(^|\/)(src|test-support|tests?)(\/|$)/u.test(path), + ), + false, + "Tarball includes source, test-support, or test files", + ); + assert.equal( + packedPaths.some((path) => /(?:^|\/)package\.json$/u.test(path)), + true, + "Tarball is missing package.json", + ); + + const tarball = join(packDirectory, filename); + await writeFile( + join(consumerDirectory, "package.json"), + JSON.stringify({ private: true, type: "module" }), + ); + run( + process.platform === "win32" ? "npm.cmd" : "npm", + [ + "install", + "--offline", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-package-lock", + resolve( + packageDirectory, + "../../node_modules/@modelcontextprotocol/client", + ), + tarball, + ], + { cwd: consumerDirectory }, + ); + + const positiveImports = publicSubpaths + .map( + (subpath) => + `import * as ${subpath.replace(/\W/gu, "_")} from "${packageName}/${subpath}";`, + ) + .join("\n"); + await writeFile( + join(consumerDirectory, "positive.ts"), + `${positiveImports}\nvoid 0;\n`, + ); + const baseCompilerOptions = { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + noEmit: true, + skipLibCheck: true, + noUncheckedSideEffectImports: true, + }; + const tsc = resolve( + packageDirectory, + "../../node_modules/typescript/bin/tsc", + ); + for (const moduleResolution of ["NodeNext", "Bundler"]) { + const compilerOptions = { + ...baseCompilerOptions, + module: moduleResolution === "Bundler" ? "ESNext" : "NodeNext", + moduleResolution, + }; + await writeFile( + join(consumerDirectory, "tsconfig.json"), + JSON.stringify({ compilerOptions, files: ["positive.ts"] }), + ); + run(process.execPath, [tsc, "-p", "tsconfig.json"], { + cwd: consumerDirectory, + }); + } + + const negativeImports = [ + ["root", `import "${packageName}";`], + ["server", `import "${packageName}/server";`], + ["client-internal", `import "${packageName}/client/api";`], + ["core-internal", `import "${packageName}/core/internal/codec";`], + [ + "test-support", + `import "${packageName}/test-support/client/fake-port";`, + ], + ...unavailableV2Names.map((alias) => [ + `removed-v2-${alias}`, + `import { ${alias} } from "${packageName}/core/v2";`, + ]), + ]; + for (const [name, source] of negativeImports) { + const file = `negative-${name}.ts`; + await writeFile(join(consumerDirectory, file), `${source}\n`); + await writeFile( + join(consumerDirectory, "tsconfig.json"), + JSON.stringify({ compilerOptions: baseCompilerOptions, files: [file] }), + ); + expectFailure(process.execPath, [tsc, "-p", "tsconfig.json"], name, { + cwd: consumerDirectory, + }); + } + + const runtimeSource = `${publicSubpaths + .map((subpath) => `await import("${packageName}/${subpath}");`) + .join("\n")}\n`; + await writeFile(join(consumerDirectory, "runtime.mjs"), runtimeSource); + run(process.execPath, ["runtime.mjs"], { cwd: consumerDirectory }); + + for (const unsupported of [ + packageName, + `${packageName}/client/api`, + `${packageName}/core/internal/codec`, + `${packageName}/test-support/client/fake-port`, + ]) { + expectFailure( + process.execPath, + [ + "--input-type=module", + "--eval", + `await import(${JSON.stringify(unsupported)})`, + ], + `runtime import ${unsupported}`, + { cwd: consumerDirectory }, + ); + } + + const installedFiles = await listFiles( + join(consumerDirectory, "node_modules", ...packageName.split("/")), + ); + assert.equal( + installedFiles.some((path) => + /(?:^|\/)(?:test-support|tests?)(?:\/|$)/u.test( + relative(consumerDirectory, path), + ), + ), + false, + "Installed package includes test support", + ); + console.log(`Validated packed consumer contract: ${filename}`); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } } + +await checkBuiltContract(); +if (process.argv.includes("--pack")) await checkPackedContract(); diff --git a/packages/ext-tasks/src/client/api.ts b/packages/ext-tasks/src/client/api.ts new file mode 100644 index 0000000..d2c03ab --- /dev/null +++ b/packages/ext-tasks/src/client/api.ts @@ -0,0 +1,234 @@ +import type { + JsonValue, + RuntimeCodec, + TaskGeneration, + TaskId, + TaskSnapshot, +} from "../core/index.js"; +import type { + CallToolResultV1, + TaskEligibleMethodV1, +} from "../core/v1/index.js"; +import type { + CallToolResultV2, + ErrorV2, + TaskEligibleMethodV2, +} from "../core/v2/index.js"; + +export class JsonRpcResponseError extends Error { + readonly code: number; + readonly data?: JsonValue; + readonly response: ErrorV2; + + constructor(error: ErrorV2, options?: ErrorOptions) { + super(error.message, options); + this.name = "JsonRpcResponseError"; + this.code = error.code; + if (error.data !== undefined) this.data = error.data; + this.response = error; + } +} + +export interface ToolDeclarationProvider { + currentTool(name: string): + | { + readonly name: string; + readonly inputSchema: Readonly>; + readonly execution?: { + readonly taskSupport?: "forbidden" | "optional" | "required"; + }; + } + | undefined; +} + +export type ApplicationInputRequest = + | { + readonly kind: "elicitation"; + readonly params: Readonly>; + } + | { + readonly kind: "sampling"; + readonly params: Readonly>; + } + | { + readonly kind: "roots"; + readonly params?: Readonly>; + }; + +export interface ApplicationElicitResult { + readonly action: "accept" | "decline" | "cancel"; + readonly content?: Readonly>; +} + +export type ApplicationCreateMessageResult = Readonly< + Record +> & { + readonly model: string; + readonly role: "assistant" | "user"; + readonly content: JsonValue; +}; + +export interface ApplicationListRootsResult { + readonly roots: readonly Readonly>[]; +} + +export type ApplicationInputResult = + TRequest extends { readonly kind: "elicitation" } + ? ApplicationElicitResult + : TRequest extends { readonly kind: "sampling" } + ? ApplicationCreateMessageResult + : TRequest extends { readonly kind: "roots" } + ? ApplicationListRootsResult + : never; + +export type ResolvedInputExchangeContext = + | { + readonly lifetime: "basic"; + readonly executionId: string; + readonly applicationContext: TApplicationContext; + readonly signal?: AbortSignal; + } + | { + readonly lifetime: "task-v1"; + readonly taskId: string; + readonly applicationContext: TApplicationContext; + readonly signal?: AbortSignal; + } + | { + readonly lifetime: "task-v2"; + readonly taskId: string; + readonly inputKey: string; + readonly applicationContext: TApplicationContext; + readonly signal?: AbortSignal; + }; + +export interface ApplicationInputHandler { + handle( + request: TRequest, + context: ResolvedInputExchangeContext, + ): Promise>; +} + +export type InputCorrelationFailureReason = + | "missing-evidence" + | "invalid-evidence" + | "zero-matches" + | "ambiguous-matches"; + +export interface InputCorrelationCandidate { + readonly generation: TaskGeneration; + readonly toolName: string; + readonly executionId: string; + readonly applicationContext: TApplicationContext; +} + +export class InputCorrelationError extends Error { + constructor( + readonly generation: TaskGeneration, + readonly requestKind: ApplicationInputRequest["kind"], + readonly candidates: readonly InputCorrelationCandidate[], + readonly reason: InputCorrelationFailureReason, + ) { + super(`Input request correlation failed: ${reason}`); + this.name = "InputCorrelationError"; + } +} + +export interface WithTasksOptions { + readonly tools?: ToolDeclarationProvider; + readonly onInputRequest?: ApplicationInputHandler["handle"]; + readonly onError?: (error: Error) => void; + readonly signal?: AbortSignal; +} + +export type { TaskEligibleMethodV2 } from "../core/v2/index.js"; + +export type TaskHandle = + | { + readonly generation: "v1"; + readonly taskId: TaskId; + readonly originalOperation: TaskEligibleMethodV1; + } + | { + readonly generation: "v2"; + readonly taskId: TaskId; + readonly originalOperation: TaskEligibleMethodV2; + }; + +export interface ToolExecutionCommon { + readonly applicationContext: TApplicationContext; + updates(signal?: AbortSignal): AsyncIterable; + result(): Promise; + cancel(signal?: AbortSignal): Promise; + close(): Promise; + [Symbol.asyncDispose](): Promise; +} + +export type ToolExecution = + | (ToolExecutionCommon & { + readonly kind: "immediate"; + readonly handle?: undefined; + }) + | (ToolExecutionCommon & { + readonly kind: "task"; + readonly handle: TaskHandle; + serializeReference(): SerializedTaskReference; + }); + +export class TaskUpdatesAlreadyAcquiredError extends Error { + constructor() { + super("Task updates have already been acquired"); + this.name = "TaskUpdatesAlreadyAcquiredError"; + } +} + +export class TaskExecutionClosedError extends Error { + constructor() { + super("Task execution is closed"); + this.name = "TaskExecutionClosedError"; + } +} + +export class TaskCancellationUnsupportedError extends Error { + constructor() { + super("Task cancellation is not supported"); + this.name = "TaskCancellationUnsupportedError"; + } +} + +export interface TaskEnabledSession { + callTool( + name: string, + params?: Readonly>, + options?: { + readonly resultCodec?: RuntimeCodec; + readonly applicationContext?: TApplicationContext; + readonly signal?: AbortSignal; + readonly preferTask?: boolean; + }, + ): Promise>; + resumeTask( + reference: SerializedTaskReference, + options?: { + readonly resultCodec?: RuntimeCodec; + readonly applicationContext?: TApplicationContext; + readonly signal?: AbortSignal; + }, + ): Promise>; + close(): Promise; + [Symbol.asyncDispose](): Promise; +} + +export type SerializedTaskReference = + | { + readonly endpointId: string; + readonly generation: "v1"; + readonly taskId: TaskId; + readonly originalOperation: TaskEligibleMethodV1; + } + | { + readonly endpointId: string; + readonly generation: "v2"; + readonly taskId: TaskId; + readonly originalOperation: TaskEligibleMethodV2; + }; diff --git a/packages/ext-tasks/src/client/client-adapter.test.ts b/packages/ext-tasks/src/client/client-adapter.test.ts new file mode 100644 index 0000000..4cbe59b --- /dev/null +++ b/packages/ext-tasks/src/client/client-adapter.test.ts @@ -0,0 +1,297 @@ +import { + Client, + ProtocolError, + SdkError, + SdkErrorCode, + type ClientContext, +} from "@modelcontextprotocol/client"; +import { describe, expect, it, vi } from "vitest"; +import { type JsonValue } from "../core/index.js"; +import { createSessionPortFromClient, withTasks } from "./index.js"; + +const client = () => new Client({ name: "test", version: "1" }); +const context = { + mcpReq: { + id: 1, + method: "custom/request", + requestState: () => undefined, + signal: new AbortController().signal, + send: vi.fn(), + notify: vi.fn(), + }, +} satisfies ClientContext; + +describe("Client adapter", () => { + it("dispatches with an explicit schema and signal, preserving full protocol errors", async () => { + const sdk = client(); + const request = vi.spyOn(sdk, "request"); + const port = createSessionPortFromClient(sdk, "endpoint-sdk"); + const controller = new AbortController(); + request.mockResolvedValueOnce({ ok: true }); + await expect( + port.dispatch( + { method: "custom/method", params: { value: 1 } }, + { signal: controller.signal }, + ), + ).resolves.toEqual({ kind: "result", result: { ok: true } }); + const schema: unknown = request.mock.calls[0]?.[1]; + expect(schema).toBeTypeOf("object"); + expect(schema).toHaveProperty("~standard"); + expect(request.mock.calls[0]?.[2]).toEqual({ signal: controller.signal }); + request.mockRejectedValueOnce( + new ProtocolError(-32001, "denied", { retry: false }), + ); + await expect(port.dispatch({ method: "custom/method" })).resolves.toEqual({ + kind: "error", + error: { code: -32001, message: "denied", data: { retry: false } }, + }); + }); + + it("wraps cancellation and local SDK failures as non-retryable DispatchError", async () => { + const sdk = client(); + const request = vi.spyOn(sdk, "request"); + const port = createSessionPortFromClient(sdk, "endpoint-sdk"); + for (const failure of [ + new DOMException("cancelled", "AbortError"), + new SdkError(SdkErrorCode.ConnectionClosed, "closed"), + ]) { + request.mockRejectedValueOnce(failure); + await expect( + port.dispatch({ method: "custom/method" }), + ).rejects.toMatchObject({ + name: "DispatchError", + retryable: false, + cause: failure, + }); + } + }); + + it("derives immutable legacy, modern, and absent task capabilities", () => { + const legacy = client(); + const legacyCapabilities = { tasks: { cancel: {}, list: {} } }; + vi.spyOn(legacy, "getProtocolEra").mockReturnValue("legacy"); + vi.spyOn(legacy, "getServerCapabilities").mockReturnValue( + legacyCapabilities, + ); + const legacyPort = createSessionPortFromClient(legacy, "legacy"); + expect(legacyPort.endpointId).toBe("legacy"); + expect(legacyPort.taskCapabilities).toEqual({ + generation: "v1", + capabilities: { cancel: {}, list: {} }, + }); + legacyCapabilities.tasks.cancel = { changed: true }; + expect(legacyPort.taskCapabilities).toEqual({ + generation: "v1", + capabilities: { cancel: {}, list: {} }, + }); + const modern = client(); + vi.spyOn(modern, "getProtocolEra").mockReturnValue("modern"); + vi.spyOn(modern, "getServerCapabilities").mockReturnValue({ + extensions: { "io.modelcontextprotocol/tasks": {} }, + }); + expect( + createSessionPortFromClient(modern, "modern").taskCapabilities, + ).toEqual({ generation: "v2", capabilities: {} }); + const absent = client(); + vi.spyOn(absent, "getProtocolEra").mockReturnValue("modern"); + vi.spyOn(absent, "getServerCapabilities").mockReturnValue({ + extensions: {}, + }); + expect( + createSessionPortFromClient(absent, "none").taskCapabilities, + ).toEqual({ generation: "none" }); + }); + + it("forwards inbound requests and settles results and full errors", async () => { + const sdk = client(); + const port = createSessionPortFromClient(sdk, "endpoint-sdk"); + const disposeResult = port.onServerRequest((incoming) => + Promise.resolve({ + kind: "result", + result: { echoed: incoming.request }, + }), + ); + await expect( + sdk.fallbackRequestHandler?.( + { + jsonrpc: "2.0", + id: 1, + method: "elicitation/create", + params: {}, + }, + context, + ), + ).resolves.toEqual({ + echoed: { + jsonrpc: "2.0", + id: 1, + method: "elicitation/create", + params: {}, + }, + }); + disposeResult(); + const disposeError = port.onServerRequest(() => + Promise.resolve({ + kind: "error", + error: { code: -32002, message: "failed", data: { reason: "x" } }, + }), + ); + await expect( + sdk.fallbackRequestHandler?.( + { jsonrpc: "2.0", id: 2, method: "elicitation/create", params: {} }, + context, + ), + ).rejects.toMatchObject({ + code: -32002, + message: "failed", + data: { reason: "x" }, + }); + disposeError(); + }); + + it("chains prior fallbacks, forwards notifications, invalidates on close, and cleans up", async () => { + const sdk = client(); + const priorRequest = vi.fn(() => Promise.resolve({ prior: true })); + const priorNotification = vi.fn(() => Promise.resolve()); + const priorClose = vi.fn(); + sdk.fallbackRequestHandler = priorRequest; + sdk.fallbackNotificationHandler = priorNotification; + sdk.onclose = priorClose; + const port = createSessionPortFromClient(sdk, "endpoint-sdk"); + const installedRequest = sdk.fallbackRequestHandler; + const installedNotification = sdk.fallbackNotificationHandler; + const installedClose = sdk.onclose; + const notifications: JsonValue[] = []; + const invalidations: unknown[] = []; + const removeNotification = port.onNotification((value) => + notifications.push(value), + ); + const removeInvalidation = port.onInvalidated((reason) => + invalidations.push(reason), + ); + await expect( + installedRequest?.({ jsonrpc: "2.0", id: 1, method: "other" }, context), + ).resolves.toEqual({ prior: true }); + await installedNotification?.({ + method: "custom/notification", + params: { value: 1 }, + }); + expect(priorNotification).toHaveBeenCalledOnce(); + expect(notifications).toEqual([ + { method: "custom/notification", params: { value: 1 } }, + ]); + removeNotification(); + await installedNotification?.({ + method: "custom/notification", + params: { value: 2 }, + }); + expect(notifications).toHaveLength(1); + installedClose?.(); + expect(priorClose).toHaveBeenCalledOnce(); + expect(port.invalidated).toBe(true); + expect(invalidations).toHaveLength(1); + removeInvalidation(); + port[Symbol.dispose](); + expect(sdk.fallbackRequestHandler).toBe(priorRequest); + expect(sdk.fallbackNotificationHandler).toBe(priorNotification); + expect(sdk.onclose).toBe(priorClose); + }); + + it("does not overwrite callbacks installed after adaptation", () => { + const sdk = client(); + const port = createSessionPortFromClient(sdk, "endpoint-sdk"); + const replacement = vi.fn(() => Promise.resolve({ replacement: true })); + sdk.fallbackRequestHandler = replacement; + port[Symbol.dispose](); + expect(sdk.fallbackRequestHandler).toBe(replacement); + }); + + it("rejects concurrent adapters and permits reuse after disposal", () => { + const sdk = client(); + const first = createSessionPortFromClient(sdk, "endpoint-sdk"); + expect(() => createSessionPortFromClient(sdk, "endpoint-sdk")).toThrow( + "already active", + ); + first[Symbol.dispose](); + const replacement = createSessionPortFromClient(sdk, "endpoint-sdk"); + replacement[Symbol.dispose](); + }); + + it("accepts Client-compatible objects from another constructor", async () => { + class ForeignClient { + fallbackRequestHandler: Client["fallbackRequestHandler"]; + fallbackNotificationHandler: Client["fallbackNotificationHandler"]; + onclose: Client["onclose"]; + readonly request = vi.fn(() => Promise.resolve({ content: [] })); + getProtocolEra(): ReturnType { + return "legacy"; + } + getServerCapabilities(): ReturnType { + return {}; + } + } + const foreign = new ForeignClient(); + const session = withTasks(foreign as unknown as Client, { + endpointId: "foreign-client", + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toEqual({ content: [] }); + expect(foreign.request).toHaveBeenCalled(); + await session.close(); + }); + + it("supports Client sessions through withTasks and restores callbacks", async () => { + const sdk = client(); + const request = vi.spyOn(sdk, "request").mockResolvedValue({ content: [] }); + const prior = vi.fn(() => Promise.resolve({ prior: true })); + sdk.fallbackRequestHandler = prior; + const session = withTasks(sdk, { + endpointId: "raw-client", + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toEqual({ content: [] }); + expect(request).toHaveBeenCalledWith( + { method: "tools/call", params: { name: "x" } }, + expect.any(Object), + expect.any(Object), + ); + await expect( + sdk.fallbackRequestHandler?.( + { jsonrpc: "2.0", id: 9, method: "custom/unrelated" }, + context, + ), + ).resolves.toEqual({ prior: true }); + expect(prior).toHaveBeenCalledWith( + { jsonrpc: "2.0", id: 9, method: "custom/unrelated" }, + context, + ); + await session.close(); + expect(sdk.fallbackRequestHandler).toBe(prior); + expect(sdk.transport).toBeUndefined(); + }); + + it("restores Client ownership when an earlier close disposer fails", async () => { + const sdk = client(); + const prior = vi.fn(() => Promise.resolve({ prior: true })); + sdk.fallbackRequestHandler = prior; + const controller = new AbortController(); + const sentinel = new Error("listener cleanup failed"); + vi.spyOn(controller.signal, "removeEventListener").mockImplementation( + () => { + throw sentinel; + }, + ); + const session = withTasks(sdk, { + endpointId: "close-failure", + signal: controller.signal, + tools: { currentTool: () => undefined }, + }); + await expect(session.close()).rejects.toBe(sentinel); + await expect(session.close()).rejects.toBe(sentinel); + expect(sdk.fallbackRequestHandler).toBe(prior); + const replacement = createSessionPortFromClient(sdk, "close-failure"); + replacement[Symbol.dispose](); + }); +}); diff --git a/packages/ext-tasks/src/client/declarations-capabilities.test.ts b/packages/ext-tasks/src/client/declarations-capabilities.test.ts new file mode 100644 index 0000000..91bc625 --- /dev/null +++ b/packages/ext-tasks/src/client/declarations-capabilities.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it, vi } from "vitest"; +import { expectRecord } from "../core/index.js"; +import { type ToolV1 } from "../core/v1/index.js"; +import { DispatchError, withTasks, type JsonRpcResponse } from "./index.js"; +import { + FakePort, + asJson, + formatJson, +} from "../../test-support/client/fake-port.js"; + +describe("declarations and capabilities", () => { + it("cleans up call listeners when declaration lookup fails", async () => { + const port = new FakePort(); + const callController = new AbortController(); + const addListener = vi.spyOn(callController.signal, "addEventListener"); + const removeListener = vi.spyOn( + callController.signal, + "removeEventListener", + ); + const session = withTasks(port, { + tools: { + currentTool: () => { + throw new Error("declaration lookup failed"); + }, + }, + }); + await expect( + session.callTool("x", undefined, { signal: callController.signal }), + ).rejects.toThrow("declaration lookup failed"); + expect(port.requests).toEqual([]); + expect(addListener).toHaveBeenCalledTimes(1); + expect(removeListener).toHaveBeenCalledTimes(1); + await session.close(); + }); + + it("manages initial tool declarations only when no provider is supplied", async () => { + const managed = new FakePort({ generation: "v1", capabilities: {} }); + managed.dispatchHandler = async (request) => { + await Promise.resolve(); + const record = expectRecord(request); + if (record.method === "tools/list") { + return { + kind: "result", + result: asJson({ + tools: [{ name: "listed", inputSchema: { type: "object" } }], + }), + }; + } + return { kind: "result", result: asJson({ content: [] }) }; + }; + const managedSession = withTasks(managed); + await managedSession.callTool("listed"); + expect(managed.requests).toEqual([ + { method: "tools/list", params: {} }, + { method: "tools/call", params: { name: "listed" } }, + ]); + await managedSession.close(); + + const supplied = new FakePort(); + const suppliedSession = withTasks(supplied, { + tools: { currentTool: () => undefined }, + }); + await suppliedSession.callTool("x"); + expect(supplied.requests).toEqual([ + { method: "tools/call", params: { name: "x" } }, + ]); + await suppliedSession.close(); + }); + + it("retries initial discovery and follows tool-list cursors", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + let attempts = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const record = expectRecord(request); + if (record.method !== "tools/list") + return { kind: "result", result: asJson({ content: [] }) }; + attempts += 1; + if (attempts === 1) throw new DispatchError("temporary", true); + const params = expectRecord(record.params); + if (params.cursor === undefined) { + return { + kind: "result", + result: asJson({ + tools: [{ name: "first", inputSchema: { type: "object" } }], + nextCursor: "next", + }), + }; + } + return { + kind: "result", + result: asJson({ + tools: [{ name: "second", inputSchema: { type: "object" } }], + }), + }; + }; + const session = withTasks(port); + await session.callTool("second"); + expect(port.requests.slice(0, 3)).toEqual([ + { method: "tools/list", params: {} }, + { method: "tools/list", params: {} }, + { method: "tools/list", params: { cursor: "next" } }, + ]); + await session.close(); + }); + + it("ignores stale tool-list refreshes", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } } }, + }); + const pending: ((response: JsonRpcResponse) => void)[] = []; + let abortedRefreshes = 0; + let listCount = 0; + port.dispatchHandler = (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") { + const params = expectRecord(record.params); + return Promise.resolve( + params.task === undefined + ? { kind: "result", result: { content: [] } } + : { + kind: "result", + result: asJson({ + task: { + taskId: "newest", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }, + }), + }, + ); + } + if (record.method === "tasks/result") + return Promise.resolve({ kind: "result", result: { content: [] } }); + if (record.method !== "tools/list") + throw new Error(`unexpected method ${formatJson(record.method)}`); + listCount += 1; + if (listCount === 1) { + return Promise.resolve({ + kind: "result", + result: asJson({ + tools: [{ name: "x", inputSchema: { type: "object" } }], + }), + }); + } + return new Promise((resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => { + abortedRefreshes += 1; + reject(new DOMException("superseded", "AbortError")); + }, + { once: true }, + ); + pending.push(resolve); + }); + }; + const session = withTasks(port); + await session.callTool("x"); + port.requests.length = 0; + port.notify({ method: "notifications/tools/list_changed" }); + port.notify({ method: "notifications/tools/list_changed" }); + expect(abortedRefreshes).toBe(1); + pending[1]?.({ + kind: "result", + result: asJson({ + tools: [ + { + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }, + ], + }), + }); + await Promise.resolve(); + const execution = await session.callTool("x"); + expect(execution.kind).toBe("task"); + expect(port.requests.slice(-2)).toEqual([ + { method: "tools/call", params: { name: "x", task: {} } }, + { method: "tasks/result", params: { taskId: "newest" } }, + ]); + await session.close(); + }); + + it("reports duplicate tools and aborts managed discovery on close", async () => { + const errors: Error[] = []; + const duplicatePort = new FakePort({ generation: "v1", capabilities: {} }); + duplicatePort.dispatchHandler = async (request) => { + await Promise.resolve(); + const record = expectRecord(request); + if (record.method === "tools/list") { + return { + kind: "result", + result: asJson({ + tools: [ + { name: "duplicate", inputSchema: { type: "object" } }, + { + name: "duplicate", + inputSchema: { type: "object" }, + title: "newer", + }, + ], + }), + }; + } + return { kind: "result", result: asJson({ content: [] }) }; + }; + const duplicateSession = withTasks(duplicatePort, { + onError: (error) => errors.push(error), + }); + await duplicateSession.callTool("duplicate"); + expect(errors.map((error) => error.message)).toContain( + "Duplicate tool declaration: duplicate", + ); + await duplicateSession.close(); + + const callAbortPort = new FakePort(); + callAbortPort.dispatchHandler = (_request, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(new DOMException("discovery aborted", "AbortError")), + { once: true }, + ); + }); + const callAbortSession = withTasks(callAbortPort); + const callController = new AbortController(); + const call = callAbortSession.callTool("x", undefined, { + signal: callController.signal, + }); + callController.abort(new Error("waiter aborted")); + await expect(call).rejects.toThrow("waiter aborted"); + expect(callAbortPort.requests).toHaveLength(1); + await callAbortSession.close(); + + const closePort = new FakePort(); + let refreshSignal: AbortSignal | undefined; + closePort.dispatchHandler = (_request, options) => + new Promise((_resolve, reject) => { + refreshSignal = options?.signal; + options?.signal?.addEventListener( + "abort", + () => reject(new DOMException("closed", "AbortError")), + { once: true }, + ); + }); + const closeSession = withTasks(closePort); + const closeCallController = new AbortController(); + const addListener = vi.spyOn( + closeCallController.signal, + "addEventListener", + ); + const removeListener = vi.spyOn( + closeCallController.signal, + "removeEventListener", + ); + const pendingCall = closeSession.callTool("x", undefined, { + signal: closeCallController.signal, + }); + await closeSession.close(); + await expect(pendingCall).rejects.toThrow(/closed|aborted/i); + expect(refreshSignal?.aborted).toBe(true); + expect(closePort.requests).toHaveLength(1); + expect(addListener).toHaveBeenCalledTimes(1); + expect(removeListener).toHaveBeenCalledTimes(1); + }); + + it("rejects generation-mismatched declarations without leaking call listeners", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + const v1Tool: ToolV1 = { + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }; + const callController = new AbortController(); + const addListener = vi.spyOn(callController.signal, "addEventListener"); + const removeListener = vi.spyOn( + callController.signal, + "removeEventListener", + ); + const session = withTasks(port, { tools: { currentTool: () => v1Tool } }); + for (let attempt = 0; attempt < 3; attempt += 1) { + await expect( + session.callTool("x", undefined, { signal: callController.signal }), + ).rejects.toThrow( + "V1 tool declaration is incompatible with the V2 session", + ); + } + expect(port.requests).toEqual([]); + expect(addListener).toHaveBeenCalledTimes(3); + expect(removeListener).toHaveBeenCalledTimes(3); + await session.close(); + }); +}); diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts new file mode 100644 index 0000000..61b761e --- /dev/null +++ b/packages/ext-tasks/src/client/execution.ts @@ -0,0 +1,394 @@ +import type { RuntimeCodec, TaskSnapshot } from "../core/index.js"; +import { + CallToolResultV1Codec, + type CallToolResultV1, + type TaskV1, +} from "../core/v1/index.js"; +import { + CallToolResultV2Codec, + type CallToolResultV2, +} from "../core/v2/index.js"; +import { + TaskExecutionClosedError, + TaskUpdatesAlreadyAcquiredError, + type SerializedTaskReference, + type TaskHandle, + type ToolExecutionCommon, +} from "./api.js"; +import type { SessionTaskCapabilities } from "./port.js"; +import { linkAbortSignals, withAbort } from "./port.js"; +import { throwIfAborted } from "./input-routing.js"; + +export function defaultResultCodec( + generation: SessionTaskCapabilities["generation"], +): RuntimeCodec { + return generation === "v2" ? CallToolResultV2Codec : CallToolResultV1Codec; +} + +export function reasonAsError(reason: unknown): Error { + if (reason instanceof Error) return reason; + return new Error( + typeof reason === "string" ? reason : "MCP session was invalidated", + { cause: reason }, + ); +} + +export const DEFAULT_TASK_POLL_INTERVAL_MS = 10; + +type TaskTurn = + { readonly sequence: number; readonly snapshot: TaskSnapshot } | undefined; + +export type TaskDriver = ( + accept: (snapshot: TaskSnapshot) => void, + waitForTurn: ( + afterSequence: number, + delayMs: number | undefined, + ) => Promise, + observe: ( + afterSequence: number, + observation: (signal: AbortSignal) => Promise, + ) => Promise, + signal: AbortSignal, + cancelledError: Error, + closedError: Error, + isClosed: () => boolean, + inputSignal: AbortSignal, +) => Promise; + +export class TaskExecution< + TResult, + TApplicationContext, +> implements ToolExecutionCommon { + readonly kind = "task" as const; + private readonly controller = new AbortController(); + private readonly inputController = new AbortController(); + private readonly cancellationController = new AbortController(); + private readonly resultPromise: Promise; + private readonly cancelledError = new Error("Task was cancelled"); + private readonly closedError = new TaskExecutionClosedError(); + private readonly notificationWaiters = new Set<() => void>(); + private readonly updateWaiters = new Set<() => void>(); + private initialSnapshot: TaskSnapshot | undefined; + private pendingSnapshot: TaskSnapshot | undefined; + private terminalSnapshot: TaskSnapshot | undefined; + private terminalSnapshotBytes: string | undefined; + private lastAcceptedBytes: string; + private notificationSequence = 0; + private latestNotification: TaskSnapshot | undefined; + private updatesAcquired = false; + private cancelPromise: Promise | undefined; + private closed = false; + + constructor( + readonly applicationContext: TApplicationContext, + readonly handle: TaskHandle, + private readonly endpointId: string, + initialSnapshot: TaskSnapshot, + driver: TaskDriver, + private readonly cancelTask: (signal?: AbortSignal) => Promise, + lifecycleSignal?: AbortSignal, + ) { + this.initialSnapshot = initialSnapshot; + if (terminalStatus(initialSnapshot.task.status)) + this.inputController.abort(); + this.lastAcceptedBytes = deterministicJson(initialSnapshot); + if (lifecycleSignal !== undefined) { + const abort = (): void => this.controller.abort(lifecycleSignal.reason); + if (lifecycleSignal.aborted) abort(); + else lifecycleSignal.addEventListener("abort", abort, { once: true }); + } + this.resultPromise = driver( + (snapshot) => this.accept(snapshot), + (afterSequence, delayMs) => this.waitForTurn(afterSequence, delayMs), + (afterSequence, observation) => + this.observeOrNotification(afterSequence, observation), + this.controller.signal, + this.cancelledError, + this.closedError, + () => this.closed, + this.inputController.signal, + ); + } + + serializeReference(): SerializedTaskReference { + return { endpointId: this.endpointId, ...this.handle }; + } + + onNotification(snapshot: TaskSnapshot): void { + if (this.closed || snapshot.generation !== this.handle.generation) return; + if (snapshot.task.taskId !== this.handle.taskId) return; + const bytes = deterministicJson(snapshot); + if (terminalStatus(snapshot.task.status)) { + this.inputController.abort(); + if (this.terminalSnapshotBytes === undefined) { + this.terminalSnapshot = snapshot; + this.terminalSnapshotBytes = bytes; + } + } else if (this.terminalSnapshotBytes !== undefined) { + return; + } + this.latestNotification = snapshot; + this.notificationSequence += 1; + for (const wake of this.notificationWaiters) wake(); + this.notificationWaiters.clear(); + for (const wake of this.updateWaiters) wake(); + this.updateWaiters.clear(); + } + + updates(signal?: AbortSignal): AsyncIterable { + if (this.updatesAcquired) throw new TaskUpdatesAlreadyAcquiredError(); + this.updatesAcquired = true; + return this.iterateUpdates(signal); + } + + private async *iterateUpdates( + signal?: AbortSignal, + ): AsyncIterable { + while (true) { + throwIfAborted(signal); + if (this.initialSnapshot !== undefined) { + const snapshot = this.initialSnapshot; + this.initialSnapshot = undefined; + yield snapshot; + continue; + } + if (this.pendingSnapshot !== undefined) { + const snapshot = this.pendingSnapshot; + this.pendingSnapshot = undefined; + yield snapshot; + continue; + } + if (this.terminalSnapshot !== undefined) { + const snapshot = this.terminalSnapshot; + this.terminalSnapshot = undefined; + yield snapshot; + continue; + } + const settled = await this.waitForUpdateOrResult(signal); + if ( + !settled && + this.initialSnapshot === undefined && + this.pendingSnapshot === undefined && + this.terminalSnapshot === undefined + ) + return; + } + } + + private accept(snapshot: TaskSnapshot): void { + if (this.closed) return; + const bytes = deterministicJson(snapshot); + if (bytes === this.lastAcceptedBytes) return; + this.lastAcceptedBytes = bytes; + if (terminalStatus(snapshot.task.status)) { + this.inputController.abort(); + if (bytes !== this.terminalSnapshotBytes) { + this.terminalSnapshot ??= snapshot; + this.terminalSnapshotBytes ??= bytes; + } + } else if (this.terminalSnapshotBytes === undefined) { + this.pendingSnapshot = snapshot; + } + for (const wake of this.updateWaiters) wake(); + this.updateWaiters.clear(); + } + + private async waitForUpdateOrResult(signal?: AbortSignal): Promise { + if ( + this.pendingSnapshot !== undefined || + this.terminalSnapshot !== undefined + ) + return true; + let wake: (() => void) | undefined; + const updated = new Promise((resolve) => { + wake = () => resolve(true); + this.updateWaiters.add(wake); + }); + try { + return await withAbort( + Promise.race([ + updated, + this.resultPromise.then( + () => false, + () => false, + ), + ]), + signal, + ); + } finally { + if (wake !== undefined) this.updateWaiters.delete(wake); + } + } + + private currentTurn(afterSequence: number): TaskTurn { + if ( + this.notificationSequence > afterSequence && + this.latestNotification !== undefined + ) { + return { + sequence: this.notificationSequence, + snapshot: this.latestNotification, + }; + } + return undefined; + } + + private async waitForTurn( + afterSequence: number, + delayMs: number | undefined, + ): Promise { + const current = this.currentTurn(afterSequence); + if (current !== undefined) return current; + if (delayMs === undefined) { + await Promise.resolve(); + throwIfAborted(this.controller.signal); + return this.currentTurn(afterSequence); + } + await new Promise((resolve, reject) => { + const finish = (error?: unknown): void => { + clearTimeout(timeout); + this.notificationWaiters.delete(onNotification); + this.controller.signal.removeEventListener("abort", onAbort); + if (error === undefined) resolve(); + else reject(reasonAsError(error)); + }; + const onNotification = (): void => finish(); + const onAbort = (): void => finish(this.controller.signal.reason); + const timeout = setTimeout(onNotification, Math.max(0, delayMs)); + this.notificationWaiters.add(onNotification); + this.controller.signal.addEventListener("abort", onAbort, { once: true }); + }); + return this.currentTurn(afterSequence); + } + + private async observeOrNotification( + afterSequence: number, + observation: (signal: AbortSignal) => Promise, + ): Promise { + const current = this.currentTurn(afterSequence); + if (current !== undefined) return current; + const observationLifecycle = linkAbortSignals(this.controller.signal); + const observationPromise = observation(observationLifecycle.signal); + void observationPromise.catch(() => {}); + let wake: (() => void) | undefined; + const notified = new Promise((resolve) => { + wake = () => resolve(this.currentTurn(afterSequence)); + this.notificationWaiters.add(wake); + }); + try { + return await withAbort( + Promise.race([ + observationPromise.then((snapshot) => ({ + sequence: afterSequence, + snapshot, + })), + notified, + ]), + this.controller.signal, + ); + } finally { + if (!observationLifecycle.signal.aborted) observationLifecycle.abort(); + observationLifecycle.dispose(); + if (wake !== undefined) this.notificationWaiters.delete(wake); + } + } + + result(): Promise { + return this.resultPromise; + } + + inputSignal(): AbortSignal { + return this.inputController.signal; + } + + endInputLifetime(): void { + this.inputController.abort(); + } + + cancel(signal?: AbortSignal): Promise { + throwIfAborted(signal); + this.cancelPromise ??= this.cancelTask(this.cancellationController.signal); + return signal === undefined + ? this.cancelPromise + : withAbort(this.cancelPromise, signal); + } + + close(): Promise { + if (!this.closed) { + this.closed = true; + this.controller.abort(this.closedError); + this.inputController.abort(this.closedError); + void this.cancel().catch(() => { + // Cooperative cancellation is best effort during close. + }); + } + return Promise.resolve(); + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } +} + +export function deterministicJson(value: unknown): string { + if (value === null || typeof value !== "object") { + const encoded = JSON.stringify(value); + return encoded ?? `[${typeof value}]`; + } + if (Array.isArray(value)) + return `[${value.map(deterministicJson).join(",")}]`; + const record = value as Readonly>; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${deterministicJson(record[key])}`) + .join(",")}}`; +} + +export function terminalStatus(status: TaskV1["status"]): boolean { + return ( + status === "completed" || status === "failed" || status === "cancelled" + ); +} + +export class ImmediateExecution< + TResult, + TApplicationContext, +> implements ToolExecutionCommon { + readonly kind = "immediate" as const; + readonly handle = undefined; + + constructor( + readonly applicationContext: TApplicationContext, + private readonly resultPromise: Promise, + ) {} + + updates(signal?: AbortSignal): AsyncIterable { + throwIfAborted(signal); + return { + [Symbol.asyncIterator]() { + return { + next: () => + Promise.resolve({ done: true as const, value: undefined }), + }; + }, + }; + } + + result(): Promise { + return this.resultPromise; + } + + cancel(signal?: AbortSignal): Promise { + throwIfAborted(signal); + return Promise.resolve(); + } + + close(): Promise { + return Promise.resolve(); + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } +} diff --git a/packages/ext-tasks/src/client/immediate-session-basics.test.ts b/packages/ext-tasks/src/client/immediate-session-basics.test.ts new file mode 100644 index 0000000..99ff77a --- /dev/null +++ b/packages/ext-tasks/src/client/immediate-session-basics.test.ts @@ -0,0 +1,176 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { + createRuntimeCodec, + expectRecord, + type JsonValue, +} from "../core/index.js"; +import { DispatchError, JsonRpcResponseError, withTasks } from "./index.js"; +import { FakePort, asJson } from "../../test-support/client/fake-port.js"; + +describe("immediate and session basics", () => { + it("dispatches a non-task call and caches the decoded result", async () => { + await fc.assert( + fc.asyncProperty( + fc.string(), + fc.dictionary(fc.string(), fc.jsonValue()), + async (name, args) => { + const port = new FakePort(); + const result = { content: [{ type: "text", text: name }] }; + port.response = { kind: "result", result: asJson(result) }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const normalizedArgs = asJson(args) as Readonly< + Record + >; + const execution = await session.callTool(name, normalizedArgs); + expect(execution.kind).toBe("immediate"); + expect(port.requests).toEqual([ + { + method: "tools/call", + params: { name, arguments: normalizedArgs }, + }, + ]); + const first = execution.result(); + const second = execution.result(); + expect(first).toBe(second); + await expect(first).resolves.toEqual(result); + const updates: unknown[] = []; + for await (const update of execution.updates()) updates.push(update); + expect(updates).toEqual([]); + await execution.cancel(); + expect(port.requests).toHaveLength(1); + await session.close(); + }, + ), + ); + }); + + it("uses an application result codec at the dispatch boundary", async () => { + const port = new FakePort(); + port.response = { kind: "result", result: { answer: 42 } }; + const codec = createRuntimeCodec((value) => { + const record = expectRecord(value); + if (typeof record.answer !== "number") throw new Error("answer required"); + return record.answer; + }); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("answer", undefined, { + resultCodec: codec, + applicationContext: "ctx", + }); + expect(execution.applicationContext).toBe("ctx"); + await expect(execution.result()).resolves.toBe(42); + await session.close(); + }); + + it("preserves complete JSON-RPC errors and dispatch failures", async () => { + const port = new FakePort(); + port.response = { + kind: "error", + error: { code: -32001, message: "denied", data: { retry: false } }, + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + await expect(session.callTool("denied")).rejects.toMatchObject({ + name: "JsonRpcResponseError", + code: -32001, + message: "denied", + data: { retry: false }, + }); + const error = new DispatchError("offline", true); + expect(error.retryable).toBe(true); + expect(new JsonRpcResponseError({ code: 1, message: "x" })).toBeInstanceOf( + Error, + ); + await session.close(); + }); + + it("closes executions and sessions idempotently without closing the borrowed port", async () => { + const port = new FakePort(); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("x"); + await execution.close(); + await execution.close(); + await session.close(); + await session.close(); + expect(port.listenerDisposals).toBe(3); + expect(port.invalidated).toBe(false); + }); + + it("rejects new and pending work after session invalidation", async () => { + const port = new FakePort(); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + port.invalidate(new Error("replaced")); + await expect(session.callTool("x")).rejects.toThrow("replaced"); + await session.close(); + }); + + it("aborts pending discovery when the port is invalidated", async () => { + const port = new FakePort(); + port.dispatchHandler = (_request, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(new DOMException("invalidated", "AbortError")), + { once: true }, + ); + }); + const session = withTasks(port); + const pending = session.callTool("x"); + port.invalidate(new Error("connection replaced")); + await expect(pending).rejects.toThrow(/connection replaced|invalidated/); + expect(port.requests).toHaveLength(1); + await session.close(); + }); + + it("honors already-aborted call and session signals before dispatch", async () => { + const callPort = new FakePort(); + const callSession = withTasks(callPort, { + tools: { currentTool: () => undefined }, + }); + const callController = new AbortController(); + callController.abort(new Error("call aborted")); + await expect( + callSession.callTool("x", undefined, { signal: callController.signal }), + ).rejects.toThrow("call aborted"); + expect(callPort.requests).toEqual([]); + await callSession.close(); + + const sessionPort = new FakePort(); + const sessionController = new AbortController(); + const session = withTasks(sessionPort, { + tools: { currentTool: () => undefined }, + signal: sessionController.signal, + }); + sessionController.abort(new Error("session aborted")); + await expect(session.callTool("x")).rejects.toThrow("session aborted"); + expect(sessionPort.requests).toEqual([]); + await session.close(); + }); + + it("does not treat an application task field as task creation", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + port.response = { + kind: "result", + result: { content: [], task: "application-data" }, + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toEqual({ + content: [], + task: "application-data", + }); + await session.close(); + }); +}); diff --git a/packages/ext-tasks/src/client/index.test.ts b/packages/ext-tasks/src/client/index.test.ts deleted file mode 100644 index 00b1e19..0000000 --- a/packages/ext-tasks/src/client/index.test.ts +++ /dev/null @@ -1,3035 +0,0 @@ -import { - Client, - ProtocolError, - SdkError, - SdkErrorCode, - type ClientContext, -} from "@modelcontextprotocol/client"; -import fc from "fast-check"; -import { describe, expect, it, vi } from "vitest"; - -import { - createRuntimeCodec, - expectRecord, - type JsonValue, - type TaskId, -} from "../core/index.js"; -import type { ServerTaskCapabilitiesV1, ToolV1 } from "../core/v1/index.js"; - -import { - DispatchError, - createSessionPortFromClient, - InputCorrelationError, - JsonRpcResponseError, - TaskCancellationUnsupportedError, - TaskExecutionClosedError, - TaskUpdatesAlreadyAcquiredError, - withTasks, - type ConnectedMcpSessionPort, - type IncomingServerRequest, - type JsonRpcResponse, - type SessionTaskCapabilities, - type SerializedTaskReference, -} from "./index.js"; - -const asJson = (value: unknown): JsonValue => - JSON.parse(JSON.stringify(value)) as JsonValue; - -const formatJson = (value: unknown): string => - JSON.stringify(value) ?? "undefined"; - -const asError = (reason: unknown): Error => - reason instanceof Error ? reason : new Error(formatJson(reason)); - -class FakePort implements ConnectedMcpSessionPort { - readonly endpointId: string; - readonly requests: JsonValue[] = []; - readonly taskCapabilities: SessionTaskCapabilities; - invalidated = false; - response: JsonRpcResponse = { kind: "result", result: { content: [] } }; - dispatchHandler?: ( - request: JsonValue, - options?: { readonly signal?: AbortSignal }, - ) => Promise; - private requestHandler?: ( - incoming: IncomingServerRequest, - ) => Promise; - private notificationListener?: (notification: JsonValue) => void; - private invalidationListener?: (reason: unknown) => void; - listenerDisposals = 0; - - constructor( - taskCapabilities: SessionTaskCapabilities = { generation: "none" }, - endpointId = "fake-endpoint", - ) { - this.taskCapabilities = taskCapabilities; - this.endpointId = endpointId; - } - - async dispatch( - request: JsonValue, - options?: { readonly signal?: AbortSignal }, - ): Promise { - this.requests.push(request); - return this.dispatchHandler === undefined - ? this.response - : this.dispatchHandler(request, options); - } - - onServerRequest( - handler: (incoming: IncomingServerRequest) => Promise, - ): () => void { - this.requestHandler = handler; - return () => { - this.requestHandler = undefined; - this.listenerDisposals += 1; - }; - } - - onNotification(listener: (notification: JsonValue) => void): () => void { - this.notificationListener = listener; - return () => { - this.notificationListener = undefined; - this.listenerDisposals += 1; - }; - } - - onInvalidated(listener: (reason: unknown) => void): () => void { - this.invalidationListener = listener; - return () => { - this.invalidationListener = undefined; - this.listenerDisposals += 1; - }; - } - - invalidate(reason: unknown): void { - this.invalidated = true; - this.invalidationListener?.(reason); - } - - async serve(request: JsonValue): Promise { - if (this.requestHandler === undefined) - throw new Error("request handler is not installed"); - return this.requestHandler({ request, requestContext: {} }); - } - - notify(notification: JsonValue): void { - this.notificationListener?.(notification); - } -} - -describe("client tool executions", () => { - it("dispatches a non-task call and caches the decoded result", async () => { - await fc.assert( - fc.asyncProperty( - fc.string(), - fc.dictionary(fc.string(), fc.jsonValue()), - async (name, args) => { - const port = new FakePort(); - const result = { content: [{ type: "text", text: name }] }; - port.response = { kind: "result", result: asJson(result) }; - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - }); - const normalizedArgs = asJson(args) as Readonly< - Record - >; - const execution = await session.callTool(name, normalizedArgs); - expect(execution.kind).toBe("immediate"); - expect(port.requests).toEqual([ - { - method: "tools/call", - params: { name, arguments: normalizedArgs }, - }, - ]); - const first = execution.result(); - const second = execution.result(); - expect(first).toBe(second); - await expect(first).resolves.toEqual(result); - const updates: unknown[] = []; - for await (const update of execution.updates()) updates.push(update); - expect(updates).toEqual([]); - await execution.cancel(); - expect(port.requests).toHaveLength(1); - await session.close(); - }, - ), - ); - }); - - it("uses an application result codec at the dispatch boundary", async () => { - const port = new FakePort(); - port.response = { kind: "result", result: { answer: 42 } }; - const codec = createRuntimeCodec((value) => { - const record = expectRecord(value); - if (typeof record.answer !== "number") throw new Error("answer required"); - return record.answer; - }); - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - }); - const execution = await session.callTool("answer", undefined, { - resultCodec: codec, - applicationContext: "ctx", - }); - expect(execution.applicationContext).toBe("ctx"); - await expect(execution.result()).resolves.toBe(42); - await session.close(); - }); - - it("preserves complete JSON-RPC errors and dispatch failures", async () => { - const port = new FakePort(); - port.response = { - kind: "error", - error: { code: -32001, message: "denied", data: { retry: false } }, - }; - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - }); - await expect(session.callTool("denied")).rejects.toMatchObject({ - name: "JsonRpcResponseError", - code: -32001, - message: "denied", - data: { retry: false }, - }); - const error = new DispatchError("offline", true); - expect(error.retryable).toBe(true); - expect(new JsonRpcResponseError({ code: 1, message: "x" })).toBeInstanceOf( - Error, - ); - await session.close(); - }); - - it("closes executions and sessions idempotently without closing the borrowed port", async () => { - const port = new FakePort(); - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - }); - const execution = await session.callTool("x"); - await execution.close(); - await execution.close(); - await session.close(); - await session.close(); - expect(port.listenerDisposals).toBe(3); - expect(port.invalidated).toBe(false); - }); - - it("rejects new and pending work after session invalidation", async () => { - const port = new FakePort(); - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - }); - port.invalidate(new Error("replaced")); - await expect(session.callTool("x")).rejects.toThrow("replaced"); - await session.close(); - }); - - it("aborts pending discovery when the port is invalidated", async () => { - const port = new FakePort(); - port.dispatchHandler = (_request, options) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - "abort", - () => reject(new DOMException("invalidated", "AbortError")), - { once: true }, - ); - }); - const session = withTasks(port); - const pending = session.callTool("x"); - port.invalidate(new Error("connection replaced")); - await expect(pending).rejects.toThrow(/connection replaced|invalidated/); - expect(port.requests).toHaveLength(1); - await session.close(); - }); - - it("settles default V1 input declines with method-specific protocol values", async () => { - const port = new FakePort({ generation: "v1", capabilities: {} }); - const errors: Error[] = []; - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - onError: (error) => errors.push(error), - }); - await expect( - port.serve({ method: "elicitation/create", params: {} }), - ).resolves.toEqual({ - kind: "result", - result: { action: "cancel" }, - }); - for (const method of ["sampling/createMessage", "roots/list"]) { - await expect(port.serve({ method, params: {} })).resolves.toEqual({ - kind: "error", - error: { code: -32603, message: "Internal error" }, - }); - } - expect(errors).toHaveLength(3); - expect( - errors.every((error) => error instanceof InputCorrelationError), - ).toBe(true); - expect( - errors.map((error) => (error as InputCorrelationError).reason), - ).toEqual(["missing-evidence", "missing-evidence", "missing-evidence"]); - await session.close(); - }); - - it("routes ordinary input requests with the execution context", async () => { - const cases = [ - { - method: "elicitation/create", - result: { action: "accept", content: { value: "ok" } }, - }, - { - method: "sampling/createMessage", - result: { - model: "m", - role: "assistant", - content: { type: "text", text: "ok" }, - }, - }, - { method: "roots/list", result: { roots: [{ uri: "file:///tmp" }] } }, - ] as const; - for (const input of cases) { - const port = new FakePort({ generation: "v1", capabilities: {} }); - const observed: unknown[] = []; - port.dispatchHandler = async () => { - observed.push( - await port.serve({ method: input.method, params: { prompt: "p" } }), - ); - return { kind: "result", result: { content: [] } }; - }; - const session = withTasks<{ readonly marker: string }>(port, { - tools: { currentTool: () => undefined }, - onInputRequest: async (request, context) => { - await Promise.resolve(); - observed.push({ request, context }); - return input.result as never; - }, - }); - await session.callTool("x", undefined, { - applicationContext: { marker: "ctx" }, - }); - expect(observed[0]).toMatchObject({ - request: { params: { prompt: "p" } }, - context: { lifetime: "basic", applicationContext: { marker: "ctx" } }, - }); - expect( - (observed[0] as { context: { executionId: string } }).context - .executionId, - ).toMatch(/^execution-/); - expect(observed[1]).toEqual({ kind: "result", result: input.result }); - await session.close(); - } - }); - - it("fails closed when the input handler rejects", async () => { - const port = new FakePort({ generation: "v1", capabilities: {} }); - let settlement: JsonRpcResponse | undefined; - port.dispatchHandler = async () => { - settlement = await port.serve({ - method: "elicitation/create", - params: {}, - }); - return { kind: "result", result: { content: [] } }; - }; - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - onInputRequest: async () => { - await Promise.resolve(); - throw new Error("declined"); - }, - }); - await session.callTool("x"); - expect(settlement).toEqual({ - kind: "result", - result: { action: "cancel" }, - }); - await session.close(); - }); - - it("reports ambiguous ordinary input correlation before declining", async () => { - const port = new FakePort({ generation: "v1", capabilities: {} }); - const completions: ((response: JsonRpcResponse) => void)[] = []; - port.dispatchHandler = () => - new Promise((resolve) => completions.push(resolve)); - const errors: Error[] = []; - let handlerCalls = 0; - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - onInputRequest: async () => { - await Promise.resolve(); - handlerCalls += 1; - return { action: "accept" } as never; - }, - onError: (error) => errors.push(error), - }); - const first = session.callTool("first", undefined, { - applicationContext: "one", - }); - const second = session.callTool("second", undefined, { - applicationContext: "two", - }); - await Promise.resolve(); - await expect( - port.serve({ method: "elicitation/create", params: {} }), - ).resolves.toEqual({ - kind: "result", - result: { action: "cancel" }, - }); - expect(handlerCalls).toBe(0); - expect(errors).toHaveLength(1); - expect(errors[0]).toBeInstanceOf(InputCorrelationError); - expect(errors[0]).toMatchObject({ - reason: "ambiguous-matches", - requestKind: "elicitation", - }); - expect( - (errors[0] as InputCorrelationError).candidates.map( - (candidate) => candidate.toolName, - ), - ).toEqual(["first", "second"]); - for (const complete of completions) - complete({ kind: "result", result: { content: [] } }); - await Promise.all([first, second]); - await session.close(); - }); - - it("correlates V1 task inputs across candidate counts and evidence states", async () => { - await fc.assert( - fc.asyncProperty( - fc.integer({ min: 0, max: 3 }), - fc.constantFrom("absent", "invalid", "matching", "missing"), - fc.constantFrom( - "elicitation/create", - "sampling/createMessage", - "roots/list", - ), - async (candidateCount, evidenceState, method) => { - const port = new FakePort({ - generation: "v1", - capabilities: { requests: { tools: { call: {} } }, cancel: {} }, - }); - port.dispatchHandler = async (request, options) => { - const record = expectRecord(request); - const params = expectRecord(record.params); - if (record.method === "tools/call") { - if (typeof params.name !== "string") - throw new Error("tool name required"); - const name = params.name; - return { - kind: "result", - result: asJson({ - task: { - taskId: `task-${name}`, - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttl: null, - }, - }), - }; - } - if (record.method === "tasks/get") - return new Promise((_resolve, reject) => - options?.signal?.addEventListener( - "abort", - () => reject(asError(options.signal?.reason)), - { once: true }, - ), - ); - if (record.method === "tasks/cancel") - return { - kind: "result", - result: asJson({ - taskId: params.taskId, - status: "cancelled", - createdAt: "a", - lastUpdatedAt: "b", - ttl: null, - }), - }; - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const errors: Error[] = []; - const observed: unknown[] = []; - const session = withTasks(port, { - tools: { - currentTool: (name) => ({ - name, - inputSchema: { type: "object" }, - execution: { taskSupport: "required" }, - }), - }, - onInputRequest: async (request, context) => { - await Promise.resolve(); - observed.push({ request, context }); - return ( - request.kind === "elicitation" - ? { action: "accept" } - : request.kind === "sampling" - ? { - model: "m", - role: "assistant", - content: { type: "text", text: "ok" }, - } - : { roots: [] } - ) as never; - }, - onError: (error) => errors.push(error), - }); - const executions = await Promise.all( - Array.from({ length: candidateCount }, (_, index) => - session.callTool(String(index), undefined, { - applicationContext: `context-${index}`, - }), - ), - ); - const relatedTask: JsonValue = - evidenceState === "absent" - ? {} - : evidenceState === "invalid" - ? { - _meta: { - "io.modelcontextprotocol/related-task": { taskId: 1 }, - }, - } - : { - _meta: { - "io.modelcontextprotocol/related-task": { - taskId: - evidenceState === "matching" ? "task-0" : "other", - }, - }, - }; - const settlement = await port.serve({ method, params: relatedTask }); - const succeeds = - (evidenceState === "absent" && candidateCount === 1) || - (evidenceState === "matching" && candidateCount > 0); - expect(observed).toHaveLength(succeeds ? 1 : 0); - expect(errors).toHaveLength(succeeds ? 0 : 1); - if (succeeds) { - const entry = expectRecord(asJson(observed[0])); - expect(entry.context).toMatchObject({ - lifetime: "task-v1", - taskId: "task-0", - applicationContext: "context-0", - }); - expect( - (observed[0] as { context: { signal: AbortSignal } }).context - .signal, - ).toBeInstanceOf(AbortSignal); - expect(settlement.kind).toBe("result"); - } else { - const expectedReason = - evidenceState === "invalid" - ? "invalid-evidence" - : evidenceState === "absent" && candidateCount === 0 - ? "missing-evidence" - : evidenceState === "missing" || candidateCount === 0 - ? "zero-matches" - : "ambiguous-matches"; - expect(errors[0]).toBeInstanceOf(InputCorrelationError); - expect(errors[0]).toMatchObject({ reason: expectedReason }); - if (evidenceState === "invalid") { - const candidates = (errors[0] as InputCorrelationError) - .candidates; - expect(candidates).toHaveLength(candidateCount); - expect( - candidates.every((candidate) => !("taskId" in candidate)), - ).toBe(true); - } - expect(settlement).toEqual( - method === "elicitation/create" - ? { kind: "result", result: { action: "cancel" } } - : { - kind: "error", - error: { code: -32603, message: "Internal error" }, - }, - ); - } - await Promise.all(executions.map((execution) => execution.close())); - await session.close(); - }, - ), - { numRuns: 40 }, - ); - }); - - it("conforms exactly to the V1 related-task metadata key", async () => { - const malformedValues: JsonValue[] = [ - null, - [], - "task-0", - {}, - { taskId: null }, - ]; - for (const relatedTask of malformedValues) { - const port = new FakePort({ generation: "v1", capabilities: {} }); - const errors: Error[] = []; - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - onInputRequest: async () => { - await Promise.resolve(); - return { action: "accept" } as never; - }, - onError: (error) => errors.push(error), - }); - await port.serve({ - method: "elicitation/create", - params: { - _meta: { "io.modelcontextprotocol/related-task": relatedTask }, - }, - }); - expect(errors[0]).toMatchObject({ reason: "invalid-evidence" }); - await session.close(); - } - const port = new FakePort({ generation: "v1", capabilities: {} }); - const errors: Error[] = []; - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - onInputRequest: async () => { - await Promise.resolve(); - return { action: "accept" } as never; - }, - onError: (error) => errors.push(error), - }); - await port.serve({ - method: "elicitation/create", - params: { - _meta: { - "modelcontextprotocol.io/related-task": { taskId: "wrong-key" }, - unrelated: true, - }, - }, - }); - expect(errors[0]).toMatchObject({ reason: "missing-evidence" }); - await session.close(); - }); - - it("unregisters a closed V1 task candidate and aborts its handler signal", async () => { - const port = new FakePort({ - generation: "v1", - capabilities: { requests: { tools: { call: {} } }, cancel: {} }, - }); - port.dispatchHandler = async (request, options) => { - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - task: { - taskId: "lifecycle", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttl: null, - }, - }), - }; - if (record.method === "tasks/get") - return new Promise((_resolve, reject) => - options?.signal?.addEventListener( - "abort", - () => reject(asError(options.signal?.reason)), - { once: true }, - ), - ); - return { - kind: "result", - result: asJson({ - taskId: "lifecycle", - status: "cancelled", - createdAt: "a", - lastUpdatedAt: "b", - ttl: null, - }), - }; - }; - const errors: Error[] = []; - let handlerSignal: AbortSignal | undefined; - const session = withTasks(port, { - tools: { - currentTool: () => ({ - name: "x", - inputSchema: { type: "object" }, - execution: { taskSupport: "required" }, - }), - }, - onInputRequest: async (_request, context) => { - await Promise.resolve(); - handlerSignal = context.signal; - return { action: "accept" } as never; - }, - onError: (error) => errors.push(error), - }); - const execution = await session.callTool("x"); - await port.serve({ method: "elicitation/create", params: {} }); - expect(handlerSignal?.aborted).toBe(false); - await execution.close(); - await expect(execution.result()).rejects.toBeInstanceOf( - TaskExecutionClosedError, - ); - expect(handlerSignal?.aborted).toBe(true); - await port.serve({ - method: "elicitation/create", - params: { - _meta: { - "io.modelcontextprotocol/related-task": { taskId: "lifecycle" }, - }, - }, - }); - expect(errors.at(-1)).toMatchObject({ reason: "zero-matches" }); - await session.close(); - }); - it("honors already-aborted call and session signals before dispatch", async () => { - const callPort = new FakePort(); - const callSession = withTasks(callPort, { - tools: { currentTool: () => undefined }, - }); - const callController = new AbortController(); - callController.abort(new Error("call aborted")); - await expect( - callSession.callTool("x", undefined, { signal: callController.signal }), - ).rejects.toThrow("call aborted"); - expect(callPort.requests).toEqual([]); - await callSession.close(); - - const sessionPort = new FakePort(); - const sessionController = new AbortController(); - const session = withTasks(sessionPort, { - tools: { currentTool: () => undefined }, - signal: sessionController.signal, - }); - sessionController.abort(new Error("session aborted")); - await expect(session.callTool("x")).rejects.toThrow("session aborted"); - expect(sessionPort.requests).toEqual([]); - await session.close(); - }); - - it("cleans up call listeners when declaration lookup fails", async () => { - const port = new FakePort(); - const callController = new AbortController(); - const addListener = vi.spyOn(callController.signal, "addEventListener"); - const removeListener = vi.spyOn( - callController.signal, - "removeEventListener", - ); - const session = withTasks(port, { - tools: { - currentTool: () => { - throw new Error("declaration lookup failed"); - }, - }, - }); - await expect( - session.callTool("x", undefined, { signal: callController.signal }), - ).rejects.toThrow("declaration lookup failed"); - expect(port.requests).toEqual([]); - expect(addListener).toHaveBeenCalledTimes(1); - expect(removeListener).toHaveBeenCalledTimes(1); - await session.close(); - }); - it("applies the exhaustive V1 capability-first task augmentation table", async () => { - const support = fc.option( - fc.constantFrom("forbidden", "optional", "required"), - { nil: undefined }, - ); - await fc.assert( - fc.asyncProperty( - fc.boolean(), - support, - fc.boolean(), - async (present, taskSupport, preferTask) => { - const capabilities: ServerTaskCapabilitiesV1 = present - ? { requests: { tools: { call: {} } } } - : {}; - const port = new FakePort({ generation: "v1", capabilities }); - let taskSelected = false; - port.dispatchHandler = async (request) => { - await Promise.resolve(); - const record = expectRecord(request); - if (record.method === "tools/call") { - return taskSelected - ? { - kind: "result", - result: asJson({ - task: { - taskId: "property-task", - status: "completed", - createdAt: "a", - lastUpdatedAt: "b", - ttl: null, - }, - }), - } - : { kind: "result", result: { content: [] } }; - } - if (record.method === "tasks/result") - return { kind: "result", result: { content: [] } }; - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - let lookups = 0; - const tool: ToolV1 = { - name: "x", - inputSchema: { type: "object" }, - execution: { taskSupport }, - }; - const session = withTasks(port, { - tools: { - currentTool: () => { - lookups += 1; - return tool; - }, - }, - }); - taskSelected = - present && - (taskSupport === "required" || - (taskSupport === "optional" && preferTask)); - const execution = await session.callTool("x", undefined, { - preferTask, - }); - if (taskSelected) { - expect(execution.kind).toBe("task"); - expect(port.requests).toEqual([ - { method: "tools/call", params: { name: "x", task: {} } }, - { method: "tasks/result", params: { taskId: "property-task" } }, - ]); - } else { - expect(execution.kind).toBe("immediate"); - expect(port.requests).toEqual([ - { method: "tools/call", params: { name: "x" } }, - ]); - } - expect(lookups).toBe(1); - await session.close(); - }, - ), - ); - }); - - it("does not treat an application task field as task creation", async () => { - const port = new FakePort({ generation: "v1", capabilities: {} }); - port.response = { - kind: "result", - result: { content: [], task: "application-data" }, - }; - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - }); - const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toEqual({ - content: [], - task: "application-data", - }); - await session.close(); - }); - - it("manages initial tool declarations only when no provider is supplied", async () => { - const managed = new FakePort({ generation: "v1", capabilities: {} }); - managed.dispatchHandler = async (request) => { - await Promise.resolve(); - const record = expectRecord(request); - if (record.method === "tools/list") { - return { - kind: "result", - result: asJson({ - tools: [{ name: "listed", inputSchema: { type: "object" } }], - }), - }; - } - return { kind: "result", result: asJson({ content: [] }) }; - }; - const managedSession = withTasks(managed); - await managedSession.callTool("listed"); - expect(managed.requests).toEqual([ - { method: "tools/list", params: {} }, - { method: "tools/call", params: { name: "listed" } }, - ]); - await managedSession.close(); - - const supplied = new FakePort(); - const suppliedSession = withTasks(supplied, { - tools: { currentTool: () => undefined }, - }); - await suppliedSession.callTool("x"); - expect(supplied.requests).toEqual([ - { method: "tools/call", params: { name: "x" } }, - ]); - await suppliedSession.close(); - }); - - it("retries initial discovery and follows tool-list cursors", async () => { - const port = new FakePort({ generation: "v1", capabilities: {} }); - let attempts = 0; - port.dispatchHandler = async (request) => { - await Promise.resolve(); - const record = expectRecord(request); - if (record.method !== "tools/list") - return { kind: "result", result: asJson({ content: [] }) }; - attempts += 1; - if (attempts === 1) throw new DispatchError("temporary", true); - const params = expectRecord(record.params); - if (params.cursor === undefined) { - return { - kind: "result", - result: asJson({ - tools: [{ name: "first", inputSchema: { type: "object" } }], - nextCursor: "next", - }), - }; - } - return { - kind: "result", - result: asJson({ - tools: [{ name: "second", inputSchema: { type: "object" } }], - }), - }; - }; - const session = withTasks(port); - await session.callTool("second"); - expect(port.requests.slice(0, 3)).toEqual([ - { method: "tools/list", params: {} }, - { method: "tools/list", params: {} }, - { method: "tools/list", params: { cursor: "next" } }, - ]); - await session.close(); - }); - it("ignores stale tool-list refreshes", async () => { - const port = new FakePort({ - generation: "v1", - capabilities: { requests: { tools: { call: {} } } }, - }); - const pending: ((response: JsonRpcResponse) => void)[] = []; - let abortedRefreshes = 0; - let listCount = 0; - port.dispatchHandler = (request, options) => { - const record = expectRecord(request); - if (record.method === "tools/call") { - const params = expectRecord(record.params); - return Promise.resolve( - params.task === undefined - ? { kind: "result", result: { content: [] } } - : { - kind: "result", - result: asJson({ - task: { - taskId: "newest", - status: "completed", - createdAt: "a", - lastUpdatedAt: "b", - ttl: null, - }, - }), - }, - ); - } - if (record.method === "tasks/result") - return Promise.resolve({ kind: "result", result: { content: [] } }); - if (record.method !== "tools/list") - throw new Error(`unexpected method ${formatJson(record.method)}`); - listCount += 1; - if (listCount === 1) { - return Promise.resolve({ - kind: "result", - result: asJson({ - tools: [{ name: "x", inputSchema: { type: "object" } }], - }), - }); - } - return new Promise((resolve, reject) => { - options?.signal?.addEventListener( - "abort", - () => { - abortedRefreshes += 1; - reject(new DOMException("superseded", "AbortError")); - }, - { once: true }, - ); - pending.push(resolve); - }); - }; - const session = withTasks(port); - await session.callTool("x"); - port.requests.length = 0; - port.notify({ method: "notifications/tools/list_changed" }); - port.notify({ method: "notifications/tools/list_changed" }); - expect(abortedRefreshes).toBe(1); - pending[1]?.({ - kind: "result", - result: asJson({ - tools: [ - { - name: "x", - inputSchema: { type: "object" }, - execution: { taskSupport: "required" }, - }, - ], - }), - }); - await Promise.resolve(); - const execution = await session.callTool("x"); - expect(execution.kind).toBe("task"); - expect(port.requests.slice(-2)).toEqual([ - { method: "tools/call", params: { name: "x", task: {} } }, - { method: "tasks/result", params: { taskId: "newest" } }, - ]); - await session.close(); - }); - - it("reports duplicate tools and aborts managed discovery on close", async () => { - const errors: Error[] = []; - const duplicatePort = new FakePort({ generation: "v1", capabilities: {} }); - duplicatePort.dispatchHandler = async (request) => { - await Promise.resolve(); - const record = expectRecord(request); - if (record.method === "tools/list") { - return { - kind: "result", - result: asJson({ - tools: [ - { name: "duplicate", inputSchema: { type: "object" } }, - { - name: "duplicate", - inputSchema: { type: "object" }, - title: "newer", - }, - ], - }), - }; - } - return { kind: "result", result: asJson({ content: [] }) }; - }; - const duplicateSession = withTasks(duplicatePort, { - onError: (error) => errors.push(error), - }); - await duplicateSession.callTool("duplicate"); - expect(errors.map((error) => error.message)).toContain( - "Duplicate tool declaration: duplicate", - ); - await duplicateSession.close(); - - const callAbortPort = new FakePort(); - callAbortPort.dispatchHandler = (_request, options) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - "abort", - () => reject(new DOMException("discovery aborted", "AbortError")), - { once: true }, - ); - }); - const callAbortSession = withTasks(callAbortPort); - const callController = new AbortController(); - const call = callAbortSession.callTool("x", undefined, { - signal: callController.signal, - }); - callController.abort(new Error("waiter aborted")); - await expect(call).rejects.toThrow("waiter aborted"); - expect(callAbortPort.requests).toHaveLength(1); - await callAbortSession.close(); - - const closePort = new FakePort(); - let refreshSignal: AbortSignal | undefined; - closePort.dispatchHandler = (_request, options) => - new Promise((_resolve, reject) => { - refreshSignal = options?.signal; - options?.signal?.addEventListener( - "abort", - () => reject(new DOMException("closed", "AbortError")), - { once: true }, - ); - }); - const closeSession = withTasks(closePort); - const closeCallController = new AbortController(); - const addListener = vi.spyOn( - closeCallController.signal, - "addEventListener", - ); - const removeListener = vi.spyOn( - closeCallController.signal, - "removeEventListener", - ); - const pendingCall = closeSession.callTool("x", undefined, { - signal: closeCallController.signal, - }); - await closeSession.close(); - await expect(pendingCall).rejects.toThrow(/closed|aborted/i); - expect(refreshSignal?.aborted).toBe(true); - expect(closePort.requests).toHaveLength(1); - expect(addListener).toHaveBeenCalledTimes(1); - expect(removeListener).toHaveBeenCalledTimes(1); - }); - it("rejects generation-mismatched declarations without leaking call listeners", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - const v1Tool: ToolV1 = { - name: "x", - inputSchema: { type: "object" }, - execution: { taskSupport: "required" }, - }; - const callController = new AbortController(); - const addListener = vi.spyOn(callController.signal, "addEventListener"); - const removeListener = vi.spyOn( - callController.signal, - "removeEventListener", - ); - const session = withTasks(port, { tools: { currentTool: () => v1Tool } }); - for (let attempt = 0; attempt < 3; attempt += 1) { - await expect( - session.callTool("x", undefined, { signal: callController.signal }), - ).rejects.toThrow( - "V1 tool declaration is incompatible with the V2 session", - ); - } - expect(port.requests).toEqual([]); - expect(addListener).toHaveBeenCalledTimes(3); - expect(removeListener).toHaveBeenCalledTimes(3); - await session.close(); - }); - - it("drives a V1 task to a separately retrieved result", async () => { - const port = new FakePort({ - generation: "v1", - capabilities: { requests: { tools: { call: {} } }, cancel: {} }, - }); - const tool: ToolV1 = { - name: "long", - inputSchema: { type: "object" }, - execution: { taskSupport: "required" }, - }; - port.dispatchHandler = async (request) => { - await Promise.resolve(); - const record = expectRecord(request); - if (record.method === "tools/call") { - return { - kind: "result", - result: asJson({ - task: { - taskId: "v1-task", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttl: null, - }, - }), - }; - } - if (record.method === "tasks/get") { - return { - kind: "result", - result: asJson({ - taskId: "v1-task", - status: "completed", - createdAt: "a", - lastUpdatedAt: "b", - ttl: null, - }), - }; - } - if (record.method === "tasks/result") { - return { - kind: "result", - result: asJson({ content: [{ type: "text", text: "done" }] }), - }; - } - if (record.method === "tasks/cancel") { - return { - kind: "result", - result: asJson({ - taskId: "v1-task", - status: "cancelled", - createdAt: "a", - lastUpdatedAt: "b", - ttl: null, - }), - }; - } - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { tools: { currentTool: () => tool } }); - const execution = await session.callTool("long"); - expect(execution.kind).toBe("task"); - expect(execution.handle).toEqual({ - generation: "v1", - taskId: "v1-task", - originalOperation: "tools/call", - }); - const snapshots: unknown[] = []; - for await (const snapshot of execution.updates()) snapshots.push(snapshot); - expect(snapshots).toEqual([ - { - generation: "v1", - task: { - taskId: "v1-task", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttl: null, - }, - }, - { - generation: "v1", - task: { - taskId: "v1-task", - status: "completed", - createdAt: "a", - lastUpdatedAt: "b", - ttl: null, - }, - }, - ]); - const first = execution.result(); - expect(execution.result()).toBe(first); - await expect(first).resolves.toEqual({ - content: [{ type: "text", text: "done" }], - }); - await session.close(); - }); - - it("drives a V2 task to its inline terminal result", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - const tool = { name: "long", inputSchema: { type: "object" } }; - port.dispatchHandler = async (request) => { - await Promise.resolve(); - const record = expectRecord(request); - if (record.method === "tools/call") { - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "v2-task", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - } - if (record.method === "tasks/get") { - return { - kind: "result", - result: asJson({ - resultType: "complete", - taskId: "v2-task", - status: "completed", - createdAt: "a", - lastUpdatedAt: "b", - ttlMs: null, - result: { - resultType: "complete", - content: [{ type: "text", text: "done" }], - }, - }), - }; - } - if (record.method === "tasks/cancel") - return { kind: "result", result: { resultType: "complete" } }; - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { tools: { currentTool: () => tool } }); - const execution = await session.callTool("long"); - expect(execution.kind).toBe("task"); - expect(execution.handle).toEqual({ - generation: "v2", - taskId: "v2-task", - originalOperation: "tools/call", - }); - expect(port.requests[0]).toMatchObject({ - method: "tools/call", - params: { - _meta: { - "io.modelcontextprotocol/clientCapabilities": { - extensions: { "io.modelcontextprotocol/tasks": {} }, - }, - }, - }, - }); - await expect(execution.result()).resolves.toEqual({ - resultType: "complete", - content: [{ type: "text", text: "done" }], - }); - await session.close(); - }); - - it("acquires distinct V2 input keys once and submits one valid subset", async () => { - await fc.assert( - fc.asyncProperty( - fc.uniqueArray( - fc.record({ - key: fc.stringMatching(/^[a-z][a-z0-9]{0,7}$/), - kind: fc.constantFrom("sampling", "roots", "elicitation"), - }), - { minLength: 1, maxLength: 8, selector: ({ key }) => key }, - ), - async (inputs) => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - const observed: unknown[] = []; - let getCalls = 0; - port.dispatchHandler = async (request) => { - await Promise.resolve(); - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "input-task", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (record.method === "tasks/get") { - getCalls += 1; - if (getCalls === 1) - return { - kind: "result", - result: asJson({ - resultType: "complete", - taskId: "input-task", - status: "input_required", - createdAt: "a", - lastUpdatedAt: "b", - ttlMs: null, - inputRequests: Object.fromEntries( - inputs.map(({ key, kind }) => [ - key, - kind === "sampling" - ? { - method: "sampling/createMessage", - params: { key }, - } - : kind === "roots" - ? { method: "roots/list" } - : { method: "elicitation/create", params: { key } }, - ]), - ), - }), - }; - return { - kind: "result", - result: asJson({ - resultType: "complete", - taskId: "input-task", - status: "completed", - createdAt: "a", - lastUpdatedAt: "c", - ttlMs: null, - result: { resultType: "complete", content: [] }, - }), - }; - } - if (record.method === "tasks/update") - return { kind: "result", result: { resultType: "complete" } }; - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks<{ marker: string }>(port, { - tools: { - currentTool: () => ({ - name: "x", - inputSchema: { type: "object" }, - }), - }, - onInputRequest: async (request, context) => { - await Promise.resolve(); - observed.push({ request, context }); - return ( - request.kind === "sampling" - ? { model: "m", role: "assistant", content: { type: "text" } } - : request.kind === "roots" - ? { roots: [{ uri: "file:///root" }] } - : { action: "cancel" } - ) as never; - }, - }); - const execution = await session.callTool( - "x", - {}, - { - applicationContext: { marker: "context" }, - }, - ); - await expect(execution.result()).resolves.toEqual({ - resultType: "complete", - content: [], - }); - expect(observed).toHaveLength(inputs.length); - expect( - observed.map((value) => { - const entry = expectRecord(asJson(value)); - return expectRecord(entry.context).inputKey; - }), - ).toEqual(inputs.map(({ key }) => key)); - const updates = port.requests.filter( - (request) => expectRecord(request).method === "tasks/update", - ); - expect(updates).toHaveLength(1); - expect(updates[0]).toMatchObject({ - params: { - taskId: "input-task", - _meta: { - "io.modelcontextprotocol/clientCapabilities": { - extensions: { "io.modelcontextprotocol/tasks": {} }, - }, - }, - }, - }); - expect( - Object.keys( - expectRecord(expectRecord(updates[0]).params) - .inputResponses as object, - ), - ).toEqual(inputs.map(({ key }) => key)); - await session.close(); - }, - ), - { numRuns: 25 }, - ); - }); - - it("reports incompatible repeated V2 keys without reacquiring or updating", async () => { - const errors: Error[] = []; - const port = new FakePort({ generation: "v2", capabilities: {} }); - let getCalls = 0; - let handlerCalls = 0; - port.dispatchHandler = async (request) => { - await Promise.resolve(); - const method = expectRecord(request).method; - if (method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "repeat", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (method === "tasks/get") { - getCalls += 1; - if (getCalls <= 2) - return { - kind: "result", - result: asJson({ - resultType: "complete", - taskId: "repeat", - status: "input_required", - createdAt: "a", - lastUpdatedAt: String(getCalls), - ttlMs: null, - inputRequests: { - same: - getCalls === 1 - ? { method: "roots/list" } - : { method: "sampling/createMessage", params: {} }, - }, - }), - }; - return { - kind: "result", - result: asJson({ - resultType: "complete", - taskId: "repeat", - status: "completed", - createdAt: "a", - lastUpdatedAt: "z", - ttlMs: null, - result: { resultType: "complete", content: [] }, - }), - }; - } - throw new Error(`unexpected method ${formatJson(method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - onInputRequest: async () => { - await Promise.resolve(); - handlerCalls += 1; - throw new Error("declined"); - }, - onError: (error) => errors.push(error), - }); - const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toMatchObject({ - resultType: "complete", - }); - expect(handlerCalls).toBe(1); - expect( - errors.some((error) => error.message.includes("reused incompatibly")), - ).toBe(true); - expect( - port.requests.filter( - (request) => expectRecord(request).method === "tasks/update", - ), - ).toEqual([]); - await session.close(); - }); - - it("declines keyed V2 elicitation while withholding sampling and roots", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let getCalls = 0; - port.dispatchHandler = async (request) => { - await Promise.resolve(); - const method = expectRecord(request).method; - if (method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "decline-input", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (method === "tasks/get") { - getCalls += 1; - return { - kind: "result", - result: asJson( - getCalls === 1 - ? { - resultType: "complete", - taskId: "decline-input", - status: "input_required", - createdAt: "a", - lastUpdatedAt: "b", - ttlMs: null, - inputRequests: { - elicit: { method: "elicitation/create", params: {} }, - sample: { method: "sampling/createMessage", params: {} }, - roots: { method: "roots/list" }, - }, - } - : { - resultType: "complete", - taskId: "decline-input", - status: "completed", - createdAt: "a", - lastUpdatedAt: "c", - ttlMs: null, - result: { resultType: "complete", content: [] }, - }, - ), - }; - } - if (method === "tasks/update") - return { kind: "result", result: { resultType: "complete" } }; - throw new Error(`unexpected method ${formatJson(method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - onInputRequest: async () => { - await Promise.resolve(); - throw new Error("declined"); - }, - }); - const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toMatchObject({ - resultType: "complete", - }); - const updates = port.requests.filter( - (request) => expectRecord(request).method === "tasks/update", - ); - expect(updates).toHaveLength(1); - expect( - expectRecord(expectRecord(updates[0]).params).inputResponses, - ).toEqual({ - elicit: { action: "cancel" }, - }); - await session.close(); - }); - - it("aborts V2 input handling when a terminal notification arrives", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let getCalls = 0; - let handlerSignal: AbortSignal | undefined; - port.dispatchHandler = async (request) => { - await Promise.resolve(); - const method = expectRecord(request).method; - if (method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "terminal-input", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (method === "tasks/get") { - getCalls += 1; - return { - kind: "result", - result: asJson({ - resultType: "complete", - taskId: "terminal-input", - status: "input_required", - createdAt: "a", - lastUpdatedAt: "b", - ttlMs: null, - inputRequests: { - key: { method: "elicitation/create", params: {} }, - }, - }), - }; - } - throw new Error(`unexpected method ${formatJson(method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - onInputRequest: (_request, context) => { - handlerSignal = context.signal; - return new Promise((_resolve, reject) => - context.signal?.addEventListener( - "abort", - () => reject(asError(context.signal?.reason)), - { once: true }, - ), - ); - }, - }); - const execution = await session.callTool("x"); - while (handlerSignal === undefined) - await new Promise((resolve) => setTimeout(resolve, 1)); - port.notify( - asJson({ - jsonrpc: "2.0", - method: "notifications/tasks", - params: { - resultType: "complete", - taskId: "terminal-input", - status: "completed", - createdAt: "a", - lastUpdatedAt: "c", - ttlMs: null, - result: { resultType: "complete", content: [] }, - }, - }), - ); - await expect(execution.result()).resolves.toEqual({ - resultType: "complete", - content: [], - }); - expect(handlerSignal.aborted).toBe(true); - expect(getCalls).toBe(1); - expect( - port.requests.some( - (request) => expectRecord(request).method === "tasks/update", - ), - ).toBe(false); - await session.close(); - }); - - it("shares cancellation and enforces single-consumer task updates", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let cancelCalls = 0; - port.dispatchHandler = async (request, options) => { - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "pending", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (record.method === "tasks/get") - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - "abort", - () => reject(asError(options.signal?.reason)), - { once: true }, - ); - }); - if (record.method === "tasks/cancel") { - cancelCalls += 1; - return { kind: "result", result: { resultType: "complete" } }; - } - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - }); - const execution = await session.callTool("x"); - execution.updates(); - expect(() => execution.updates()).toThrow(TaskUpdatesAlreadyAcquiredError); - const firstCancel = execution.cancel(); - expect(execution.cancel()).toBe(firstCancel); - await firstCancel; - expect(cancelCalls).toBe(1); - expect( - port.requests.find( - (request) => expectRecord(request).method === "tasks/cancel", - ), - ).toMatchObject({ - params: { - _meta: { - "io.modelcontextprotocol/clientCapabilities": { - extensions: { "io.modelcontextprotocol/tasks": {} }, - }, - }, - }, - }); - await execution.close(); - await expect(execution.result()).rejects.toBeInstanceOf( - TaskExecutionClosedError, - ); - await session.close(); - }); - - it("session close cancels and closes active task executions", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let cancelCalls = 0; - port.dispatchHandler = async (request, options) => { - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "session-close", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (record.method === "tasks/get") - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - "abort", - () => reject(asError(options.signal?.reason)), - { once: true }, - ); - }); - if (record.method === "tasks/cancel") { - cancelCalls += 1; - return { kind: "result", result: { resultType: "complete" } }; - } - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - }); - const execution = await session.callTool("x"); - await session.close(); - expect(cancelCalls).toBe(1); - await expect(execution.result()).rejects.toBeInstanceOf( - TaskExecutionClosedError, - ); - }); - - it("retries task observations once after any DispatchError", async () => { - await fc.assert( - fc.asyncProperty(fc.boolean(), async (retryable) => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let getCalls = 0; - port.dispatchHandler = async (request) => { - await Promise.resolve(); - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "retry-get", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (record.method === "tasks/get") { - getCalls += 1; - if (getCalls === 1) - throw new DispatchError("observe failed", retryable); - return { - kind: "result", - result: asJson({ - resultType: "complete", - taskId: "retry-get", - status: "completed", - createdAt: "a", - lastUpdatedAt: "b", - ttlMs: null, - result: { resultType: "complete", content: [] }, - }), - }; - } - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - }); - const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toEqual({ - resultType: "complete", - content: [], - }); - expect(getCalls).toBe(2); - await session.close(); - }), - { numRuns: 10 }, - ); - }); - - it("retries cancellation only for proven retryable dispatch failures", async () => { - await fc.assert( - fc.asyncProperty(fc.boolean(), async (retryable) => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let cancelCalls = 0; - port.dispatchHandler = async (request, options) => { - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "retry-cancel", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (record.method === "tasks/get") - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - "abort", - () => reject(asError(options.signal?.reason)), - { once: true }, - ); - }); - if (record.method === "tasks/cancel") { - cancelCalls += 1; - if (cancelCalls === 1) - throw new DispatchError("cancel failed", retryable); - return { kind: "result", result: { resultType: "complete" } }; - } - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - }); - const execution = await session.callTool("x"); - if (retryable) - await expect(execution.cancel()).resolves.toBeUndefined(); - else await expect(execution.cancel()).rejects.toThrow("cancel failed"); - expect(cancelCalls).toBe(retryable ? 2 : 1); - await execution.close(); - await expect(execution.result()).rejects.toBeInstanceOf( - TaskExecutionClosedError, - ); - await session.close(); - }), - ); - }); - - it("conflates nonterminal task updates and always delivers terminal", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - port.dispatchHandler = async (request) => { - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "conflate", - status: "working", - statusMessage: "initial", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - pollIntervalMs: 1000, - }), - }; - if (record.method === "tasks/get") return new Promise(() => {}); - if (record.method === "tasks/cancel") - return { kind: "result", result: { resultType: "complete" } }; - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - }); - const execution = await session.callTool("x"); - const iterator = execution.updates()[Symbol.asyncIterator](); - await expect(iterator.next()).resolves.toMatchObject({ - value: { task: { statusMessage: "initial" } }, - }); - for (const statusMessage of ["one", "one", "two", "three"]) { - port.notify( - asJson({ - jsonrpc: "2.0", - method: "notifications/tasks", - params: { - resultType: "complete", - taskId: "conflate", - status: "working", - statusMessage, - createdAt: "a", - lastUpdatedAt: statusMessage, - ttlMs: null, - pollIntervalMs: 1000, - }, - }), - ); - } - await Promise.resolve(); - await expect(iterator.next()).resolves.toMatchObject({ - value: { task: { statusMessage: "three" } }, - }); - port.notify( - asJson({ - jsonrpc: "2.0", - method: "notifications/tasks", - params: { - resultType: "complete", - taskId: "conflate", - status: "completed", - createdAt: "a", - lastUpdatedAt: "z", - ttlMs: null, - result: { resultType: "complete", content: [] }, - }, - }), - ); - port.notify( - asJson({ - jsonrpc: "2.0", - method: "notifications/tasks", - params: { - resultType: "complete", - taskId: "conflate", - status: "working", - statusMessage: "late", - createdAt: "a", - lastUpdatedAt: "late", - ttlMs: null, - pollIntervalMs: 1000, - }, - }), - ); - await expect(iterator.next()).resolves.toMatchObject({ - value: { task: { status: "completed" } }, - }); - await expect(iterator.next()).resolves.toEqual({ - done: true, - value: undefined, - }); - await session.close(); - }); - - it("does not retry complete JSON-RPC task errors", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let getCalls = 0; - port.dispatchHandler = async (request) => { - await Promise.resolve(); - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "rpc-error", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (record.method === "tasks/get") { - getCalls += 1; - return { kind: "error", error: { code: -32000, message: "failed" } }; - } - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - }); - const execution = await session.callTool("x"); - await expect(execution.result()).rejects.toBeInstanceOf( - JsonRpcResponseError, - ); - expect(getCalls).toBe(1); - await session.close(); - }); - - it("fetches V2 details when task creation is already terminal", async () => { - await fc.assert( - fc.asyncProperty( - fc.constantFrom("completed", "failed", "cancelled"), - async (status) => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let getCalls = 0; - port.dispatchHandler = async (request) => { - await Promise.resolve(); - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "terminal-at-creation", - status, - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (record.method === "tasks/get") { - getCalls += 1; - const terminal = { - resultType: "complete", - taskId: "terminal-at-creation", - status, - createdAt: "a", - lastUpdatedAt: "b", - ttlMs: null, - }; - return { - kind: "result", - result: asJson( - status === "completed" - ? { - ...terminal, - result: { resultType: "complete", content: [] }, - } - : status === "failed" - ? { - ...terminal, - error: { code: -32000, message: "task failed" }, - } - : terminal, - ), - }; - } - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ - name: "x", - inputSchema: { type: "object" }, - }), - }, - }); - const execution = await session.callTool("x"); - if (status === "completed") - await expect(execution.result()).resolves.toEqual({ - resultType: "complete", - content: [], - }); - else if (status === "failed") - await expect(execution.result()).rejects.toMatchObject({ - name: "JsonRpcResponseError", - code: -32000, - message: "task failed", - }); - else await expect(execution.result()).rejects.toThrow(/cancel/i); - expect(getCalls).toBe(1); - await session.close(); - }, - ), - { numRuns: 9 }, - ); - }); - - it("routes matching task notifications without cancelling the task", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let cancelCalls = 0; - let getCalls = 0; - port.dispatchHandler = async (request) => { - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "notify", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - pollIntervalMs: 1000, - }), - }; - if (record.method === "tasks/get") { - getCalls += 1; - return new Promise(() => {}); - } - if (record.method === "tasks/cancel") { - cancelCalls += 1; - return { kind: "result", result: { resultType: "complete" } }; - } - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - }); - const execution = await session.callTool("x"); - const observer = new AbortController(); - const iterator = execution.updates(observer.signal)[Symbol.asyncIterator](); - await expect(iterator.next()).resolves.toMatchObject({ - value: { - generation: "v2", - task: { taskId: "notify", status: "working" }, - }, - }); - const waiting = iterator.next(); - observer.abort(new Error("observer done")); - await expect(waiting).rejects.toThrow("observer done"); - expect(cancelCalls).toBe(0); - port.notify( - asJson({ - jsonrpc: "2.0", - method: "notifications/tasks", - params: { - resultType: "complete", - taskId: "wrong", - status: "completed", - createdAt: "a", - lastUpdatedAt: "b", - ttlMs: null, - result: { resultType: "complete", content: [] }, - }, - }), - ); - await Promise.resolve(); - port.notify( - asJson({ - jsonrpc: "2.0", - method: "notifications/tasks", - params: { - resultType: "complete", - taskId: "notify", - status: "completed", - createdAt: "a", - lastUpdatedAt: "b", - ttlMs: null, - result: { resultType: "complete", content: [] }, - }, - }), - ); - await expect(execution.result()).resolves.toEqual({ - resultType: "complete", - content: [], - }); - expect(cancelCalls).toBe(0); - expect(getCalls).toBe(0); - await session.close(); - }); - - it("closes promptly when remote cancellation never settles", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - port.dispatchHandler = async (request, options) => { - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "stuck-cancel", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (record.method === "tasks/get") - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - "abort", - () => reject(asError(options.signal?.reason)), - { once: true }, - ); - }); - if (record.method === "tasks/cancel") return new Promise(() => {}); - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - }); - const execution = await session.callTool("x"); - await expect(execution.close()).resolves.toBeUndefined(); - await expect(execution.result()).rejects.toBeInstanceOf( - TaskExecutionClosedError, - ); - await expect(session.close()).resolves.toBeUndefined(); - }); - - it("invalidating a session aborts active task executions", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - port.dispatchHandler = async (request, options) => { - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "invalidate-active", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (record.method === "tasks/get") - return new Promise((_resolve, reject) => { - options?.signal?.addEventListener( - "abort", - () => reject(asError(options.signal?.reason)), - { once: true }, - ); - }); - if (record.method === "tasks/cancel") - return { kind: "result", result: { resultType: "complete" } }; - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - }); - const execution = await session.callTool("x"); - port.invalidate(new Error("session replaced")); - await expect(execution.result()).rejects.toThrow("session replaced"); - await session.close(); - }); - - it("a terminal notification preempts an in-flight observation", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let getStarted = false; - let observationSignal: AbortSignal | undefined; - port.dispatchHandler = async (request, options) => { - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "preempt", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - pollIntervalMs: 10, - }), - }; - if (record.method === "tasks/get") { - getStarted = true; - const signal = options?.signal; - if (signal === undefined) - throw new Error("observation signal is required"); - observationSignal = signal; - return new Promise((_resolve, reject) => - signal.addEventListener( - "abort", - () => reject(asError(signal.reason)), - { - once: true, - }, - ), - ); - } - if (record.method === "tasks/cancel") - return { kind: "result", result: { resultType: "complete" } }; - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - }); - const execution = await session.callTool("x"); - while (!getStarted) await new Promise((resolve) => setTimeout(resolve, 1)); - port.notify( - asJson({ - jsonrpc: "2.0", - method: "notifications/tasks", - params: { - resultType: "complete", - taskId: "preempt", - status: "completed", - createdAt: "a", - lastUpdatedAt: "b", - ttlMs: null, - result: { resultType: "complete", content: [] }, - }, - }), - ); - await expect(execution.result()).resolves.toEqual({ - resultType: "complete", - content: [], - }); - expect(observationSignal?.aborted).toBe(true); - await session.close(); - }); - - it("caller abort does not poison the shared cancellation attempt", async () => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let cancelCalls = 0; - let finishCancel: (() => void) | undefined; - port.dispatchHandler = async (request, options) => { - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - resultType: "task", - taskId: "cancel-waiter", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - }), - }; - if (record.method === "tasks/get") - return new Promise((_resolve, reject) => - options?.signal?.addEventListener( - "abort", - () => reject(asError(options.signal?.reason)), - { once: true }, - ), - ); - if (record.method === "tasks/cancel") { - cancelCalls += 1; - await new Promise((resolve) => { - finishCancel = resolve; - }); - return { kind: "result", result: { resultType: "complete" } }; - } - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { - tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), - }, - }); - const execution = await session.callTool("x"); - const waiter = new AbortController(); - const first = execution.cancel(waiter.signal); - waiter.abort(new Error("waiter stopped")); - await expect(first).rejects.toThrow("waiter stopped"); - const second = execution.cancel(); - finishCancel?.(); - await expect(second).resolves.toBeUndefined(); - expect(cancelCalls).toBe(1); - await execution.close(); - await session.close(); - }); - - it("identifies unsupported V1 cancellation without dispatching it", async () => { - const port = new FakePort({ - generation: "v1", - capabilities: { requests: { tools: { call: {} } } }, - }); - const tool: ToolV1 = { - name: "x", - inputSchema: { type: "object" }, - execution: { taskSupport: "required" }, - }; - port.dispatchHandler = async (request, options) => { - const record = expectRecord(request); - if (record.method === "tools/call") - return { - kind: "result", - result: asJson({ - task: { - taskId: "no-cancel", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttl: null, - }, - }), - }; - if (record.method === "tasks/get") - return new Promise((_resolve, reject) => - options?.signal?.addEventListener( - "abort", - () => reject(asError(options.signal?.reason)), - { once: true }, - ), - ); - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const session = withTasks(port, { tools: { currentTool: () => tool } }); - const execution = await session.callTool("x"); - await expect(execution.cancel()).rejects.toBeInstanceOf( - TaskCancellationUnsupportedError, - ); - expect( - port.requests.some( - (request) => expectRecord(request).method === "tasks/cancel", - ), - ).toBe(false); - await execution.close(); - await expect(execution.result()).rejects.toBeInstanceOf( - TaskExecutionClosedError, - ); - await session.close(); - }); -}); - -describe("task reference resumption", () => { - it("does not expose reference serialization on immediate executions", async () => { - const port = new FakePort(); - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - }); - const execution = await session.callTool("immediate"); - expect(execution.kind).toBe("immediate"); - expect("serializeReference" in execution).toBe(false); - await session.close(); - }); - - it("rejects endpoint, generation, and operation mismatches before dispatch", async () => { - await fc.assert( - fc.asyncProperty( - fc.constantFrom("endpoint", "generation", "operation"), - fc.string({ minLength: 1 }), - async (mismatch, suffix) => { - const port = new FakePort( - { generation: "v2", capabilities: {} }, - "endpoint-a", - ); - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - }); - const reference = { - endpointId: - mismatch === "endpoint" ? `other-${suffix}` : "endpoint-a", - generation: mismatch === "generation" ? "v1" : "v2", - taskId: `task-${suffix}`, - originalOperation: - mismatch === "operation" ? "unsupported/operation" : "tools/call", - } as SerializedTaskReference; - await expect(session.resumeTask(reference)).rejects.toThrow(); - expect(port.requests).toHaveLength(0); - await session.close(); - }, - ), - { numRuns: 20 }, - ); - }); - - it("labels resumed V1 candidates without inventing a tool name", async () => { - const port = new FakePort( - { - generation: "v1", - capabilities: { requests: { tools: { call: {} } }, cancel: {} }, - }, - "resume-endpoint", - ); - let finishOrdinary: ((response: JsonRpcResponse) => void) | undefined; - let getCalls = 0; - port.dispatchHandler = async (request, options) => { - const record = expectRecord(request); - if (record.method === "tasks/get") { - getCalls += 1; - if (getCalls === 1) - return { - kind: "result", - result: asJson({ - taskId: "resumed-task", - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttl: null, - }), - }; - return new Promise((_resolve, reject) => - options?.signal?.addEventListener( - "abort", - () => reject(asError(options.signal?.reason)), - { once: true }, - ), - ); - } - if (record.method === "tools/call") - return new Promise((resolve) => { - finishOrdinary = resolve; - }); - if (record.method === "tasks/cancel") - return { - kind: "result", - result: asJson({ - taskId: "resumed-task", - status: "cancelled", - createdAt: "a", - lastUpdatedAt: "b", - ttl: null, - }), - }; - throw new Error(`unexpected method ${formatJson(record.method)}`); - }; - const errors: Error[] = []; - const session = withTasks(port, { - tools: { - currentTool: (name) => - name === "ordinary" - ? { name, inputSchema: { type: "object" } } - : undefined, - }, - onError: (error) => errors.push(error), - }); - const resumed = await session.resumeTask({ - endpointId: port.endpointId, - generation: "v1", - taskId: "resumed-task" as TaskId, - originalOperation: "tools/call", - }); - const ordinary = session.callTool("ordinary"); - while (finishOrdinary === undefined) await Promise.resolve(); - await port.serve({ method: "elicitation/create", params: {} }); - expect(errors).toHaveLength(1); - const candidates = (errors[0] as InputCorrelationError).candidates; - expect(candidates.map((candidate) => candidate.toolName)).toEqual([ - "ordinary", - "", - ]); - expect(candidates.every((candidate) => !("taskId" in candidate))).toBe( - true, - ); - finishOrdinary({ kind: "result", result: { content: [] } }); - await ordinary; - await resumed.close(); - await session.close(); - }); - - it("roundtrips serialized task references across V1/V2 terminal and nonterminal tasks", async () => { - await fc.assert( - fc.asyncProperty( - fc.constantFrom("v1", "v2"), - fc.boolean(), - fc.stringMatching(/^[a-z0-9]{1,12}$/), - async (generation, initiallyTerminal, taskSuffix) => { - const taskId = `task-${taskSuffix}`; - const endpointId = `endpoint-${taskSuffix}`; - const capabilities: SessionTaskCapabilities = - generation === "v1" - ? { - generation: "v1", - capabilities: { - requests: { tools: { call: {} } }, - cancel: {}, - }, - } - : { generation: "v2", capabilities: {} }; - const sourcePort = new FakePort(capabilities, endpointId); - sourcePort.dispatchHandler = async (request) => { - await Promise.resolve(); - const method = expectRecord(request).method; - if (method === "tools/call") - return generation === "v1" - ? { - kind: "result", - result: asJson({ - task: { - taskId, - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttl: null, - pollInterval: 1000, - }, - }), - } - : { - kind: "result", - result: asJson({ - resultType: "task", - taskId, - status: "working", - createdAt: "a", - lastUpdatedAt: "a", - ttlMs: null, - pollIntervalMs: 1000, - }), - }; - if (method === "tasks/cancel") - return { - kind: "result", - result: asJson( - generation === "v2" ? { resultType: "complete" } : {}, - ), - }; - throw new Error(`unexpected source method ${formatJson(method)}`); - }; - const sourceSession = withTasks(sourcePort, { - tools: { - currentTool: () => - generation === "v1" - ? { - name: "roundtrip", - inputSchema: {}, - execution: { taskSupport: "required" }, - } - : { name: "roundtrip", inputSchema: {} }, - }, - }); - const sourceExecution = await sourceSession.callTool("roundtrip"); - expect(sourceExecution.kind).toBe("task"); - if (sourceExecution.kind !== "task") throw new Error("expected task"); - const reference = sourceExecution.serializeReference(); - expect(reference).toEqual({ - endpointId, - generation, - taskId, - originalOperation: "tools/call", - }); - - const resumedPort = new FakePort(capabilities, endpointId); - let getCalls = 0; - resumedPort.dispatchHandler = async (request) => { - await Promise.resolve(); - const method = expectRecord(request).method; - if (method === "tasks/get") { - getCalls += 1; - const terminal = initiallyTerminal || getCalls > 1; - return generation === "v1" - ? { - kind: "result", - result: asJson({ - taskId, - status: terminal ? "completed" : "working", - createdAt: "a", - lastUpdatedAt: terminal ? "b" : "a", - ttl: null, - pollInterval: 0, - }), - } - : { - kind: "result", - result: asJson({ - resultType: "complete", - taskId, - status: terminal ? "completed" : "working", - createdAt: "a", - lastUpdatedAt: terminal ? "b" : "a", - ttlMs: null, - pollIntervalMs: 0, - ...(terminal - ? { result: { resultType: "complete", content: [] } } - : {}), - }), - }; - } - if (method === "tasks/result") - return { - kind: "result", - result: asJson({ - content: [{ type: "text", text: taskSuffix }], - }), - }; - if (method === "tasks/cancel") - return { - kind: "result", - result: asJson( - generation === "v2" ? { resultType: "complete" } : {}, - ), - }; - throw new Error(`unexpected resumed method ${formatJson(method)}`); - }; - const applicationContext = { taskSuffix }; - const resumedSession = withTasks( - resumedPort, - { - tools: { currentTool: () => undefined }, - }, - ); - const resumed = await resumedSession.resumeTask(reference, { - applicationContext, - }); - expect(resumed.kind).toBe("task"); - if (resumed.kind !== "task") throw new Error("expected resumed task"); - expect(resumed.applicationContext).toBe(applicationContext); - expect(resumed.serializeReference()).toEqual(reference); - await expect(resumed.result()).resolves.toEqual( - generation === "v1" - ? { content: [{ type: "text", text: taskSuffix }] } - : { resultType: "complete", content: [] }, - ); - expect(getCalls).toBe(initiallyTerminal ? 1 : 2); - const firstRequest = expectRecord(resumedPort.requests[0]); - expect(firstRequest.method).toBe("tasks/get"); - if (generation === "v2") - expect(firstRequest.params).toMatchObject({ - _meta: { - "io.modelcontextprotocol/clientCapabilities": { - extensions: { "io.modelcontextprotocol/tasks": {} }, - }, - }, - }); - expect( - resumedPort.requests.some( - (request) => expectRecord(request).method === "tasks/result", - ), - ).toBe(generation === "v1"); - await resumedSession.close(); - await sourceSession.close(); - }, - ), - { numRuns: 12 }, - ); - }); - - it("retries the initial resumed observation once for any DispatchError", async () => { - await fc.assert( - fc.asyncProperty(fc.boolean(), async (retryable) => { - const port = new FakePort({ generation: "v2", capabilities: {} }); - let calls = 0; - port.dispatchHandler = async () => { - await Promise.resolve(); - calls += 1; - if (calls === 1) - throw new DispatchError("initial get failed", retryable); - return { - kind: "result", - result: asJson({ - resultType: "complete", - taskId: "retry-resume", - status: "completed", - createdAt: "a", - lastUpdatedAt: "b", - ttlMs: null, - result: { resultType: "complete", content: [] }, - }), - }; - }; - const session = withTasks(port, { - tools: { currentTool: () => undefined }, - }); - const execution = await session.resumeTask({ - endpointId: port.endpointId, - generation: "v2", - taskId: "retry-resume" as TaskId, - originalOperation: "tools/call", - }); - await expect(execution.result()).resolves.toMatchObject({ - content: [], - }); - expect(calls).toBe(2); - await session.close(); - }), - { numRuns: 10 }, - ); - }); -}); - -describe("Client session integration", () => { - const client = () => new Client({ name: "test", version: "1" }); - const context = { - mcpReq: { - id: 1, - method: "custom/request", - requestState: () => undefined, - signal: new AbortController().signal, - send: vi.fn(), - notify: vi.fn(), - }, - } satisfies ClientContext; - - it("dispatches with an explicit schema and signal, preserving full protocol errors", async () => { - const sdk = client(); - const request = vi.spyOn(sdk, "request"); - const port = createSessionPortFromClient(sdk, "endpoint-sdk"); - const controller = new AbortController(); - request.mockResolvedValueOnce({ ok: true }); - await expect( - port.dispatch( - { method: "custom/method", params: { value: 1 } }, - { signal: controller.signal }, - ), - ).resolves.toEqual({ kind: "result", result: { ok: true } }); - const schema: unknown = request.mock.calls[0]?.[1]; - expect(schema).toBeTypeOf("object"); - expect(schema).toHaveProperty("~standard"); - expect(request.mock.calls[0]?.[2]).toEqual({ signal: controller.signal }); - request.mockRejectedValueOnce( - new ProtocolError(-32001, "denied", { retry: false }), - ); - await expect(port.dispatch({ method: "custom/method" })).resolves.toEqual({ - kind: "error", - error: { code: -32001, message: "denied", data: { retry: false } }, - }); - }); - - it("wraps cancellation and local SDK failures as non-retryable DispatchError", async () => { - const sdk = client(); - const request = vi.spyOn(sdk, "request"); - const port = createSessionPortFromClient(sdk, "endpoint-sdk"); - for (const failure of [ - new DOMException("cancelled", "AbortError"), - new SdkError(SdkErrorCode.ConnectionClosed, "closed"), - ]) { - request.mockRejectedValueOnce(failure); - await expect( - port.dispatch({ method: "custom/method" }), - ).rejects.toMatchObject({ - name: "DispatchError", - retryable: false, - cause: failure, - }); - } - }); - - it("derives immutable legacy, modern, and absent task capabilities", () => { - const legacy = client(); - const legacyCapabilities = { tasks: { cancel: {}, list: {} } }; - vi.spyOn(legacy, "getProtocolEra").mockReturnValue("legacy"); - vi.spyOn(legacy, "getServerCapabilities").mockReturnValue( - legacyCapabilities, - ); - const legacyPort = createSessionPortFromClient(legacy, "legacy"); - expect(legacyPort.endpointId).toBe("legacy"); - expect(legacyPort.taskCapabilities).toEqual({ - generation: "v1", - capabilities: { cancel: {}, list: {} }, - }); - legacyCapabilities.tasks.cancel = { changed: true }; - expect(legacyPort.taskCapabilities).toEqual({ - generation: "v1", - capabilities: { cancel: {}, list: {} }, - }); - const modern = client(); - vi.spyOn(modern, "getProtocolEra").mockReturnValue("modern"); - vi.spyOn(modern, "getServerCapabilities").mockReturnValue({ - extensions: { "io.modelcontextprotocol/tasks": {} }, - }); - expect( - createSessionPortFromClient(modern, "modern").taskCapabilities, - ).toEqual({ generation: "v2", capabilities: {} }); - const absent = client(); - vi.spyOn(absent, "getProtocolEra").mockReturnValue("modern"); - vi.spyOn(absent, "getServerCapabilities").mockReturnValue({ - extensions: {}, - }); - expect( - createSessionPortFromClient(absent, "none").taskCapabilities, - ).toEqual({ generation: "none" }); - }); - - it("forwards inbound requests and settles results and full errors", async () => { - const sdk = client(); - const port = createSessionPortFromClient(sdk, "endpoint-sdk"); - const disposeResult = port.onServerRequest((incoming) => - Promise.resolve({ - kind: "result", - result: { echoed: incoming.request }, - }), - ); - await expect( - sdk.fallbackRequestHandler?.( - { - jsonrpc: "2.0", - id: 1, - method: "elicitation/create", - params: {}, - }, - context, - ), - ).resolves.toEqual({ - echoed: { - jsonrpc: "2.0", - id: 1, - method: "elicitation/create", - params: {}, - }, - }); - disposeResult(); - const disposeError = port.onServerRequest(() => - Promise.resolve({ - kind: "error", - error: { code: -32002, message: "failed", data: { reason: "x" } }, - }), - ); - await expect( - sdk.fallbackRequestHandler?.( - { jsonrpc: "2.0", id: 2, method: "elicitation/create", params: {} }, - context, - ), - ).rejects.toMatchObject({ - code: -32002, - message: "failed", - data: { reason: "x" }, - }); - disposeError(); - }); - - it("chains prior fallbacks, forwards notifications, invalidates on close, and cleans up", async () => { - const sdk = client(); - const priorRequest = vi.fn(() => Promise.resolve({ prior: true })); - const priorNotification = vi.fn(() => Promise.resolve()); - const priorClose = vi.fn(); - sdk.fallbackRequestHandler = priorRequest; - sdk.fallbackNotificationHandler = priorNotification; - sdk.onclose = priorClose; - const port = createSessionPortFromClient(sdk, "endpoint-sdk"); - const installedRequest = sdk.fallbackRequestHandler; - const installedNotification = sdk.fallbackNotificationHandler; - const installedClose = sdk.onclose; - const notifications: JsonValue[] = []; - const invalidations: unknown[] = []; - const removeNotification = port.onNotification((value) => - notifications.push(value), - ); - const removeInvalidation = port.onInvalidated((reason) => - invalidations.push(reason), - ); - await expect( - installedRequest?.({ jsonrpc: "2.0", id: 1, method: "other" }, context), - ).resolves.toEqual({ prior: true }); - await installedNotification?.({ - method: "custom/notification", - params: { value: 1 }, - }); - expect(priorNotification).toHaveBeenCalledOnce(); - expect(notifications).toEqual([ - { method: "custom/notification", params: { value: 1 } }, - ]); - removeNotification(); - await installedNotification?.({ - method: "custom/notification", - params: { value: 2 }, - }); - expect(notifications).toHaveLength(1); - installedClose?.(); - expect(priorClose).toHaveBeenCalledOnce(); - expect(port.invalidated).toBe(true); - expect(invalidations).toHaveLength(1); - removeInvalidation(); - port[Symbol.dispose](); - expect(sdk.fallbackRequestHandler).toBe(priorRequest); - expect(sdk.fallbackNotificationHandler).toBe(priorNotification); - expect(sdk.onclose).toBe(priorClose); - }); - - it("does not overwrite callbacks installed after adaptation", () => { - const sdk = client(); - const port = createSessionPortFromClient(sdk, "endpoint-sdk"); - const replacement = vi.fn(() => Promise.resolve({ replacement: true })); - sdk.fallbackRequestHandler = replacement; - port[Symbol.dispose](); - expect(sdk.fallbackRequestHandler).toBe(replacement); - }); - - it("rejects concurrent adapters and permits reuse after disposal", () => { - const sdk = client(); - const first = createSessionPortFromClient(sdk, "endpoint-sdk"); - expect(() => createSessionPortFromClient(sdk, "endpoint-sdk")).toThrow( - "already active", - ); - first[Symbol.dispose](); - const replacement = createSessionPortFromClient(sdk, "endpoint-sdk"); - replacement[Symbol.dispose](); - }); - - it("accepts Client-compatible objects from another constructor", async () => { - class ForeignClient { - fallbackRequestHandler: Client["fallbackRequestHandler"]; - fallbackNotificationHandler: Client["fallbackNotificationHandler"]; - onclose: Client["onclose"]; - readonly request = vi.fn(() => Promise.resolve({ content: [] })); - getProtocolEra(): ReturnType { - return "legacy"; - } - getServerCapabilities(): ReturnType { - return {}; - } - } - const foreign = new ForeignClient(); - const session = withTasks(foreign as unknown as Client, { - endpointId: "foreign-client", - tools: { currentTool: () => undefined }, - }); - const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toEqual({ content: [] }); - expect(foreign.request).toHaveBeenCalled(); - await session.close(); - }); - - it("supports Client sessions through withTasks and restores callbacks", async () => { - const sdk = client(); - const request = vi.spyOn(sdk, "request").mockResolvedValue({ content: [] }); - const prior = vi.fn(() => Promise.resolve({ prior: true })); - sdk.fallbackRequestHandler = prior; - const session = withTasks(sdk, { - endpointId: "raw-client", - tools: { currentTool: () => undefined }, - }); - const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toEqual({ content: [] }); - expect(request).toHaveBeenCalledWith( - { method: "tools/call", params: { name: "x" } }, - expect.any(Object), - expect.any(Object), - ); - await expect( - sdk.fallbackRequestHandler?.( - { jsonrpc: "2.0", id: 9, method: "custom/unrelated" }, - context, - ), - ).resolves.toEqual({ prior: true }); - expect(prior).toHaveBeenCalledWith( - { jsonrpc: "2.0", id: 9, method: "custom/unrelated" }, - context, - ); - await session.close(); - expect(sdk.fallbackRequestHandler).toBe(prior); - expect(sdk.transport).toBeUndefined(); - }); - - it("restores Client ownership when an earlier close disposer fails", async () => { - const sdk = client(); - const prior = vi.fn(() => Promise.resolve({ prior: true })); - sdk.fallbackRequestHandler = prior; - const controller = new AbortController(); - const sentinel = new Error("listener cleanup failed"); - vi.spyOn(controller.signal, "removeEventListener").mockImplementation( - () => { - throw sentinel; - }, - ); - const session = withTasks(sdk, { - endpointId: "close-failure", - signal: controller.signal, - tools: { currentTool: () => undefined }, - }); - await expect(session.close()).rejects.toBe(sentinel); - await expect(session.close()).rejects.toBe(sentinel); - expect(sdk.fallbackRequestHandler).toBe(prior); - const replacement = createSessionPortFromClient(sdk, "close-failure"); - replacement[Symbol.dispose](); - }); -}); diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts index da62ab4..c04338b 100644 --- a/packages/ext-tasks/src/client/index.ts +++ b/packages/ext-tasks/src/client/index.ts @@ -1,2359 +1,37 @@ /** Requester-side MCP Tasks session and execution support. */ -import { - Client, - ProtocolError, - type StandardSchemaV1, -} from "@modelcontextprotocol/client"; -import { - isJsonValue, - isJsonArray, - type JsonValue, - type RuntimeCodec, - type TaskGeneration, - type TaskId, - type TaskSnapshot, -} from "../core/index.js"; -import { - CallToolResultV1Codec, - CancelTaskResultV1Codec, - CreateTaskResultV1Codec, - GetTaskResultV1Codec, - TaskResultV1Codec, - TaskStatusNotificationV1Codec, - ToolV1Codec, - shouldCallToolAsTaskV1, - type CallToolResultV1, - type ServerTaskCapabilitiesV1, - type TaskEligibleMethodV1, - type TaskV1, - type ToolV1, -} from "../core/v1/index.js"; -import { - CallToolResultV2Codec, - CancelTaskResultV2Codec, - CreateMessageResultV2Codec, - CreateTaskResultV2Codec, - ElicitResultV2Codec, - GetTaskResultV2Codec, - ListRootsResultV2Codec, - TaskStatusNotificationV2Codec, - ToolV2Codec, - UpdateTaskResultV2Codec, - isCreateTaskResultV2, - withTaskCapabilityV2, - type CallToolResultV2, - type DetailedTaskV2, - type ErrorV2, - type InputRequestV2, - type InputResponseV2, - type TaskExtensionCapabilitiesV2, - type TaskEligibleMethodV2, - type ToolV2, -} from "../core/v2/index.js"; - -export type SessionTaskCapabilities = - | { readonly generation: "none" } - | { - readonly generation: "v1"; - readonly capabilities: ServerTaskCapabilitiesV1; - } - | { - readonly generation: "v2"; - readonly capabilities: TaskExtensionCapabilitiesV2; - }; - -export type JsonRpcResponse = - | { readonly kind: "result"; readonly result: JsonValue } - | { readonly kind: "error"; readonly error: ErrorV2 }; - -export interface IncomingServerRequest { - readonly request: JsonValue; - readonly requestContext: unknown; -} - -export interface ConnectedMcpSessionPort { - readonly endpointId: string; - readonly taskCapabilities: SessionTaskCapabilities; - dispatch( - request: JsonValue, - options?: { readonly signal?: AbortSignal }, - ): Promise; - onServerRequest( - handler: (incoming: IncomingServerRequest) => Promise, - ): () => void; - onNotification(listener: (notification: JsonValue) => void): () => void; - onInvalidated(listener: (reason: unknown) => void): () => void; - readonly invalidated: boolean; -} - -const jsonValueSchema: StandardSchemaV1 = { - "~standard": { - version: 1, - vendor: "@modelcontextprotocol/ext-tasks", - validate(value) { - return isJsonValue(value) - ? { value } - : { issues: [{ message: "Expected a JSON value" }] }; - }, - }, -}; - -function isJsonRecord( - value: unknown, -): value is Readonly> { - return ( - isJsonValue(value) && - value !== null && - !Array.isArray(value) && - typeof value === "object" - ); -} - -function clientTaskCapabilities( - client: ClientPublicSurface, -): SessionTaskCapabilities { - const capabilities = client.getServerCapabilities(); - if (client.getProtocolEra() === "modern") { - const extension = - capabilities?.extensions?.["io.modelcontextprotocol/tasks"]; - if ( - extension !== null && - typeof extension === "object" && - !Array.isArray(extension) && - Object.keys(extension).length === 0 - ) - return { generation: "v2", capabilities: {} }; - return { generation: "none" }; - } - const tasks = capabilities?.tasks; - return tasks === undefined - ? { generation: "none" } - : { generation: "v1", capabilities: structuredClone(tasks) }; -} - -function asClientRequest(request: JsonValue): { - readonly method: string; - readonly params?: Readonly>; -} { - if (!isJsonRecord(request)) - throw new DispatchError("MCP request must be a JSON object"); - const method = request.method; - if (typeof method !== "string") - throw new DispatchError("MCP request method must be a string"); - const params = request.params; - if (params === undefined) return { method }; - if (!isJsonRecord(params)) - throw new DispatchError("MCP request params must be a JSON object"); - return { method, params }; -} - -function isTaskInputMethod(method: string): boolean { - return ( - method === "elicitation/create" || - method === "sampling/createMessage" || - method === "roots/list" - ); -} - -type ClientPublicSurface = Pick< - Client, - | "request" - | "getProtocolEra" - | "getServerCapabilities" - | "fallbackRequestHandler" - | "fallbackNotificationHandler" - | "onclose" ->; - -const adaptedClients = new WeakSet(); - -function isConnectedMcpSessionPort( - value: unknown, -): value is ConnectedMcpSessionPort { - if (value === null || typeof value !== "object") return false; - const candidate = value as Partial; - return ( - typeof candidate.endpointId === "string" && - candidate.taskCapabilities !== undefined && - typeof candidate.dispatch === "function" && - typeof candidate.onServerRequest === "function" && - typeof candidate.onNotification === "function" && - typeof candidate.onInvalidated === "function" && - typeof candidate.invalidated === "boolean" - ); -} - -function isClientPublicSurface(value: unknown): value is ClientPublicSurface { - if (value === null || typeof value !== "object") return false; - const candidate = value as Partial; - return ( - typeof candidate.request === "function" && - typeof candidate.getProtocolEra === "function" && - typeof candidate.getServerCapabilities === "function" - ); -} - -class ClientSessionPort implements ConnectedMcpSessionPort { - readonly taskCapabilities: SessionTaskCapabilities; - private readonly serverRequestListeners = new Set< - (incoming: IncomingServerRequest) => Promise - >(); - private readonly notificationListeners = new Set< - (notification: JsonValue) => void - >(); - private readonly invalidationListeners = new Set<(reason: unknown) => void>(); - private readonly previousFallbackRequestHandler: ClientPublicSurface["fallbackRequestHandler"]; - private readonly previousFallbackNotificationHandler: ClientPublicSurface["fallbackNotificationHandler"]; - private readonly previousOnclose: ClientPublicSurface["onclose"]; - private disposed = false; - private isInvalidated = false; - - private readonly fallbackRequestHandler: NonNullable< - ClientPublicSurface["fallbackRequestHandler"] - > = async (request, context) => { - if (!isTaskInputMethod(request.method)) { - if (this.previousFallbackRequestHandler !== undefined) - return this.previousFallbackRequestHandler(request, context); - throw new ProtocolError(-32601, `Method not found: ${request.method}`); - } - const listener = this.serverRequestListeners.values().next().value; - if (listener === undefined) { - if (this.previousFallbackRequestHandler !== undefined) - return this.previousFallbackRequestHandler(request, context); - throw new ProtocolError(-32601, `Method not found: ${request.method}`); - } - if (!isJsonValue(request)) - throw new ProtocolError(-32600, "Inbound request is not JSON"); - const response = await listener({ request, requestContext: context }); - if (response.kind === "error") - throw new ProtocolError( - response.error.code, - response.error.message, - response.error.data, - ); - if (!isJsonRecord(response.result)) - throw new ProtocolError( - -32603, - "Inbound handler returned a non-object result", - ); - return response.result; - }; - - private readonly fallbackNotificationHandler: NonNullable< - ClientPublicSurface["fallbackNotificationHandler"] - > = async (notification) => { - await this.previousFallbackNotificationHandler?.(notification); - if (!isJsonValue(notification)) return; - for (const listener of [...this.notificationListeners]) - listener(notification); - }; - - private readonly onclose = (): void => { - try { - this.previousOnclose?.(); - } finally { - this.invalidate(new Error("MCP client connection closed")); - } - }; - - constructor( - private readonly client: ClientPublicSurface, - readonly endpointId: string, - ) { - if (adaptedClients.has(client)) - throw new TypeError( - "An ext-tasks adapter is already active for this Client", - ); - this.previousFallbackRequestHandler = client.fallbackRequestHandler; - this.previousFallbackNotificationHandler = - client.fallbackNotificationHandler; - this.previousOnclose = client.onclose; - this.taskCapabilities = clientTaskCapabilities(client); - adaptedClients.add(client); - client.fallbackRequestHandler = this.fallbackRequestHandler; - client.fallbackNotificationHandler = this.fallbackNotificationHandler; - client.onclose = this.onclose; - } - - get invalidated(): boolean { - return this.isInvalidated; - } - - async dispatch( - request: JsonValue, - options: { readonly signal?: AbortSignal } = {}, - ): Promise { - try { - const result = await this.client.request( - asClientRequest(request), - jsonValueSchema, - options.signal === undefined ? {} : { signal: options.signal }, - ); - return { kind: "result", result }; - } catch (error) { - if (error instanceof ProtocolError) { - const data = error.data; - return { - kind: "error", - error: { - code: error.code, - message: error.message, - ...(data === undefined || !isJsonValue(data) ? {} : { data }), - }, - }; - } - throw new DispatchError("MCP client request failed", false, { - cause: error, - }); - } - } - - onServerRequest( - handler: (incoming: IncomingServerRequest) => Promise, - ): () => void { - this.serverRequestListeners.add(handler); - return () => this.serverRequestListeners.delete(handler); - } - - onNotification(listener: (notification: JsonValue) => void): () => void { - this.notificationListeners.add(listener); - return () => this.notificationListeners.delete(listener); - } - - onInvalidated(listener: (reason: unknown) => void): () => void { - this.invalidationListeners.add(listener); - return () => this.invalidationListeners.delete(listener); - } - - [Symbol.dispose](): void { - if (this.disposed) return; - this.disposed = true; - if (this.client.fallbackRequestHandler === this.fallbackRequestHandler) - this.client.fallbackRequestHandler = this.previousFallbackRequestHandler; - if ( - this.client.fallbackNotificationHandler === - this.fallbackNotificationHandler - ) - this.client.fallbackNotificationHandler = - this.previousFallbackNotificationHandler; - if (this.client.onclose === this.onclose) - this.client.onclose = this.previousOnclose; - adaptedClients.delete(this.client); - this.serverRequestListeners.clear(); - this.notificationListeners.clear(); - this.invalidationListeners.clear(); - } - - private invalidate(reason: unknown): void { - if (this.isInvalidated) return; - this.isInvalidated = true; - for (const listener of [...this.invalidationListeners]) listener(reason); - } -} - -export function createSessionPortFromClient( - client: Client, - endpointId: string, -): ConnectedMcpSessionPort & Disposable { - return new ClientSessionPort(client, endpointId); -} - -export class DispatchError extends Error { - readonly retryable: boolean; - - constructor(message: string, retryable = false, options?: ErrorOptions) { - super(message, options); - this.name = "DispatchError"; - this.retryable = retryable; - } -} - -export class JsonRpcResponseError extends Error { - readonly code: number; - readonly data?: JsonValue; - readonly response: ErrorV2; - - constructor(error: ErrorV2, options?: ErrorOptions) { - super(error.message, options); - this.name = "JsonRpcResponseError"; - this.code = error.code; - if (error.data !== undefined) this.data = error.data; - this.response = error; - } -} - -export interface ToolDeclarationProvider { - currentTool(name: string): - | { - readonly name: string; - readonly inputSchema: Readonly>; - readonly execution?: { - readonly taskSupport?: "forbidden" | "optional" | "required"; - }; - } - | undefined; -} - -export type ApplicationInputRequest = - | { - readonly kind: "elicitation"; - readonly params: Readonly>; - } - | { - readonly kind: "sampling"; - readonly params: Readonly>; - } - | { - readonly kind: "roots"; - readonly params?: Readonly>; - }; - -export interface ApplicationElicitResult { - readonly action: "accept" | "decline" | "cancel"; - readonly content?: Readonly>; -} - -export type ApplicationCreateMessageResult = Readonly< - Record -> & { - readonly model: string; - readonly role: "assistant" | "user"; - readonly content: JsonValue; -}; - -export interface ApplicationListRootsResult { - readonly roots: readonly Readonly>[]; -} - -export type ApplicationInputResult = - TRequest extends { readonly kind: "elicitation" } - ? ApplicationElicitResult - : TRequest extends { readonly kind: "sampling" } - ? ApplicationCreateMessageResult - : TRequest extends { readonly kind: "roots" } - ? ApplicationListRootsResult - : never; - -export type ResolvedInputExchangeContext = - | { - readonly lifetime: "basic"; - readonly executionId: string; - readonly applicationContext: TApplicationContext; - readonly signal?: AbortSignal; - } - | { - readonly lifetime: "task-v1"; - readonly taskId: string; - readonly applicationContext: TApplicationContext; - readonly signal?: AbortSignal; - } - | { - readonly lifetime: "task-v2"; - readonly taskId: string; - readonly inputKey: string; - readonly applicationContext: TApplicationContext; - readonly signal?: AbortSignal; - }; - -export interface ApplicationInputHandler { - handle( - request: TRequest, - context: ResolvedInputExchangeContext, - ): Promise>; -} - -export type InputCorrelationFailureReason = - | "missing-evidence" - | "invalid-evidence" - | "zero-matches" - | "ambiguous-matches"; - -export interface InputCorrelationCandidate { - readonly generation: TaskGeneration; - readonly toolName: string; - readonly executionId: string; - readonly applicationContext: TApplicationContext; -} - -export class InputCorrelationError extends Error { - constructor( - readonly generation: TaskGeneration, - readonly requestKind: ApplicationInputRequest["kind"], - readonly candidates: readonly InputCorrelationCandidate[], - readonly reason: InputCorrelationFailureReason, - ) { - super(`Input request correlation failed: ${reason}`); - this.name = "InputCorrelationError"; - } -} - -export interface WithTasksOptions { - readonly tools?: ToolDeclarationProvider; - readonly onInputRequest?: ApplicationInputHandler["handle"]; - readonly onError?: (error: Error) => void; - readonly signal?: AbortSignal; -} - +export { + InputCorrelationError, + JsonRpcResponseError, + TaskCancellationUnsupportedError, + TaskExecutionClosedError, + TaskUpdatesAlreadyAcquiredError, +} from "./api.js"; +export type { + ApplicationCreateMessageResult, + ApplicationElicitResult, + ApplicationInputHandler, + ApplicationInputRequest, + ApplicationInputResult, + ApplicationListRootsResult, + InputCorrelationCandidate, + InputCorrelationFailureReason, + ResolvedInputExchangeContext, + SerializedTaskReference, + TaskEnabledSession, + TaskHandle, + ToolDeclarationProvider, + ToolExecution, + ToolExecutionCommon, + WithTasksOptions, +} from "./api.js"; +export { DispatchError } from "./port.js"; +export type { + ConnectedMcpSessionPort, + IncomingServerRequest, + JsonRpcResponse, + SessionTaskCapabilities, +} from "./port.js"; +export { createSessionPortFromClient } from "./sdk-client-adapter.js"; +export { withTasks } from "./session.js"; export type { TaskEligibleMethodV2 } from "../core/v2/index.js"; - -export type TaskHandle = - | { - readonly generation: "v1"; - readonly taskId: TaskId; - readonly originalOperation: TaskEligibleMethodV1; - } - | { - readonly generation: "v2"; - readonly taskId: TaskId; - readonly originalOperation: TaskEligibleMethodV2; - }; - -export interface ToolExecutionCommon { - readonly applicationContext: TApplicationContext; - updates(signal?: AbortSignal): AsyncIterable; - result(): Promise; - cancel(signal?: AbortSignal): Promise; - close(): Promise; - [Symbol.asyncDispose](): Promise; -} - -export type ToolExecution = - | (ToolExecutionCommon & { - readonly kind: "immediate"; - readonly handle?: undefined; - }) - | (ToolExecutionCommon & { - readonly kind: "task"; - readonly handle: TaskHandle; - serializeReference(): SerializedTaskReference; - }); - -export class TaskUpdatesAlreadyAcquiredError extends Error { - constructor() { - super("Task updates have already been acquired"); - this.name = "TaskUpdatesAlreadyAcquiredError"; - } -} - -export class TaskExecutionClosedError extends Error { - constructor() { - super("Task execution is closed"); - this.name = "TaskExecutionClosedError"; - } -} - -export class TaskCancellationUnsupportedError extends Error { - constructor() { - super("Task cancellation is not supported"); - this.name = "TaskCancellationUnsupportedError"; - } -} - -export interface TaskEnabledSession { - callTool( - name: string, - params?: Readonly>, - options?: { - readonly resultCodec?: RuntimeCodec; - readonly applicationContext?: TApplicationContext; - readonly signal?: AbortSignal; - readonly preferTask?: boolean; - }, - ): Promise>; - resumeTask( - reference: SerializedTaskReference, - options?: { - readonly resultCodec?: RuntimeCodec; - readonly applicationContext?: TApplicationContext; - readonly signal?: AbortSignal; - }, - ): Promise>; - close(): Promise; - [Symbol.asyncDispose](): Promise; -} - -export type SerializedTaskReference = - | { - readonly endpointId: string; - readonly generation: "v1"; - readonly taskId: TaskId; - readonly originalOperation: TaskEligibleMethodV1; - } - | { - readonly endpointId: string; - readonly generation: "v2"; - readonly taskId: TaskId; - readonly originalOperation: TaskEligibleMethodV2; - }; - -function defaultResultCodec( - generation: SessionTaskCapabilities["generation"], -): RuntimeCodec { - return generation === "v2" ? CallToolResultV2Codec : CallToolResultV1Codec; -} - -function reasonAsError(reason: unknown): Error { - if (reason instanceof Error) return reason; - return new Error( - typeof reason === "string" ? reason : "MCP session was invalidated", - { cause: reason }, - ); -} - -const DEFAULT_TASK_POLL_INTERVAL_MS = 10; - -type TaskTurn = - { readonly sequence: number; readonly snapshot: TaskSnapshot } | undefined; - -type TaskDriver = ( - accept: (snapshot: TaskSnapshot) => void, - waitForTurn: ( - afterSequence: number, - delayMs: number | undefined, - ) => Promise, - observe: ( - afterSequence: number, - observation: (signal: AbortSignal) => Promise, - ) => Promise, - signal: AbortSignal, - cancelledError: Error, - closedError: Error, - isClosed: () => boolean, - inputSignal: AbortSignal, -) => Promise; - -class TaskExecution< - TResult, - TApplicationContext, -> implements ToolExecutionCommon { - readonly kind = "task" as const; - private readonly controller = new AbortController(); - private readonly inputController = new AbortController(); - private readonly cancellationController = new AbortController(); - private readonly resultPromise: Promise; - private readonly cancelledError = new Error("Task was cancelled"); - private readonly closedError = new TaskExecutionClosedError(); - private readonly notificationWaiters = new Set<() => void>(); - private readonly updateWaiters = new Set<() => void>(); - private initialSnapshot: TaskSnapshot | undefined; - private pendingSnapshot: TaskSnapshot | undefined; - private terminalSnapshot: TaskSnapshot | undefined; - private terminalSnapshotBytes: string | undefined; - private lastAcceptedBytes: string; - private notificationSequence = 0; - private latestNotification: TaskSnapshot | undefined; - private updatesAcquired = false; - private cancelPromise: Promise | undefined; - private closed = false; - - constructor( - readonly applicationContext: TApplicationContext, - readonly handle: TaskHandle, - private readonly endpointId: string, - initialSnapshot: TaskSnapshot, - driver: TaskDriver, - private readonly cancelTask: (signal?: AbortSignal) => Promise, - lifecycleSignal?: AbortSignal, - ) { - this.initialSnapshot = initialSnapshot; - if (terminalStatus(initialSnapshot.task.status)) - this.inputController.abort(); - this.lastAcceptedBytes = deterministicJson(initialSnapshot); - if (lifecycleSignal !== undefined) { - const abort = (): void => this.controller.abort(lifecycleSignal.reason); - if (lifecycleSignal.aborted) abort(); - else lifecycleSignal.addEventListener("abort", abort, { once: true }); - } - this.resultPromise = driver( - (snapshot) => this.accept(snapshot), - (afterSequence, delayMs) => this.waitForTurn(afterSequence, delayMs), - (afterSequence, observation) => - this.observeOrNotification(afterSequence, observation), - this.controller.signal, - this.cancelledError, - this.closedError, - () => this.closed, - this.inputController.signal, - ); - } - - serializeReference(): SerializedTaskReference { - return { endpointId: this.endpointId, ...this.handle }; - } - - onNotification(snapshot: TaskSnapshot): void { - if (this.closed || snapshot.generation !== this.handle.generation) return; - if (snapshot.task.taskId !== this.handle.taskId) return; - const bytes = deterministicJson(snapshot); - if (terminalStatus(snapshot.task.status)) { - this.inputController.abort(); - if (this.terminalSnapshotBytes === undefined) { - this.terminalSnapshot = snapshot; - this.terminalSnapshotBytes = bytes; - } - } else if (this.terminalSnapshotBytes !== undefined) { - return; - } - this.latestNotification = snapshot; - this.notificationSequence += 1; - for (const wake of this.notificationWaiters) wake(); - this.notificationWaiters.clear(); - for (const wake of this.updateWaiters) wake(); - this.updateWaiters.clear(); - } - - updates(signal?: AbortSignal): AsyncIterable { - if (this.updatesAcquired) throw new TaskUpdatesAlreadyAcquiredError(); - this.updatesAcquired = true; - return this.iterateUpdates(signal); - } - - private async *iterateUpdates( - signal?: AbortSignal, - ): AsyncIterable { - while (true) { - throwIfAborted(signal); - if (this.initialSnapshot !== undefined) { - const snapshot = this.initialSnapshot; - this.initialSnapshot = undefined; - yield snapshot; - continue; - } - if (this.pendingSnapshot !== undefined) { - const snapshot = this.pendingSnapshot; - this.pendingSnapshot = undefined; - yield snapshot; - continue; - } - if (this.terminalSnapshot !== undefined) { - const snapshot = this.terminalSnapshot; - this.terminalSnapshot = undefined; - yield snapshot; - continue; - } - const settled = await this.waitForUpdateOrResult(signal); - if ( - !settled && - this.initialSnapshot === undefined && - this.pendingSnapshot === undefined && - this.terminalSnapshot === undefined - ) - return; - } - } - - private accept(snapshot: TaskSnapshot): void { - if (this.closed) return; - const bytes = deterministicJson(snapshot); - if (bytes === this.lastAcceptedBytes) return; - this.lastAcceptedBytes = bytes; - if (terminalStatus(snapshot.task.status)) { - this.inputController.abort(); - if (bytes !== this.terminalSnapshotBytes) { - this.terminalSnapshot ??= snapshot; - this.terminalSnapshotBytes ??= bytes; - } - } else if (this.terminalSnapshotBytes === undefined) { - this.pendingSnapshot = snapshot; - } - for (const wake of this.updateWaiters) wake(); - this.updateWaiters.clear(); - } - - private async waitForUpdateOrResult(signal?: AbortSignal): Promise { - if ( - this.pendingSnapshot !== undefined || - this.terminalSnapshot !== undefined - ) - return true; - let wake: (() => void) | undefined; - const updated = new Promise((resolve) => { - wake = () => resolve(true); - this.updateWaiters.add(wake); - }); - try { - return await withAbort( - Promise.race([ - updated, - this.resultPromise.then( - () => false, - () => false, - ), - ]), - signal, - ); - } finally { - if (wake !== undefined) this.updateWaiters.delete(wake); - } - } - - private currentTurn(afterSequence: number): TaskTurn { - if ( - this.notificationSequence > afterSequence && - this.latestNotification !== undefined - ) { - return { - sequence: this.notificationSequence, - snapshot: this.latestNotification, - }; - } - return undefined; - } - - private async waitForTurn( - afterSequence: number, - delayMs: number | undefined, - ): Promise { - const current = this.currentTurn(afterSequence); - if (current !== undefined) return current; - if (delayMs === undefined) { - await Promise.resolve(); - throwIfAborted(this.controller.signal); - return this.currentTurn(afterSequence); - } - await new Promise((resolve, reject) => { - const finish = (error?: unknown): void => { - clearTimeout(timeout); - this.notificationWaiters.delete(onNotification); - this.controller.signal.removeEventListener("abort", onAbort); - if (error === undefined) resolve(); - else reject(reasonAsError(error)); - }; - const onNotification = (): void => finish(); - const onAbort = (): void => finish(this.controller.signal.reason); - const timeout = setTimeout(onNotification, Math.max(0, delayMs)); - this.notificationWaiters.add(onNotification); - this.controller.signal.addEventListener("abort", onAbort, { once: true }); - }); - return this.currentTurn(afterSequence); - } - - private async observeOrNotification( - afterSequence: number, - observation: (signal: AbortSignal) => Promise, - ): Promise { - const current = this.currentTurn(afterSequence); - if (current !== undefined) return current; - const observationLifecycle = linkAbortSignals(this.controller.signal); - const observationPromise = observation(observationLifecycle.signal); - void observationPromise.catch(() => {}); - let wake: (() => void) | undefined; - const notified = new Promise((resolve) => { - wake = () => resolve(this.currentTurn(afterSequence)); - this.notificationWaiters.add(wake); - }); - try { - return await withAbort( - Promise.race([ - observationPromise.then((snapshot) => ({ - sequence: afterSequence, - snapshot, - })), - notified, - ]), - this.controller.signal, - ); - } finally { - if (!observationLifecycle.signal.aborted) observationLifecycle.abort(); - observationLifecycle.dispose(); - if (wake !== undefined) this.notificationWaiters.delete(wake); - } - } - - result(): Promise { - return this.resultPromise; - } - - inputSignal(): AbortSignal { - return this.inputController.signal; - } - - endInputLifetime(): void { - this.inputController.abort(); - } - - cancel(signal?: AbortSignal): Promise { - throwIfAborted(signal); - this.cancelPromise ??= this.cancelTask(this.cancellationController.signal); - return signal === undefined - ? this.cancelPromise - : withAbort(this.cancelPromise, signal); - } - - close(): Promise { - if (!this.closed) { - this.closed = true; - this.controller.abort(this.closedError); - this.inputController.abort(this.closedError); - void this.cancel().catch(() => { - // Cooperative cancellation is best effort during close. - }); - } - return Promise.resolve(); - } - - [Symbol.asyncDispose](): Promise { - return this.close(); - } -} - -function deterministicJson(value: unknown): string { - if (value === null || typeof value !== "object") return JSON.stringify(value); - if (Array.isArray(value)) - return `[${value.map(deterministicJson).join(",")}]`; - const record = value as Readonly>; - return `{${Object.keys(record) - .sort() - .map((key) => `${JSON.stringify(key)}:${deterministicJson(record[key])}`) - .join(",")}}`; -} - -async function withAbort( - promise: Promise, - signal?: AbortSignal, -): Promise { - if (signal === undefined) return promise; - throwIfAborted(signal); - let onAbort: (() => void) | undefined; - const aborted = new Promise((_, reject) => { - onAbort = () => - reject( - signal.reason instanceof Error - ? signal.reason - : new DOMException("The operation was aborted", "AbortError"), - ); - signal.addEventListener("abort", onAbort, { once: true }); - }); - try { - return await Promise.race([promise, aborted]); - } finally { - if (onAbort !== undefined) signal.removeEventListener("abort", onAbort); - } -} - -function linkAbortSignals(...signals: readonly (AbortSignal | undefined)[]): { - readonly signal: AbortSignal; - readonly abort: (reason?: unknown) => void; - readonly dispose: () => void; -} { - const controller = new AbortController(); - const listeners: (() => void)[] = []; - for (const signal of signals) { - if (signal === undefined) continue; - const abort = (): void => controller.abort(signal.reason); - if (signal.aborted) { - abort(); - break; - } - signal.addEventListener("abort", abort, { once: true }); - listeners.push(() => signal.removeEventListener("abort", abort)); - } - return { - signal: controller.signal, - abort: (reason) => controller.abort(reason), - dispose: () => { - for (const remove of listeners) remove(); - }, - }; -} - -async function dispatchWithRetry( - port: ConnectedMcpSessionPort, - request: JsonValue, - signal: AbortSignal | undefined, - retry: "observe" | "mutate", -): Promise { - const options = signal === undefined ? undefined : { signal }; - try { - return await port.dispatch(request, options); - } catch (error) { - throwIfAborted(signal); - if ( - !(error instanceof DispatchError) || - (retry === "mutate" && !error.retryable) - ) { - throw error; - } - return port.dispatch(request, options); - } -} - -function decodeResult(codec: RuntimeCodec, value: JsonValue): T { - const decoded = codec.parse(value); - if (!decoded.success) throw decoded.error; - return decoded.value; -} - -function responseResult(response: JsonRpcResponse): JsonValue { - if (response.kind === "error") throw new JsonRpcResponseError(response.error); - return response.result; -} - -function terminalStatus(status: TaskV1["status"]): boolean { - return ( - status === "completed" || status === "failed" || status === "cancelled" - ); -} - -class ImmediateExecution< - TResult, - TApplicationContext, -> implements ToolExecutionCommon { - readonly kind = "immediate" as const; - readonly handle = undefined; - - constructor( - readonly applicationContext: TApplicationContext, - private readonly resultPromise: Promise, - ) {} - - updates(signal?: AbortSignal): AsyncIterable { - throwIfAborted(signal); - return { - [Symbol.asyncIterator]() { - return { - next: () => - Promise.resolve({ done: true as const, value: undefined }), - }; - }, - }; - } - - result(): Promise { - return this.resultPromise; - } - - cancel(signal?: AbortSignal): Promise { - throwIfAborted(signal); - return Promise.resolve(); - } - - close(): Promise { - return Promise.resolve(); - } - - [Symbol.asyncDispose](): Promise { - return this.close(); - } -} - -class ManagedToolDeclarations implements ToolDeclarationProvider { - private tools = new Map(); - private refreshSequence = 0; - private refreshController: AbortController | undefined; - private initialReady: Promise; - private closed = false; - - constructor( - private readonly port: ConnectedMcpSessionPort, - private readonly reportError: (error: Error) => void, - ) { - this.initialReady = this.refresh(); - void this.initialReady.catch(() => {}); - } - - currentTool(name: string): ToolV1 | ToolV2 | undefined { - return this.tools.get(name); - } - - async ensureReady(signal?: AbortSignal): Promise { - throwIfAborted(signal); - const wait = async (): Promise => { - try { - await this.initialReady; - } catch (error) { - if ( - this.closed || - (error instanceof DOMException && error.name === "AbortError") - ) - throw error; - this.initialReady = this.refresh(); - void this.initialReady.catch(() => {}); - await this.initialReady; - } - }; - const waiting = wait(); - if (signal === undefined) return waiting; - let onAbort: (() => void) | undefined; - const aborted = new Promise((_, reject) => { - onAbort = () => - reject( - signal.reason instanceof Error - ? signal.reason - : new DOMException("The operation was aborted", "AbortError"), - ); - signal.addEventListener("abort", onAbort, { once: true }); - }); - try { - await Promise.race([waiting, aborted]); - } finally { - if (onAbort !== undefined) signal.removeEventListener("abort", onAbort); - } - } - - close(): void { - if (this.closed) return; - this.closed = true; - this.refreshController?.abort(); - } - - onNotification(notification: JsonValue): void { - if (this.closed) return; - if ( - notification === null || - Array.isArray(notification) || - typeof notification !== "object" - ) - return; - const record = notification as Readonly>; - if (record.method !== "notifications/tools/list_changed") return; - void this.refresh().catch((error: unknown) => { - if (!(error instanceof DOMException && error.name === "AbortError")) { - this.reportError( - error instanceof Error - ? error - : new Error("Tool refresh failed", { cause: error }), - ); - } - }); - } - - private async refresh(): Promise { - if (this.closed) - throw new DOMException("Tool declarations are closed", "AbortError"); - const sequence = ++this.refreshSequence; - this.refreshController?.abort(); - const controller = new AbortController(); - this.refreshController = controller; - const decoded = new Map(); - let cursor: string | undefined; - do { - const response = await this.port.dispatch( - { - method: "tools/list", - params: cursor === undefined ? {} : { cursor }, - }, - { signal: controller.signal }, - ); - if (response.kind === "error") - throw new JsonRpcResponseError(response.error); - if ( - response.result === null || - Array.isArray(response.result) || - typeof response.result !== "object" - ) { - throw new Error("tools/list result must be an object"); - } - const result = response.result as Readonly>; - const listed = result.tools; - if (!isJsonArray(listed)) - throw new Error("tools/list result must contain tools"); - for (const value of listed) { - const parsed = - this.port.taskCapabilities.generation === "v1" - ? ToolV1Codec.parse(value) - : this.port.taskCapabilities.generation === "v2" - ? ToolV2Codec.parse(value) - : (() => { - const v2 = ToolV2Codec.parse(value); - return v2.success ? v2 : ToolV1Codec.parse(value); - })(); - if (!parsed.success) throw parsed.error; - if (decoded.has(parsed.value.name)) { - this.reportError( - new Error(`Duplicate tool declaration: ${parsed.value.name}`), - ); - } - decoded.set(parsed.value.name, parsed.value); - } - cursor = - typeof result.nextCursor === "string" ? result.nextCursor : undefined; - } while (cursor !== undefined); - if (sequence === this.refreshSequence) this.tools = decoded; - } -} - -function requestParams( - request: Readonly>, -): Readonly> { - if (request.params === undefined) return {}; - if ( - request.params === null || - Array.isArray(request.params) || - typeof request.params !== "object" - ) { - throw new Error("Input request params must be an object"); - } - return request.params as Readonly>; -} - -interface OrdinaryInputCandidate { - readonly lifetime: "basic"; - readonly generation: TaskGeneration; - readonly toolName: string; - readonly executionId: string; - readonly applicationContext: TApplicationContext; - readonly signal?: AbortSignal; -} - -interface V1TaskInputCandidate { - readonly lifetime: "task-v1"; - readonly generation: "v1"; - readonly taskId: TaskId; - readonly toolName: string; - readonly executionId: string; - readonly applicationContext: TApplicationContext; - readonly signal?: AbortSignal; -} - -let nextExecutionId = 0; - -function defaultServerRequestResponse( - incoming: IncomingServerRequest, -): JsonRpcResponse { - if ( - incoming.request !== null && - !Array.isArray(incoming.request) && - typeof incoming.request === "object" - ) { - const request = incoming.request as Readonly>; - if (request.method === "elicitation/create") { - return { kind: "result", result: { action: "cancel" } }; - } - } - return { kind: "error", error: { code: -32603, message: "Internal error" } }; -} - -function throwIfAborted(signal: AbortSignal | undefined): void { - if (signal?.aborted !== true) return; - throw signal.reason instanceof Error - ? signal.reason - : new DOMException("The operation was aborted", "AbortError"); -} - -class PortTaskEnabledSession< - TApplicationContext, -> implements TaskEnabledSession { - private closed = false; - private closeError: Error | undefined; - private readonly lifecycleController = new AbortController(); - private invalidationError: Error | undefined; - private readonly disposeListeners: readonly (() => void)[]; - private readonly declarations: ToolDeclarationProvider; - private readonly managedDeclarations: ManagedToolDeclarations | undefined; - private readonly ordinaryInputCandidates = new Map< - string, - OrdinaryInputCandidate - >(); - private readonly v1TaskInputCandidates = new Map< - string, - V1TaskInputCandidate - >(); - private readonly activeTaskExecutions = new Set< - TaskExecution - >(); - - constructor( - private readonly port: ConnectedMcpSessionPort, - private readonly options: WithTasksOptions, - disposePort?: () => void, - ) { - const reportError = (error: Error): void => { - try { - this.options.onError?.(error); - } catch (sinkError) { - console.error(sinkError); - } - }; - this.managedDeclarations = - options.tools === undefined - ? new ManagedToolDeclarations(port, reportError) - : undefined; - this.declarations = options.tools ?? this.managedDeclarations!; - const onSessionAbort = (): void => { - const error = - options.signal?.reason instanceof Error - ? options.signal.reason - : new DOMException("The session was aborted", "AbortError"); - this.invalidationError ??= error; - this.lifecycleController.abort(error); - this.managedDeclarations?.close(); - }; - options.signal?.addEventListener("abort", onSessionAbort, { once: true }); - this.disposeListeners = [ - port.onServerRequest(async (incoming) => - this.handleServerRequest(incoming), - ), - port.onNotification((notification) => - this.handleNotification(notification), - ), - port.onInvalidated((reason) => { - const error = reasonAsError(reason); - this.invalidationError ??= error; - this.lifecycleController.abort(error); - this.managedDeclarations?.close(); - }), - () => options.signal?.removeEventListener("abort", onSessionAbort), - () => this.managedDeclarations?.close(), - ...(disposePort === undefined ? [] : [disposePort]), - ]; - if (options.signal?.aborted === true) onSessionAbort(); - if (port.invalidated) { - const error = new Error("MCP session was invalidated"); - this.invalidationError = error; - this.lifecycleController.abort(error); - this.managedDeclarations?.close(); - } - } - - async callTool( - name: string, - params?: Readonly>, - options: { - readonly resultCodec?: RuntimeCodec; - readonly applicationContext?: TApplicationContext; - readonly signal?: AbortSignal; - readonly preferTask?: boolean; - } = {}, - ): Promise> { - this.assertUsable(); - const callLifecycle = linkAbortSignals( - this.lifecycleController.signal, - options.signal, - ); - const callSignal = callLifecycle.signal; - let declaration: ReturnType; - try { - throwIfAborted(callSignal); - await this.managedDeclarations?.ensureReady(callSignal); - this.assertUsable(); - declaration = this.declarations.currentTool(name); - } catch (error) { - callLifecycle.dispose(); - throw error; - } - if ( - this.port.taskCapabilities.generation === "v2" && - declaration !== undefined && - "execution" in declaration - ) { - callLifecycle.dispose(); - throw new Error( - "V1 tool declaration is incompatible with the V2 session", - ); - } - const requestParams: Record = { name }; - if (params !== undefined) requestParams.arguments = params; - const generation = this.port.taskCapabilities.generation; - const callAsTaskV1 = - generation === "v1" && - declaration !== undefined && - "execution" in declaration && - shouldCallToolAsTaskV1( - this.port.taskCapabilities.capabilities, - declaration as ToolV1, - options.preferTask, - ); - if (callAsTaskV1) requestParams.task = {}; - const executionId = `execution-${++nextExecutionId}`; - if (!callAsTaskV1) { - this.ordinaryInputCandidates.set(executionId, { - lifetime: "basic", - generation: generation === "none" ? "v1" : generation, - toolName: name, - executionId, - applicationContext: options.applicationContext as TApplicationContext, - signal: callSignal, - }); - } - const dispatchPromise = dispatchWithRetry( - this.port, - { - method: "tools/call", - params: - generation === "v2" - ? withTaskCapabilityV2(requestParams) - : requestParams, - }, - callSignal, - "mutate", - ); - let response: JsonRpcResponse; - try { - response = await withAbort(dispatchPromise, callSignal); - } catch (error) { - void dispatchPromise.then( - (lateResponse) => - this.cleanupLateTaskCreation(lateResponse, generation, callAsTaskV1), - () => {}, - ); - throw error; - } finally { - this.ordinaryInputCandidates.delete(executionId); - callLifecycle.dispose(); - } - try { - this.assertUsable(); - throwIfAborted(callSignal); - } catch (error) { - this.cleanupLateTaskCreation(response, generation, callAsTaskV1); - throw error; - } - const wireResult = responseResult(response); - const codec = - options.resultCodec ?? - (defaultResultCodec(generation) as RuntimeCodec); - - if (generation === "v1" && callAsTaskV1) { - const created = decodeResult(CreateTaskResultV1Codec, wireResult); - const handle: TaskHandle = { - generation: "v1", - taskId: created.task.taskId as TaskId, - originalOperation: "tools/call", - }; - const initial: TaskSnapshot = { generation: "v1", task: created.task }; - const execution = new TaskExecution( - options.applicationContext as TApplicationContext, - handle, - this.port.endpointId, - initial, - async ( - accept, - waitForTurn, - observe, - signal, - cancelledError, - closedError, - isClosed, - ) => { - let task = created.task; - let notificationSequence = 0; - while (!terminalStatus(task.status)) { - const turn = await waitForTurn( - notificationSequence, - Math.max( - DEFAULT_TASK_POLL_INTERVAL_MS, - task.pollInterval ?? DEFAULT_TASK_POLL_INTERVAL_MS, - ), - ); - const observed = - turn ?? - (await observe(notificationSequence, (observationSignal) => - dispatchWithRetry( - this.port, - { method: "tasks/get", params: { taskId: task.taskId } }, - observationSignal, - "observe", - ).then((response) => ({ - generation: "v1" as const, - task: decodeResult( - GetTaskResultV1Codec, - responseResult(response), - ), - })), - )); - if (observed?.snapshot.generation !== "v1") continue; - notificationSequence = observed.sequence; - task = observed.snapshot.task; - if (!isClosed()) accept({ generation: "v1", task }); - } - if (isClosed()) throw closedError; - if (task.status === "cancelled") throw cancelledError; - if (task.status === "failed") - throw new Error(task.statusMessage ?? "Task failed"); - const taskResult = responseResult( - await dispatchWithRetry( - this.port, - { method: "tasks/result", params: { taskId: task.taskId } }, - signal, - "observe", - ), - ); - decodeResult(TaskResultV1Codec, taskResult); - return decodeResult(codec, taskResult); - }, - async (signal) => { - const capabilities = this.port.taskCapabilities; - if ( - capabilities.generation !== "v1" || - capabilities.capabilities.cancel === undefined - ) - throw new TaskCancellationUnsupportedError(); - const cancelled = responseResult( - await dispatchWithRetry( - this.port, - { - method: "tasks/cancel", - params: { taskId: created.task.taskId }, - }, - signal, - "mutate", - ), - ); - decodeResult(CancelTaskResultV1Codec, cancelled); - }, - this.lifecycleController.signal, - ); - return this.trackTaskExecution(execution, { - lifetime: "task-v1", - generation: "v1", - taskId: created.task.taskId as TaskId, - toolName: name, - executionId, - applicationContext: options.applicationContext as TApplicationContext, - signal: execution.inputSignal(), - }); - } - - if (generation === "v2" && isCreateTaskResultV2(wireResult)) { - const created = decodeResult(CreateTaskResultV2Codec, wireResult); - const handle: TaskHandle = { - generation: "v2", - taskId: created.taskId as TaskId, - originalOperation: "tools/call", - }; - const initial: TaskSnapshot = { generation: "v2", task: created }; - return this.trackTaskExecution( - new TaskExecution( - options.applicationContext as TApplicationContext, - handle, - this.port.endpointId, - initial, - async ( - accept, - waitForTurn, - observe, - signal, - cancelledError, - closedError, - isClosed, - inputSignal, - ) => { - let status = created.status; - let current: DetailedTaskV2 | undefined; - let notificationSequence = 0; - const acquiredInputs = new Map(); - const acquireInputs = async ( - task: DetailedTaskV2, - ): Promise => { - if (task.status !== "input_required") return; - const inputResponses: Record = {}; - for (const [inputKey, inputRequest] of Object.entries( - task.inputRequests, - )) { - const signature = deterministicJson(inputRequest); - const acquiredSignature = acquiredInputs.get(inputKey); - if (acquiredSignature !== undefined) { - if (acquiredSignature !== signature) - this.reportBackgroundError( - new Error( - `V2 task input key ${inputKey} was reused incompatibly`, - ), - ); - continue; - } - acquiredInputs.set(inputKey, signature); - const request: InputRequestV2 = inputRequest; - const projected: ApplicationInputRequest | undefined = - request.method === "sampling/createMessage" - ? { kind: "sampling", params: request.params } - : request.method === "roots/list" - ? { - kind: "roots", - ...(request.params === undefined - ? {} - : { params: request.params }), - } - : request.method === "elicitation/create" - ? { kind: "elicitation", params: request.params } - : undefined; - if (projected === undefined) { - this.reportBackgroundError( - new Error( - `Unknown V2 task input method for key ${inputKey}`, - ), - ); - continue; - } - let result: unknown; - if (this.options.onInputRequest === undefined) { - if (request.method !== "elicitation/create") continue; - result = { action: "cancel" }; - } else { - try { - result = await this.options.onInputRequest(projected, { - lifetime: "task-v2", - taskId: task.taskId, - inputKey, - applicationContext: - options.applicationContext as TApplicationContext, - signal: inputSignal, - }); - } catch { - if (inputSignal.aborted) return; - if (request.method !== "elicitation/create") continue; - result = { action: "cancel" }; - } - } - try { - const codec = - request.method === "sampling/createMessage" - ? CreateMessageResultV2Codec - : request.method === "roots/list" - ? ListRootsResultV2Codec - : ElicitResultV2Codec; - inputResponses[inputKey] = decodeResult( - codec as RuntimeCodec, - result as JsonValue, - ); - } catch (error) { - this.reportBackgroundError( - error instanceof Error ? error : new Error(String(error)), - ); - } - } - if (inputSignal.aborted) return; - if (Object.keys(inputResponses).length === 0) return; - const updated = responseResult( - await dispatchWithRetry( - this.port, - { - method: "tasks/update", - params: withTaskCapabilityV2({ - taskId: task.taskId, - inputResponses, - }), - }, - signal, - "mutate", - ), - ); - decodeResult(UpdateTaskResultV2Codec, updated); - }; - while (!terminalStatus(status)) { - const delayMs = Math.max( - DEFAULT_TASK_POLL_INTERVAL_MS, - current?.pollIntervalMs ?? - created.pollIntervalMs ?? - DEFAULT_TASK_POLL_INTERVAL_MS, - ); - const turn = await waitForTurn(notificationSequence, delayMs); - const observed = - turn ?? - (await observe(notificationSequence, (observationSignal) => - dispatchWithRetry( - this.port, - { - method: "tasks/get", - params: withTaskCapabilityV2({ taskId: created.taskId }), - }, - observationSignal, - "observe", - ).then((response) => ({ - generation: "v2" as const, - task: decodeResult( - GetTaskResultV2Codec, - responseResult(response), - ), - })), - )); - if (observed?.snapshot.generation !== "v2") continue; - notificationSequence = observed.sequence; - const next = observed.snapshot.task as DetailedTaskV2; - current = next; - status = next.status; - if (!isClosed()) accept({ generation: "v2", task: next }); - await acquireInputs(next); - } - if (isClosed()) throw closedError; - if (current === undefined) { - current = decodeResult( - GetTaskResultV2Codec, - responseResult( - await dispatchWithRetry( - this.port, - { - method: "tasks/get", - params: withTaskCapabilityV2({ taskId: created.taskId }), - }, - signal, - "observe", - ), - ), - ); - if (!isClosed()) accept({ generation: "v2", task: current }); - } - if (current.status === "cancelled") throw cancelledError; - if (current.status === "failed") - throw new JsonRpcResponseError(current.error); - if (current.status !== "completed") - throw new Error( - `Unsupported terminal task status: ${current.status}`, - ); - return decodeResult(codec, current.result); - }, - async (signal) => { - const cancelled = responseResult( - await dispatchWithRetry( - this.port, - { - method: "tasks/cancel", - params: withTaskCapabilityV2({ taskId: created.taskId }), - }, - signal, - "mutate", - ), - ); - decodeResult(CancelTaskResultV2Codec, cancelled); - }, - this.lifecycleController.signal, - ), - ); - } - - const resultPromise = Promise.resolve(decodeResult(codec, wireResult)); - return new ImmediateExecution( - options.applicationContext as TApplicationContext, - resultPromise, - ); - } - - async resumeTask( - reference: SerializedTaskReference, - options: { - readonly resultCodec?: RuntimeCodec; - readonly applicationContext?: TApplicationContext; - readonly signal?: AbortSignal; - } = {}, - ): Promise> { - this.assertUsable(); - const capabilities = this.port.taskCapabilities; - if (reference.endpointId !== this.port.endpointId) - throw new Error("Task reference belongs to a different endpoint"); - if (reference.generation !== capabilities.generation) - throw new Error("Task reference generation does not match this session"); - if (reference.originalOperation !== "tools/call") - throw new Error("Task reference operation is not supported"); - - const resumeLifecycle = linkAbortSignals( - this.lifecycleController.signal, - options.signal, - ); - const resumeSignal = resumeLifecycle.signal; - const executionId = `execution-${++nextExecutionId}`; - const codec = - options.resultCodec ?? - (defaultResultCodec(reference.generation) as RuntimeCodec); - try { - throwIfAborted(resumeSignal); - const response = await dispatchWithRetry( - this.port, - { - method: "tasks/get", - params: - reference.generation === "v2" - ? withTaskCapabilityV2({ taskId: reference.taskId }) - : { taskId: reference.taskId }, - }, - resumeSignal, - "observe", - ); - this.assertUsable(); - throwIfAborted(resumeSignal); - - if (reference.generation === "v1") { - const task = decodeResult( - GetTaskResultV1Codec, - responseResult(response), - ); - const execution = new TaskExecution( - options.applicationContext as TApplicationContext, - reference, - this.port.endpointId, - { generation: "v1", task }, - async ( - accept, - waitForTurn, - observe, - signal, - cancelledError, - closedError, - isClosed, - ) => { - let current = task; - let notificationSequence = 0; - while (!terminalStatus(current.status)) { - const turn = await waitForTurn( - notificationSequence, - Math.max( - DEFAULT_TASK_POLL_INTERVAL_MS, - current.pollInterval ?? DEFAULT_TASK_POLL_INTERVAL_MS, - ), - ); - const observed = - turn ?? - (await observe(notificationSequence, (observationSignal) => - dispatchWithRetry( - this.port, - { - method: "tasks/get", - params: { taskId: reference.taskId }, - }, - observationSignal, - "observe", - ).then((nextResponse) => ({ - generation: "v1" as const, - task: decodeResult( - GetTaskResultV1Codec, - responseResult(nextResponse), - ), - })), - )); - if (observed?.snapshot.generation !== "v1") continue; - notificationSequence = observed.sequence; - current = observed.snapshot.task; - if (!isClosed()) accept({ generation: "v1", task: current }); - } - if (isClosed()) throw closedError; - if (current.status === "cancelled") throw cancelledError; - if (current.status === "failed") - throw new Error(current.statusMessage ?? "Task failed"); - const taskResult = responseResult( - await dispatchWithRetry( - this.port, - { - method: "tasks/result", - params: { taskId: reference.taskId }, - }, - signal, - "observe", - ), - ); - decodeResult(TaskResultV1Codec, taskResult); - return decodeResult(codec, taskResult); - }, - async (signal) => { - const currentCapabilities = this.port.taskCapabilities; - if ( - currentCapabilities.generation !== "v1" || - currentCapabilities.capabilities.cancel === undefined - ) - throw new TaskCancellationUnsupportedError(); - decodeResult( - CancelTaskResultV1Codec, - responseResult( - await dispatchWithRetry( - this.port, - { - method: "tasks/cancel", - params: { taskId: reference.taskId }, - }, - signal, - "mutate", - ), - ), - ); - }, - this.lifecycleController.signal, - ); - return this.trackTaskExecution(execution, { - lifetime: "task-v1", - generation: "v1", - taskId: reference.taskId, - toolName: "", - executionId, - applicationContext: options.applicationContext as TApplicationContext, - signal: execution.inputSignal(), - }); - } - - const task = decodeResult(GetTaskResultV2Codec, responseResult(response)); - return this.trackTaskExecution( - new TaskExecution( - options.applicationContext as TApplicationContext, - reference, - this.port.endpointId, - { generation: "v2", task }, - async ( - accept, - waitForTurn, - observe, - signal, - cancelledError, - closedError, - isClosed, - inputSignal, - ) => { - let current: DetailedTaskV2 = task; - let notificationSequence = 0; - const acquiredInputs = new Map(); - await this.acquireV2TaskInputs( - current, - options.applicationContext as TApplicationContext, - inputSignal, - signal, - acquiredInputs, - ); - while (!terminalStatus(current.status)) { - const turn = await waitForTurn( - notificationSequence, - Math.max( - DEFAULT_TASK_POLL_INTERVAL_MS, - current.pollIntervalMs ?? DEFAULT_TASK_POLL_INTERVAL_MS, - ), - ); - const observed = - turn ?? - (await observe(notificationSequence, (observationSignal) => - dispatchWithRetry( - this.port, - { - method: "tasks/get", - params: withTaskCapabilityV2({ - taskId: reference.taskId, - }), - }, - observationSignal, - "observe", - ).then((nextResponse) => ({ - generation: "v2" as const, - task: decodeResult( - GetTaskResultV2Codec, - responseResult(nextResponse), - ), - })), - )); - if (observed?.snapshot.generation !== "v2") continue; - notificationSequence = observed.sequence; - current = observed.snapshot.task as DetailedTaskV2; - if (!isClosed()) accept({ generation: "v2", task: current }); - await this.acquireV2TaskInputs( - current, - options.applicationContext as TApplicationContext, - inputSignal, - signal, - acquiredInputs, - ); - } - if (isClosed()) throw closedError; - if (current.status === "cancelled") throw cancelledError; - if (current.status === "failed") - throw new JsonRpcResponseError(current.error); - if (current.status !== "completed") - throw new Error( - `Unsupported terminal task status: ${current.status}`, - ); - return decodeResult(codec, current.result); - }, - async (signal) => { - decodeResult( - CancelTaskResultV2Codec, - responseResult( - await dispatchWithRetry( - this.port, - { - method: "tasks/cancel", - params: withTaskCapabilityV2({ taskId: reference.taskId }), - }, - signal, - "mutate", - ), - ), - ); - }, - this.lifecycleController.signal, - ), - ); - } finally { - resumeLifecycle.dispose(); - } - } - - private async acquireV2TaskInputs( - task: DetailedTaskV2, - applicationContext: TApplicationContext, - inputSignal: AbortSignal, - signal: AbortSignal, - acquiredInputs: Map, - ): Promise { - if (task.status !== "input_required") return; - const inputResponses: Record = {}; - for (const [inputKey, inputRequest] of Object.entries(task.inputRequests)) { - const signature = deterministicJson(inputRequest); - const acquiredSignature = acquiredInputs.get(inputKey); - if (acquiredSignature !== undefined) { - if (acquiredSignature !== signature) - this.reportBackgroundError( - new Error(`V2 task input key ${inputKey} was reused incompatibly`), - ); - continue; - } - acquiredInputs.set(inputKey, signature); - const request: InputRequestV2 = inputRequest; - const projected: ApplicationInputRequest | undefined = - request.method === "sampling/createMessage" - ? { kind: "sampling", params: request.params } - : request.method === "roots/list" - ? { - kind: "roots", - ...(request.params === undefined - ? {} - : { params: request.params }), - } - : request.method === "elicitation/create" - ? { kind: "elicitation", params: request.params } - : undefined; - if (projected === undefined) { - this.reportBackgroundError( - new Error(`Unknown V2 task input method for key ${inputKey}`), - ); - continue; - } - let result: unknown; - if (this.options.onInputRequest === undefined) { - if (request.method !== "elicitation/create") continue; - result = { action: "cancel" }; - } else { - try { - result = await this.options.onInputRequest(projected, { - lifetime: "task-v2", - taskId: task.taskId, - inputKey, - applicationContext, - signal: inputSignal, - }); - } catch { - if (inputSignal.aborted) return; - if (request.method !== "elicitation/create") continue; - result = { action: "cancel" }; - } - } - try { - const responseCodec = - request.method === "sampling/createMessage" - ? CreateMessageResultV2Codec - : request.method === "roots/list" - ? ListRootsResultV2Codec - : ElicitResultV2Codec; - inputResponses[inputKey] = decodeResult( - responseCodec as RuntimeCodec, - result as JsonValue, - ); - } catch (error) { - this.reportBackgroundError( - error instanceof Error ? error : new Error(String(error)), - ); - } - } - if (inputSignal.aborted || Object.keys(inputResponses).length === 0) return; - decodeResult( - UpdateTaskResultV2Codec, - responseResult( - await dispatchWithRetry( - this.port, - { - method: "tasks/update", - params: withTaskCapabilityV2({ - taskId: task.taskId, - inputResponses, - }), - }, - signal, - "mutate", - ), - ), - ); - } - - private cleanupLateTaskCreation( - response: JsonRpcResponse, - generation: SessionTaskCapabilities["generation"], - callAsTaskV1: boolean, - ): void { - if (response.kind !== "result") return; - let taskId: TaskId | undefined; - let params: JsonValue | undefined; - if (generation === "v1" && callAsTaskV1) { - const parsed = CreateTaskResultV1Codec.parse(response.result); - if (parsed.success) { - taskId = parsed.value.task.taskId as TaskId; - params = { taskId }; - } - } else if (generation === "v2" && isCreateTaskResultV2(response.result)) { - const parsed = CreateTaskResultV2Codec.parse(response.result); - if (parsed.success) { - taskId = parsed.value.taskId as TaskId; - params = withTaskCapabilityV2({ taskId }); - } - } - if (taskId === undefined || params === undefined) return; - void dispatchWithRetry( - this.port, - { method: "tasks/cancel", params }, - undefined, - "mutate", - ).catch(() => { - // A task returned after call abort is cleaned up on a best-effort basis. - }); - } - - close(): Promise { - if (!this.closed) { - this.closed = true; - for (const execution of this.activeTaskExecutions) { - void execution.close().catch(() => {}); - } - this.lifecycleController.abort( - new Error("Task-enabled session is closed"), - ); - for (const dispose of this.disposeListeners) { - try { - dispose(); - } catch (error) { - this.closeError ??= reasonAsError(error); - } - } - } - return this.closeError === undefined - ? Promise.resolve() - : Promise.reject(this.closeError); - } - - [Symbol.asyncDispose](): Promise { - return this.close(); - } - - private trackTaskExecution( - execution: TaskExecution, - v1InputCandidate?: V1TaskInputCandidate, - ): TaskExecution { - const tracked = execution as TaskExecution; - this.activeTaskExecutions.add(tracked); - if ( - v1InputCandidate !== undefined && - v1InputCandidate.signal?.aborted !== true - ) { - this.v1TaskInputCandidates.set( - v1InputCandidate.executionId, - v1InputCandidate, - ); - v1InputCandidate.signal?.addEventListener( - "abort", - () => this.v1TaskInputCandidates.delete(v1InputCandidate.executionId), - { once: true }, - ); - } - void execution - .result() - .catch(() => {}) - .finally(() => { - execution.endInputLifetime(); - this.activeTaskExecutions.delete(tracked); - if (v1InputCandidate !== undefined) - this.v1TaskInputCandidates.delete(v1InputCandidate.executionId); - }); - return execution; - } - - private handleNotification(notification: JsonValue): void { - this.managedDeclarations?.onNotification(notification); - if ( - notification === null || - Array.isArray(notification) || - typeof notification !== "object" - ) - return; - const method = (notification as Readonly>).method; - const generation = this.port.taskCapabilities.generation; - const parsed = - generation === "v1" && method === "notifications/tasks/status" - ? TaskStatusNotificationV1Codec.parse(notification) - : generation === "v2" && method === "notifications/tasks" - ? TaskStatusNotificationV2Codec.parse(notification) - : undefined; - if (parsed === undefined) return; - if (!parsed.success) { - this.reportBackgroundError(parsed.error); - return; - } - const snapshot: TaskSnapshot = - generation === "v1" - ? { generation: "v1", task: parsed.value.params as TaskV1 } - : { generation: "v2", task: parsed.value.params as DetailedTaskV2 }; - for (const execution of this.activeTaskExecutions) { - execution.onNotification(snapshot); - } - } - - private async handleServerRequest( - incoming: IncomingServerRequest, - ): Promise { - if ( - incoming.request === null || - Array.isArray(incoming.request) || - typeof incoming.request !== "object" - ) { - return defaultServerRequestResponse(incoming); - } - const wire = incoming.request as Readonly>; - const method = wire.method; - const request: ApplicationInputRequest | undefined = - method === "elicitation/create" - ? { kind: "elicitation", params: requestParams(wire) } - : method === "sampling/createMessage" - ? { kind: "sampling", params: requestParams(wire) } - : method === "roots/list" - ? { - kind: "roots", - ...(wire.params === undefined - ? {} - : { params: requestParams(wire) }), - } - : undefined; - if (request === undefined) return defaultServerRequestResponse(incoming); - const taskCandidates = [...this.v1TaskInputCandidates.values()]; - const ordinaryCandidates = [...this.ordinaryInputCandidates.values()]; - const params = request.params; - const meta = params?._meta; - const relatedTaskKey = "io.modelcontextprotocol/related-task"; - let evidence: "absent" | "invalid" | { readonly taskId: string }; - if (meta === undefined) evidence = "absent"; - else if (meta === null || Array.isArray(meta) || typeof meta !== "object") - evidence = "invalid"; - else { - const relatedTask = (meta as Readonly>)[ - relatedTaskKey - ]; - if (relatedTask === undefined) evidence = "absent"; - else if ( - relatedTask === null || - Array.isArray(relatedTask) || - typeof relatedTask !== "object" || - typeof (relatedTask as Readonly>).taskId !== - "string" - ) - evidence = "invalid"; - else - evidence = { - taskId: (relatedTask as Readonly>) - .taskId as string, - }; - } - const allCandidates = [...ordinaryCandidates, ...taskCandidates]; - const matches = - evidence === "absent" || evidence === "invalid" - ? allCandidates - : taskCandidates.filter( - (candidate) => candidate.taskId === evidence.taskId, - ); - const failureReason: InputCorrelationFailureReason | undefined = - evidence === "invalid" - ? "invalid-evidence" - : evidence === "absent" && matches.length === 0 - ? "missing-evidence" - : matches.length === 0 - ? "zero-matches" - : matches.length > 1 - ? "ambiguous-matches" - : undefined; - if (failureReason !== undefined) { - const candidates = matches.map((candidate) => ({ - generation: candidate.generation, - toolName: candidate.toolName, - executionId: candidate.executionId, - applicationContext: candidate.applicationContext, - })); - this.reportBackgroundError( - new InputCorrelationError( - this.port.taskCapabilities.generation === "none" - ? "v1" - : this.port.taskCapabilities.generation, - request.kind, - candidates, - failureReason, - ), - ); - return defaultServerRequestResponse(incoming); - } - const candidate = matches[0]; - if (this.options.onInputRequest === undefined) - return defaultServerRequestResponse(incoming); - try { - const context: ResolvedInputExchangeContext = - candidate.lifetime === "task-v1" - ? { - lifetime: "task-v1", - taskId: candidate.taskId, - applicationContext: candidate.applicationContext, - ...(candidate.signal === undefined - ? {} - : { signal: candidate.signal }), - } - : { - lifetime: "basic", - executionId: candidate.executionId, - applicationContext: candidate.applicationContext, - ...(candidate.signal === undefined - ? {} - : { signal: candidate.signal }), - }; - const result = await this.options.onInputRequest(request, context); - if (!isJsonValue(result)) - throw new Error("Input handler returned a non-JSON value"); - return { kind: "result", result }; - } catch { - return defaultServerRequestResponse(incoming); - } - } - - private reportBackgroundError(error: Error): void { - try { - if (this.options.onError === undefined) console.error(error); - else this.options.onError(error); - } catch (sinkError) { - console.error(sinkError); - } - } - - private assertUsable(): void { - if (this.invalidationError !== undefined) throw this.invalidationError; - if (this.closed) throw new Error("Task-enabled session is closed"); - } -} - -export function withTasks( - session: ConnectedMcpSessionPort, - options?: WithTasksOptions, -): TaskEnabledSession; -export function withTasks( - client: Client, - options: WithTasksOptions & { - readonly endpointId: string; - }, -): TaskEnabledSession; -export function withTasks( - session: ConnectedMcpSessionPort | Client, - options: WithTasksOptions & { - readonly endpointId?: string; - } = {}, -): TaskEnabledSession { - if (isConnectedMcpSessionPort(session)) - return new PortTaskEnabledSession(session, options); - if (!isClientPublicSurface(session)) - throw new TypeError( - "withTasks requires a ConnectedMcpSessionPort or Client-compatible object", - ); - const endpointId = options.endpointId; - if (endpointId === undefined) - throw new TypeError("withTasks(Client) requires options.endpointId"); - const port = new ClientSessionPort(session, endpointId); - try { - return new PortTaskEnabledSession(port, options, () => - port[Symbol.dispose](), - ); - } catch (error) { - port[Symbol.dispose](); - throw error; - } -} diff --git a/packages/ext-tasks/src/client/input-routing.ts b/packages/ext-tasks/src/client/input-routing.ts new file mode 100644 index 0000000..dd8c8a1 --- /dev/null +++ b/packages/ext-tasks/src/client/input-routing.ts @@ -0,0 +1,64 @@ +import type { JsonValue, TaskGeneration, TaskId } from "../core/index.js"; +import type { IncomingServerRequest, JsonRpcResponse } from "./port.js"; + +export function requestParams( + request: Readonly>, +): Readonly> { + if (request.params === undefined) return {}; + if ( + request.params === null || + Array.isArray(request.params) || + typeof request.params !== "object" + ) { + throw new Error("Input request params must be an object"); + } + return request.params as Readonly>; +} + +export interface OrdinaryInputCandidate { + readonly lifetime: "basic"; + readonly generation: TaskGeneration; + readonly toolName: string; + readonly executionId: string; + readonly applicationContext: TApplicationContext; + readonly signal?: AbortSignal; +} + +export interface V1TaskInputCandidate { + readonly lifetime: "task-v1"; + readonly generation: "v1"; + readonly taskId: TaskId; + readonly toolName: string; + readonly executionId: string; + readonly applicationContext: TApplicationContext; + readonly signal?: AbortSignal; +} + +let nextExecutionId = 0; + +export function nextExecutionIdentifier(): string { + return `execution-${++nextExecutionId}`; +} + +export function defaultServerRequestResponse( + incoming: IncomingServerRequest, +): JsonRpcResponse { + if ( + incoming.request !== null && + !Array.isArray(incoming.request) && + typeof incoming.request === "object" + ) { + const request = incoming.request as Readonly>; + if (request.method === "elicitation/create") { + return { kind: "result", result: { action: "cancel" } }; + } + } + return { kind: "error", error: { code: -32603, message: "Internal error" } }; +} + +export function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted !== true) return; + throw signal.reason instanceof Error + ? signal.reason + : new DOMException("The operation was aborted", "AbortError"); +} diff --git a/packages/ext-tasks/src/client/port.ts b/packages/ext-tasks/src/client/port.ts new file mode 100644 index 0000000..432a74a --- /dev/null +++ b/packages/ext-tasks/src/client/port.ts @@ -0,0 +1,132 @@ +import type { JsonValue, RuntimeCodec } from "../core/index.js"; +import type { ServerTaskCapabilitiesV1 } from "../core/v1/index.js"; +import type { ErrorV2, TasksExtensionCapabilityV2 } from "../core/v2/index.js"; +import { JsonRpcResponseError } from "./api.js"; +import { throwIfAborted } from "./input-routing.js"; + +export type SessionTaskCapabilities = + | { readonly generation: "none" } + | { + readonly generation: "v1"; + readonly capabilities: ServerTaskCapabilitiesV1; + } + | { + readonly generation: "v2"; + readonly capabilities: TasksExtensionCapabilityV2; + }; + +export type JsonRpcResponse = + | { readonly kind: "result"; readonly result: JsonValue } + | { readonly kind: "error"; readonly error: ErrorV2 }; + +export interface IncomingServerRequest { + readonly request: JsonValue; + readonly requestContext: unknown; +} + +export interface ConnectedMcpSessionPort { + readonly endpointId: string; + readonly taskCapabilities: SessionTaskCapabilities; + dispatch( + request: JsonValue, + options?: { readonly signal?: AbortSignal }, + ): Promise; + onServerRequest( + handler: (incoming: IncomingServerRequest) => Promise, + ): () => void; + onNotification(listener: (notification: JsonValue) => void): () => void; + onInvalidated(listener: (reason: unknown) => void): () => void; + readonly invalidated: boolean; +} + +export class DispatchError extends Error { + readonly retryable: boolean; + + constructor(message: string, retryable = false, options?: ErrorOptions) { + super(message, options); + this.name = "DispatchError"; + this.retryable = retryable; + } +} +export async function withAbort( + promise: Promise, + signal?: AbortSignal, +): Promise { + if (signal === undefined) return promise; + throwIfAborted(signal); + let onAbort: (() => void) | undefined; + const aborted = new Promise((_, reject) => { + onAbort = () => + reject( + signal.reason instanceof Error + ? signal.reason + : new DOMException("The operation was aborted", "AbortError"), + ); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([promise, aborted]); + } finally { + if (onAbort !== undefined) signal.removeEventListener("abort", onAbort); + } +} + +export function linkAbortSignals( + ...signals: readonly (AbortSignal | undefined)[] +): { + readonly signal: AbortSignal; + readonly abort: (reason?: unknown) => void; + readonly dispose: () => void; +} { + const controller = new AbortController(); + const listeners: (() => void)[] = []; + for (const signal of signals) { + if (signal === undefined) continue; + const abort = (): void => controller.abort(signal.reason); + if (signal.aborted) { + abort(); + break; + } + signal.addEventListener("abort", abort, { once: true }); + listeners.push(() => signal.removeEventListener("abort", abort)); + } + return { + signal: controller.signal, + abort: (reason) => controller.abort(reason), + dispose: () => { + for (const remove of listeners) remove(); + }, + }; +} + +export async function dispatchWithRetry( + port: ConnectedMcpSessionPort, + request: JsonValue, + signal: AbortSignal | undefined, + retry: "observe" | "mutate", +): Promise { + const options = signal === undefined ? undefined : { signal }; + try { + return await port.dispatch(request, options); + } catch (error) { + throwIfAborted(signal); + if ( + !(error instanceof DispatchError) || + (retry === "mutate" && !error.retryable) + ) { + throw error; + } + return port.dispatch(request, options); + } +} + +export function decodeResult(codec: RuntimeCodec, value: JsonValue): T { + const decoded = codec.parse(value); + if (!decoded.success) throw decoded.error; + return decoded.value; +} + +export function responseResult(response: JsonRpcResponse): JsonValue { + if (response.kind === "error") throw new JsonRpcResponseError(response.error); + return response.result; +} diff --git a/packages/ext-tasks/src/client/sdk-client-adapter.ts b/packages/ext-tasks/src/client/sdk-client-adapter.ts new file mode 100644 index 0000000..719f4ef --- /dev/null +++ b/packages/ext-tasks/src/client/sdk-client-adapter.ts @@ -0,0 +1,287 @@ +import { + Client, + ProtocolError, + type StandardSchemaV1, +} from "@modelcontextprotocol/client"; +import { isJsonValue, type JsonValue } from "../core/index.js"; +import type { SessionTaskCapabilities } from "./port.js"; +import { + DispatchError, + type ConnectedMcpSessionPort, + type IncomingServerRequest, + type JsonRpcResponse, +} from "./port.js"; + +const jsonValueSchema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "@modelcontextprotocol/ext-tasks", + validate(value) { + return isJsonValue(value) + ? { value } + : { issues: [{ message: "Expected a JSON value" }] }; + }, + }, +}; + +function isJsonRecord( + value: unknown, +): value is Readonly> { + return ( + isJsonValue(value) && + value !== null && + !Array.isArray(value) && + typeof value === "object" + ); +} + +function clientTaskCapabilities( + client: ClientPublicSurface, +): SessionTaskCapabilities { + const capabilities = client.getServerCapabilities(); + if (client.getProtocolEra() === "modern") { + const extension = + capabilities?.extensions?.["io.modelcontextprotocol/tasks"]; + if ( + extension !== null && + typeof extension === "object" && + !Array.isArray(extension) && + Object.keys(extension).length === 0 + ) + return { generation: "v2", capabilities: {} }; + return { generation: "none" }; + } + const tasks = capabilities?.tasks; + return tasks === undefined + ? { generation: "none" } + : { generation: "v1", capabilities: structuredClone(tasks) }; +} + +function asClientRequest(request: JsonValue): { + readonly method: string; + readonly params?: Readonly>; +} { + if (!isJsonRecord(request)) + throw new DispatchError("MCP request must be a JSON object"); + const method = request.method; + if (typeof method !== "string") + throw new DispatchError("MCP request method must be a string"); + const params = request.params; + if (params === undefined) return { method }; + if (!isJsonRecord(params)) + throw new DispatchError("MCP request params must be a JSON object"); + return { method, params }; +} + +function isTaskInputMethod(method: string): boolean { + return ( + method === "elicitation/create" || + method === "sampling/createMessage" || + method === "roots/list" + ); +} + +type ClientPublicSurface = Pick< + Client, + | "request" + | "getProtocolEra" + | "getServerCapabilities" + | "fallbackRequestHandler" + | "fallbackNotificationHandler" + | "onclose" +>; + +const adaptedClients = new WeakSet(); + +export function isConnectedMcpSessionPort( + value: unknown, +): value is ConnectedMcpSessionPort { + if (value === null || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.endpointId === "string" && + candidate.taskCapabilities !== undefined && + typeof candidate.dispatch === "function" && + typeof candidate.onServerRequest === "function" && + typeof candidate.onNotification === "function" && + typeof candidate.onInvalidated === "function" && + typeof candidate.invalidated === "boolean" + ); +} + +export function isClientPublicSurface( + value: unknown, +): value is ClientPublicSurface { + if (value === null || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.request === "function" && + typeof candidate.getProtocolEra === "function" && + typeof candidate.getServerCapabilities === "function" + ); +} + +export class ClientSessionPort implements ConnectedMcpSessionPort { + readonly taskCapabilities: SessionTaskCapabilities; + private readonly serverRequestListeners = new Set< + (incoming: IncomingServerRequest) => Promise + >(); + private readonly notificationListeners = new Set< + (notification: JsonValue) => void + >(); + private readonly invalidationListeners = new Set<(reason: unknown) => void>(); + private readonly previousFallbackRequestHandler: ClientPublicSurface["fallbackRequestHandler"]; + private readonly previousFallbackNotificationHandler: ClientPublicSurface["fallbackNotificationHandler"]; + private readonly previousOnclose: ClientPublicSurface["onclose"]; + private disposed = false; + private isInvalidated = false; + + private readonly fallbackRequestHandler: NonNullable< + ClientPublicSurface["fallbackRequestHandler"] + > = async (request, context) => { + if (!isTaskInputMethod(request.method)) { + if (this.previousFallbackRequestHandler !== undefined) + return this.previousFallbackRequestHandler(request, context); + throw new ProtocolError(-32601, `Method not found: ${request.method}`); + } + const listener = this.serverRequestListeners.values().next().value; + if (listener === undefined) { + if (this.previousFallbackRequestHandler !== undefined) + return this.previousFallbackRequestHandler(request, context); + throw new ProtocolError(-32601, `Method not found: ${request.method}`); + } + if (!isJsonValue(request)) + throw new ProtocolError(-32600, "Inbound request is not JSON"); + const response = await listener({ request, requestContext: context }); + if (response.kind === "error") + throw new ProtocolError( + response.error.code, + response.error.message, + response.error.data, + ); + if (!isJsonRecord(response.result)) + throw new ProtocolError( + -32603, + "Inbound handler returned a non-object result", + ); + return response.result; + }; + + private readonly fallbackNotificationHandler: NonNullable< + ClientPublicSurface["fallbackNotificationHandler"] + > = async (notification) => { + await this.previousFallbackNotificationHandler?.(notification); + if (!isJsonValue(notification)) return; + for (const listener of [...this.notificationListeners]) + listener(notification); + }; + + private readonly onclose = (): void => { + try { + this.previousOnclose?.(); + } finally { + this.invalidate(new Error("MCP client connection closed")); + } + }; + + constructor( + private readonly client: ClientPublicSurface, + readonly endpointId: string, + ) { + if (adaptedClients.has(client)) + throw new TypeError( + "An ext-tasks adapter is already active for this Client", + ); + this.previousFallbackRequestHandler = client.fallbackRequestHandler; + this.previousFallbackNotificationHandler = + client.fallbackNotificationHandler; + this.previousOnclose = client.onclose; + this.taskCapabilities = clientTaskCapabilities(client); + adaptedClients.add(client); + client.fallbackRequestHandler = this.fallbackRequestHandler; + client.fallbackNotificationHandler = this.fallbackNotificationHandler; + client.onclose = this.onclose; + } + + get invalidated(): boolean { + return this.isInvalidated; + } + + async dispatch( + request: JsonValue, + options: { readonly signal?: AbortSignal } = {}, + ): Promise { + try { + const result = await this.client.request( + asClientRequest(request), + jsonValueSchema, + options.signal === undefined ? {} : { signal: options.signal }, + ); + return { kind: "result", result }; + } catch (error) { + if (error instanceof ProtocolError) { + const data = error.data; + return { + kind: "error", + error: { + code: error.code, + message: error.message, + ...(data === undefined || !isJsonValue(data) ? {} : { data }), + }, + }; + } + throw new DispatchError("MCP client request failed", false, { + cause: error, + }); + } + } + + onServerRequest( + handler: (incoming: IncomingServerRequest) => Promise, + ): () => void { + this.serverRequestListeners.add(handler); + return () => this.serverRequestListeners.delete(handler); + } + + onNotification(listener: (notification: JsonValue) => void): () => void { + this.notificationListeners.add(listener); + return () => this.notificationListeners.delete(listener); + } + + onInvalidated(listener: (reason: unknown) => void): () => void { + this.invalidationListeners.add(listener); + return () => this.invalidationListeners.delete(listener); + } + + [Symbol.dispose](): void { + if (this.disposed) return; + this.disposed = true; + if (this.client.fallbackRequestHandler === this.fallbackRequestHandler) + this.client.fallbackRequestHandler = this.previousFallbackRequestHandler; + if ( + this.client.fallbackNotificationHandler === + this.fallbackNotificationHandler + ) + this.client.fallbackNotificationHandler = + this.previousFallbackNotificationHandler; + if (this.client.onclose === this.onclose) + this.client.onclose = this.previousOnclose; + adaptedClients.delete(this.client); + this.serverRequestListeners.clear(); + this.notificationListeners.clear(); + this.invalidationListeners.clear(); + } + + private invalidate(reason: unknown): void { + if (this.isInvalidated) return; + this.isInvalidated = true; + for (const listener of [...this.invalidationListeners]) listener(reason); + } +} + +export function createSessionPortFromClient( + client: Client, + endpointId: string, +): ConnectedMcpSessionPort & Disposable { + return new ClientSessionPort(client, endpointId); +} diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts new file mode 100644 index 0000000..83ba5c7 --- /dev/null +++ b/packages/ext-tasks/src/client/session.ts @@ -0,0 +1,676 @@ +import { Client } from "@modelcontextprotocol/client"; +import { + isJsonValue, + type JsonValue, + type RuntimeCodec, + type TaskId, + type TaskSnapshot, +} from "../core/index.js"; +import { + CreateTaskResultV1Codec, + GetTaskResultV1Codec, + TaskStatusNotificationV1Codec, + shouldCallToolAsTaskV1, + type CallToolResultV1, + type TaskV1, + type ToolV1, +} from "../core/v1/index.js"; +import { + CreateTaskResultV2Codec, + GetTaskResultV2Codec, + TaskStatusNotificationV2Codec, + isCreateTaskResultV2, + withTaskCapabilityV2, + type CallToolResultV2, + type DetailedTaskV2, +} from "../core/v2/index.js"; +import { + InputCorrelationError, + type ApplicationInputRequest, + type InputCorrelationFailureReason, + type ResolvedInputExchangeContext, + type SerializedTaskReference, + type TaskEnabledSession, + type TaskHandle, + type ToolDeclarationProvider, + type ToolExecution, + type WithTasksOptions, +} from "./api.js"; +import { + ImmediateExecution, + TaskExecution, + defaultResultCodec, + reasonAsError, +} from "./execution.js"; +import { + defaultServerRequestResponse, + nextExecutionIdentifier, + requestParams, + throwIfAborted, + type OrdinaryInputCandidate, + type V1TaskInputCandidate, +} from "./input-routing.js"; +import { + decodeResult, + dispatchWithRetry, + linkAbortSignals, + responseResult, + withAbort, + type ConnectedMcpSessionPort, + type IncomingServerRequest, + type JsonRpcResponse, + type SessionTaskCapabilities, +} from "./port.js"; +import { + ClientSessionPort, + isClientPublicSurface, + isConnectedMcpSessionPort, +} from "./sdk-client-adapter.js"; +import { ManagedToolDeclarations } from "./tool-declarations.js"; +import { createTaskExecutionV1 } from "./task-protocol-v1.js"; +import { createTaskExecutionV2 } from "./task-protocol-v2.js"; + +class PortTaskEnabledSession< + TApplicationContext, +> implements TaskEnabledSession { + private closed = false; + private closeError: Error | undefined; + private readonly lifecycleController = new AbortController(); + private invalidationError: Error | undefined; + private readonly disposeListeners: readonly (() => void)[]; + private readonly declarations: ToolDeclarationProvider; + private readonly managedDeclarations: ManagedToolDeclarations | undefined; + private readonly ordinaryInputCandidates = new Map< + string, + OrdinaryInputCandidate + >(); + private readonly v1TaskInputCandidates = new Map< + string, + V1TaskInputCandidate + >(); + private readonly activeTaskExecutions = new Set< + TaskExecution + >(); + + constructor( + private readonly port: ConnectedMcpSessionPort, + private readonly options: WithTasksOptions, + disposePort?: () => void, + ) { + const reportError = (error: Error): void => { + try { + this.options.onError?.(error); + } catch (sinkError) { + console.error(sinkError); + } + }; + this.managedDeclarations = + options.tools === undefined + ? new ManagedToolDeclarations(port, reportError) + : undefined; + this.declarations = options.tools ?? this.managedDeclarations!; + const onSessionAbort = (): void => { + const error = + options.signal?.reason instanceof Error + ? options.signal.reason + : new DOMException("The session was aborted", "AbortError"); + this.invalidationError ??= error; + this.lifecycleController.abort(error); + this.managedDeclarations?.close(); + }; + options.signal?.addEventListener("abort", onSessionAbort, { once: true }); + this.disposeListeners = [ + port.onServerRequest(async (incoming) => + this.handleServerRequest(incoming), + ), + port.onNotification((notification) => + this.handleNotification(notification), + ), + port.onInvalidated((reason) => { + const error = reasonAsError(reason); + this.invalidationError ??= error; + this.lifecycleController.abort(error); + this.managedDeclarations?.close(); + }), + () => options.signal?.removeEventListener("abort", onSessionAbort), + () => this.managedDeclarations?.close(), + ...(disposePort === undefined ? [] : [disposePort]), + ]; + if (options.signal?.aborted === true) onSessionAbort(); + if (port.invalidated) { + const error = new Error("MCP session was invalidated"); + this.invalidationError = error; + this.lifecycleController.abort(error); + this.managedDeclarations?.close(); + } + } + + async callTool( + name: string, + params?: Readonly>, + options: { + readonly resultCodec?: RuntimeCodec; + readonly applicationContext?: TApplicationContext; + readonly signal?: AbortSignal; + readonly preferTask?: boolean; + } = {}, + ): Promise> { + this.assertUsable(); + const callLifecycle = linkAbortSignals( + this.lifecycleController.signal, + options.signal, + ); + const callSignal = callLifecycle.signal; + let declaration: ReturnType; + try { + throwIfAborted(callSignal); + await this.managedDeclarations?.ensureReady(callSignal); + this.assertUsable(); + declaration = this.declarations.currentTool(name); + } catch (error) { + callLifecycle.dispose(); + throw error; + } + if ( + this.port.taskCapabilities.generation === "v2" && + declaration !== undefined && + "execution" in declaration + ) { + callLifecycle.dispose(); + throw new Error( + "V1 tool declaration is incompatible with the V2 session", + ); + } + const requestParams: Record = { name }; + if (params !== undefined) requestParams.arguments = params; + const generation = this.port.taskCapabilities.generation; + const callAsTaskV1 = + generation === "v1" && + declaration !== undefined && + "execution" in declaration && + shouldCallToolAsTaskV1( + this.port.taskCapabilities.capabilities, + declaration as ToolV1, + options.preferTask, + ); + if (callAsTaskV1) requestParams.task = {}; + const executionId = nextExecutionIdentifier(); + if (!callAsTaskV1) { + this.ordinaryInputCandidates.set(executionId, { + lifetime: "basic", + generation: generation === "none" ? "v1" : generation, + toolName: name, + executionId, + applicationContext: options.applicationContext as TApplicationContext, + signal: callSignal, + }); + } + const dispatchPromise = dispatchWithRetry( + this.port, + { + method: "tools/call", + params: + generation === "v2" + ? withTaskCapabilityV2(requestParams) + : requestParams, + }, + callSignal, + "mutate", + ); + let response: JsonRpcResponse; + try { + response = await withAbort(dispatchPromise, callSignal); + } catch (error) { + void dispatchPromise.then( + (lateResponse) => + this.cleanupLateTaskCreation(lateResponse, generation, callAsTaskV1), + () => {}, + ); + throw error; + } finally { + this.ordinaryInputCandidates.delete(executionId); + callLifecycle.dispose(); + } + try { + this.assertUsable(); + throwIfAborted(callSignal); + } catch (error) { + this.cleanupLateTaskCreation(response, generation, callAsTaskV1); + throw error; + } + const wireResult = responseResult(response); + const codec = + options.resultCodec ?? + (defaultResultCodec(generation) as RuntimeCodec); + + if (generation === "v1" && callAsTaskV1) { + const created = decodeResult(CreateTaskResultV1Codec, wireResult); + const handle: TaskHandle & { readonly generation: "v1" } = { + generation: "v1", + taskId: created.task.taskId as TaskId, + originalOperation: "tools/call", + }; + const execution = createTaskExecutionV1({ + applicationContext: options.applicationContext as TApplicationContext, + handle, + initialTask: created.task, + resultCodec: codec, + port: this.port, + lifecycleSignal: this.lifecycleController.signal, + }); + return this.trackTaskExecution(execution, { + lifetime: "task-v1", + generation: "v1", + taskId: created.task.taskId as TaskId, + toolName: name, + executionId, + applicationContext: options.applicationContext as TApplicationContext, + signal: execution.inputSignal(), + }); + } + + if (generation === "v2" && isCreateTaskResultV2(wireResult)) { + const created = decodeResult(CreateTaskResultV2Codec, wireResult); + const handle: TaskHandle & { readonly generation: "v2" } = { + generation: "v2", + taskId: created.taskId as TaskId, + originalOperation: "tools/call", + }; + return this.trackTaskExecution( + createTaskExecutionV2({ + applicationContext: options.applicationContext as TApplicationContext, + handle, + initialTask: created, + resultCodec: codec, + port: this.port, + lifecycleSignal: this.lifecycleController.signal, + onInputRequest: this.options.onInputRequest, + reportError: (error) => this.reportBackgroundError(error), + }), + ); + } + + const resultPromise = Promise.resolve(decodeResult(codec, wireResult)); + return new ImmediateExecution( + options.applicationContext as TApplicationContext, + resultPromise, + ); + } + + async resumeTask( + reference: SerializedTaskReference, + options: { + readonly resultCodec?: RuntimeCodec; + readonly applicationContext?: TApplicationContext; + readonly signal?: AbortSignal; + } = {}, + ): Promise> { + this.assertUsable(); + const capabilities = this.port.taskCapabilities; + if (reference.endpointId !== this.port.endpointId) + throw new Error("Task reference belongs to a different endpoint"); + if (reference.generation !== capabilities.generation) + throw new Error("Task reference generation does not match this session"); + if (reference.originalOperation !== "tools/call") + throw new Error("Task reference operation is not supported"); + + const resumeLifecycle = linkAbortSignals( + this.lifecycleController.signal, + options.signal, + ); + const resumeSignal = resumeLifecycle.signal; + const executionId = nextExecutionIdentifier(); + const codec = + options.resultCodec ?? + (defaultResultCodec(reference.generation) as RuntimeCodec); + try { + throwIfAborted(resumeSignal); + const response = await dispatchWithRetry( + this.port, + { + method: "tasks/get", + params: + reference.generation === "v2" + ? withTaskCapabilityV2({ taskId: reference.taskId }) + : { taskId: reference.taskId }, + }, + resumeSignal, + "observe", + ); + this.assertUsable(); + throwIfAborted(resumeSignal); + + if (reference.generation === "v1") { + const task = decodeResult( + GetTaskResultV1Codec, + responseResult(response), + ); + const execution = createTaskExecutionV1({ + applicationContext: options.applicationContext as TApplicationContext, + handle: reference, + initialTask: task, + resultCodec: codec, + port: this.port, + lifecycleSignal: this.lifecycleController.signal, + }); + return this.trackTaskExecution(execution, { + lifetime: "task-v1", + generation: "v1", + taskId: reference.taskId, + toolName: "", + executionId, + applicationContext: options.applicationContext as TApplicationContext, + signal: execution.inputSignal(), + }); + } + + const task = decodeResult(GetTaskResultV2Codec, responseResult(response)); + return this.trackTaskExecution( + createTaskExecutionV2({ + applicationContext: options.applicationContext as TApplicationContext, + handle: reference, + initialTask: task, + initialDetailedTask: task, + resultCodec: codec, + port: this.port, + lifecycleSignal: this.lifecycleController.signal, + onInputRequest: this.options.onInputRequest, + reportError: (error) => this.reportBackgroundError(error), + }), + ); + } finally { + resumeLifecycle.dispose(); + } + } + + private cleanupLateTaskCreation( + response: JsonRpcResponse, + generation: SessionTaskCapabilities["generation"], + callAsTaskV1: boolean, + ): void { + if (response.kind !== "result") return; + let taskId: TaskId | undefined; + let params: JsonValue | undefined; + if (generation === "v1" && callAsTaskV1) { + const parsed = CreateTaskResultV1Codec.parse(response.result); + if (parsed.success) { + taskId = parsed.value.task.taskId as TaskId; + params = { taskId }; + } + } else if (generation === "v2" && isCreateTaskResultV2(response.result)) { + const parsed = CreateTaskResultV2Codec.parse(response.result); + if (parsed.success) { + taskId = parsed.value.taskId as TaskId; + params = withTaskCapabilityV2({ taskId }); + } + } + if (taskId === undefined || params === undefined) return; + void dispatchWithRetry( + this.port, + { method: "tasks/cancel", params }, + undefined, + "mutate", + ).catch(() => { + // A task returned after call abort is cleaned up on a best-effort basis. + }); + } + + close(): Promise { + if (!this.closed) { + this.closed = true; + for (const execution of this.activeTaskExecutions) { + void execution.close().catch(() => {}); + } + this.lifecycleController.abort( + new Error("Task-enabled session is closed"), + ); + for (const dispose of this.disposeListeners) { + try { + dispose(); + } catch (error) { + this.closeError ??= reasonAsError(error); + } + } + } + return this.closeError === undefined + ? Promise.resolve() + : Promise.reject(this.closeError); + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } + + private trackTaskExecution( + execution: TaskExecution, + v1InputCandidate?: V1TaskInputCandidate, + ): TaskExecution { + const tracked = execution as TaskExecution; + this.activeTaskExecutions.add(tracked); + if ( + v1InputCandidate !== undefined && + v1InputCandidate.signal?.aborted !== true + ) { + this.v1TaskInputCandidates.set( + v1InputCandidate.executionId, + v1InputCandidate, + ); + v1InputCandidate.signal?.addEventListener( + "abort", + () => this.v1TaskInputCandidates.delete(v1InputCandidate.executionId), + { once: true }, + ); + } + void execution + .result() + .catch(() => {}) + .finally(() => { + execution.endInputLifetime(); + this.activeTaskExecutions.delete(tracked); + if (v1InputCandidate !== undefined) + this.v1TaskInputCandidates.delete(v1InputCandidate.executionId); + }); + return execution; + } + + private handleNotification(notification: JsonValue): void { + this.managedDeclarations?.onNotification(notification); + if ( + notification === null || + Array.isArray(notification) || + typeof notification !== "object" + ) + return; + const method = (notification as Readonly>).method; + const generation = this.port.taskCapabilities.generation; + const parsed = + generation === "v1" && method === "notifications/tasks/status" + ? TaskStatusNotificationV1Codec.parse(notification) + : generation === "v2" && method === "notifications/tasks" + ? TaskStatusNotificationV2Codec.parse(notification) + : undefined; + if (parsed === undefined) return; + if (!parsed.success) { + this.reportBackgroundError(parsed.error); + return; + } + const snapshot: TaskSnapshot = + generation === "v1" + ? { generation: "v1", task: parsed.value.params as TaskV1 } + : { generation: "v2", task: parsed.value.params as DetailedTaskV2 }; + for (const execution of this.activeTaskExecutions) { + execution.onNotification(snapshot); + } + } + + private async handleServerRequest( + incoming: IncomingServerRequest, + ): Promise { + if ( + incoming.request === null || + Array.isArray(incoming.request) || + typeof incoming.request !== "object" + ) { + return defaultServerRequestResponse(incoming); + } + const wire = incoming.request as Readonly>; + const method = wire.method; + const request: ApplicationInputRequest | undefined = + method === "elicitation/create" + ? { kind: "elicitation", params: requestParams(wire) } + : method === "sampling/createMessage" + ? { kind: "sampling", params: requestParams(wire) } + : method === "roots/list" + ? { + kind: "roots", + ...(wire.params === undefined + ? {} + : { params: requestParams(wire) }), + } + : undefined; + if (request === undefined) return defaultServerRequestResponse(incoming); + const taskCandidates = [...this.v1TaskInputCandidates.values()]; + const ordinaryCandidates = [...this.ordinaryInputCandidates.values()]; + const params = request.params; + const meta = params?._meta; + const relatedTaskKey = "io.modelcontextprotocol/related-task"; + let evidence: "absent" | "invalid" | { readonly taskId: string }; + if (meta === undefined) evidence = "absent"; + else if (meta === null || Array.isArray(meta) || typeof meta !== "object") + evidence = "invalid"; + else { + const relatedTask = (meta as Readonly>)[ + relatedTaskKey + ]; + if (relatedTask === undefined) evidence = "absent"; + else if ( + relatedTask === null || + Array.isArray(relatedTask) || + typeof relatedTask !== "object" || + typeof (relatedTask as Readonly>).taskId !== + "string" + ) + evidence = "invalid"; + else + evidence = { + taskId: (relatedTask as Readonly>) + .taskId as string, + }; + } + const allCandidates = [...ordinaryCandidates, ...taskCandidates]; + const matches = + evidence === "absent" || evidence === "invalid" + ? allCandidates + : taskCandidates.filter( + (candidate) => candidate.taskId === evidence.taskId, + ); + const failureReason: InputCorrelationFailureReason | undefined = + evidence === "invalid" + ? "invalid-evidence" + : evidence === "absent" && matches.length === 0 + ? "missing-evidence" + : matches.length === 0 + ? "zero-matches" + : matches.length > 1 + ? "ambiguous-matches" + : undefined; + if (failureReason !== undefined) { + const candidates = matches.map((candidate) => ({ + generation: candidate.generation, + toolName: candidate.toolName, + executionId: candidate.executionId, + applicationContext: candidate.applicationContext, + })); + this.reportBackgroundError( + new InputCorrelationError( + this.port.taskCapabilities.generation === "none" + ? "v1" + : this.port.taskCapabilities.generation, + request.kind, + candidates, + failureReason, + ), + ); + return defaultServerRequestResponse(incoming); + } + const candidate = matches[0]; + if (this.options.onInputRequest === undefined) + return defaultServerRequestResponse(incoming); + try { + const context: ResolvedInputExchangeContext = + candidate.lifetime === "task-v1" + ? { + lifetime: "task-v1", + taskId: candidate.taskId, + applicationContext: candidate.applicationContext, + ...(candidate.signal === undefined + ? {} + : { signal: candidate.signal }), + } + : { + lifetime: "basic", + executionId: candidate.executionId, + applicationContext: candidate.applicationContext, + ...(candidate.signal === undefined + ? {} + : { signal: candidate.signal }), + }; + const result = await this.options.onInputRequest(request, context); + if (!isJsonValue(result)) + throw new Error("Input handler returned a non-JSON value"); + return { kind: "result", result }; + } catch { + return defaultServerRequestResponse(incoming); + } + } + + private reportBackgroundError(error: Error): void { + try { + if (this.options.onError === undefined) console.error(error); + else this.options.onError(error); + } catch (sinkError) { + console.error(sinkError); + } + } + + private assertUsable(): void { + if (this.invalidationError !== undefined) throw this.invalidationError; + if (this.closed) throw new Error("Task-enabled session is closed"); + } +} + +export function withTasks( + session: ConnectedMcpSessionPort, + options?: WithTasksOptions, +): TaskEnabledSession; +export function withTasks( + client: Client, + options: WithTasksOptions & { + readonly endpointId: string; + }, +): TaskEnabledSession; +export function withTasks( + session: ConnectedMcpSessionPort | Client, + options: WithTasksOptions & { + readonly endpointId?: string; + } = {}, +): TaskEnabledSession { + if (isConnectedMcpSessionPort(session)) + return new PortTaskEnabledSession(session, options); + if (!isClientPublicSurface(session)) + throw new TypeError( + "withTasks requires a ConnectedMcpSessionPort or Client-compatible object", + ); + const endpointId = options.endpointId; + if (endpointId === undefined) + throw new TypeError("withTasks(Client) requires options.endpointId"); + const port = new ClientSessionPort(session, endpointId); + try { + return new PortTaskEnabledSession(port, options, () => + port[Symbol.dispose](), + ); + } catch (error) { + port[Symbol.dispose](); + throw error; + } +} diff --git a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts new file mode 100644 index 0000000..d51b348 --- /dev/null +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -0,0 +1,661 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { expectRecord } from "../core/index.js"; +import { + DispatchError, + JsonRpcResponseError, + TaskExecutionClosedError, + TaskUpdatesAlreadyAcquiredError, + withTasks, +} from "./index.js"; +import { deterministicJson } from "./execution.js"; +import { + FakePort, + asJson, + formatJson, + asError, +} from "../../test-support/client/fake-port.js"; + +describe("task lifecycle and races", () => { + it("canonicalizes undefined values deterministically", () => { + expect(deterministicJson(undefined)).toBe("[undefined]"); + expect(deterministicJson({ keep: 1, omit: undefined })).toBe('{"keep":1}'); + }); + it("shares cancellation and enforces single-consumer task updates", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "pending", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ); + }); + if (record.method === "tasks/cancel") { + cancelCalls += 1; + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + execution.updates(); + expect(() => execution.updates()).toThrow(TaskUpdatesAlreadyAcquiredError); + const firstCancel = execution.cancel(); + expect(execution.cancel()).toBe(firstCancel); + await firstCancel; + expect(cancelCalls).toBe(1); + expect( + port.requests.find( + (request) => expectRecord(request).method === "tasks/cancel", + ), + ).toMatchObject({ + params: { + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }, + }); + await execution.close(); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + await session.close(); + }); + + it("session close cancels and closes active task executions", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "session-close", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ); + }); + if (record.method === "tasks/cancel") { + cancelCalls += 1; + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await session.close(); + expect(cancelCalls).toBe(1); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + }); + + it("retries task observations once after any DispatchError", async () => { + await fc.assert( + fc.asyncProperty(fc.boolean(), async (retryable) => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "retry-get", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") { + getCalls += 1; + if (getCalls === 1) + throw new DispatchError("observe failed", retryable); + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "retry-get", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }), + }; + } + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(getCalls).toBe(2); + await session.close(); + }), + { numRuns: 10 }, + ); + }); + + it("retries cancellation only for proven retryable dispatch failures", async () => { + await fc.assert( + fc.asyncProperty(fc.boolean(), async (retryable) => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "retry-cancel", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ); + }); + if (record.method === "tasks/cancel") { + cancelCalls += 1; + if (cancelCalls === 1) + throw new DispatchError("cancel failed", retryable); + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + if (retryable) + await expect(execution.cancel()).resolves.toBeUndefined(); + else await expect(execution.cancel()).rejects.toThrow("cancel failed"); + expect(cancelCalls).toBe(retryable ? 2 : 1); + await execution.close(); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + await session.close(); + }), + ); + }); + + it("conflates nonterminal task updates and always delivers terminal", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "conflate", + status: "working", + statusMessage: "initial", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + pollIntervalMs: 1000, + }), + }; + if (record.method === "tasks/get") return new Promise(() => {}); + if (record.method === "tasks/cancel") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const iterator = execution.updates()[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ + value: { task: { statusMessage: "initial" } }, + }); + for (const statusMessage of ["one", "one", "two", "three"]) { + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "conflate", + status: "working", + statusMessage, + createdAt: "a", + lastUpdatedAt: statusMessage, + ttlMs: null, + pollIntervalMs: 1000, + }, + }), + ); + } + await Promise.resolve(); + await expect(iterator.next()).resolves.toMatchObject({ + value: { task: { statusMessage: "three" } }, + }); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "conflate", + status: "completed", + createdAt: "a", + lastUpdatedAt: "z", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + }), + ); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "conflate", + status: "working", + statusMessage: "late", + createdAt: "a", + lastUpdatedAt: "late", + ttlMs: null, + pollIntervalMs: 1000, + }, + }), + ); + await expect(iterator.next()).resolves.toMatchObject({ + value: { task: { status: "completed" } }, + }); + await expect(iterator.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + await session.close(); + }); + + it("does not retry complete JSON-RPC task errors", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "rpc-error", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") { + getCalls += 1; + return { kind: "error", error: { code: -32000, message: "failed" } }; + } + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).rejects.toBeInstanceOf( + JsonRpcResponseError, + ); + expect(getCalls).toBe(1); + await session.close(); + }); + + it("routes matching task notifications without cancelling the task", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + let getCalls = 0; + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "notify", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + pollIntervalMs: 1000, + }), + }; + if (record.method === "tasks/get") { + getCalls += 1; + return new Promise(() => {}); + } + if (record.method === "tasks/cancel") { + cancelCalls += 1; + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const observer = new AbortController(); + const iterator = execution.updates(observer.signal)[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ + value: { + generation: "v2", + task: { taskId: "notify", status: "working" }, + }, + }); + const waiting = iterator.next(); + observer.abort(new Error("observer done")); + await expect(waiting).rejects.toThrow("observer done"); + expect(cancelCalls).toBe(0); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "wrong", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + }), + ); + await Promise.resolve(); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "notify", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + }), + ); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(cancelCalls).toBe(0); + expect(getCalls).toBe(0); + await session.close(); + }); + + it("closes promptly when remote cancellation never settles", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "stuck-cancel", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ); + }); + if (record.method === "tasks/cancel") return new Promise(() => {}); + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await expect(execution.close()).resolves.toBeUndefined(); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + await expect(session.close()).resolves.toBeUndefined(); + }); + + it("invalidating a session aborts active task executions", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "invalidate-active", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ); + }); + if (record.method === "tasks/cancel") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + port.invalidate(new Error("session replaced")); + await expect(execution.result()).rejects.toThrow("session replaced"); + await session.close(); + }); + + it("a terminal notification preempts an in-flight observation", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getStarted = false; + let observationSignal: AbortSignal | undefined; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "preempt", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + pollIntervalMs: 10, + }), + }; + if (record.method === "tasks/get") { + getStarted = true; + const signal = options?.signal; + if (signal === undefined) + throw new Error("observation signal is required"); + observationSignal = signal; + return new Promise((_resolve, reject) => + signal.addEventListener( + "abort", + () => reject(asError(signal.reason)), + { + once: true, + }, + ), + ); + } + if (record.method === "tasks/cancel") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + while (!getStarted) await new Promise((resolve) => setTimeout(resolve, 1)); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "preempt", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + }), + ); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(observationSignal?.aborted).toBe(true); + await session.close(); + }); + + it("caller abort does not poison the shared cancellation attempt", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + let finishCancel: (() => void) | undefined; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "cancel-waiter", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ), + ); + if (record.method === "tasks/cancel") { + cancelCalls += 1; + await new Promise((resolve) => { + finishCancel = resolve; + }); + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const waiter = new AbortController(); + const first = execution.cancel(waiter.signal); + waiter.abort(new Error("waiter stopped")); + await expect(first).rejects.toThrow("waiter stopped"); + const second = execution.cancel(); + finishCancel?.(); + await expect(second).resolves.toBeUndefined(); + expect(cancelCalls).toBe(1); + await execution.close(); + await session.close(); + }); +}); diff --git a/packages/ext-tasks/src/client/task-protocol-v1.ts b/packages/ext-tasks/src/client/task-protocol-v1.ts new file mode 100644 index 0000000..7c09e5c --- /dev/null +++ b/packages/ext-tasks/src/client/task-protocol-v1.ts @@ -0,0 +1,117 @@ +/** Generation-specific requester-side V1 task execution. */ + +import type { RuntimeCodec } from "../core/index.js"; +import { + CancelTaskResultV1Codec, + GetTaskResultV1Codec, + TaskResultV1Codec, + type TaskV1, +} from "../core/v1/index.js"; +import { TaskCancellationUnsupportedError, type TaskHandle } from "./api.js"; +import { + DEFAULT_TASK_POLL_INTERVAL_MS, + TaskExecution, + terminalStatus, +} from "./execution.js"; +import { + decodeResult, + dispatchWithRetry, + responseResult, + type ConnectedMcpSessionPort, +} from "./port.js"; + +export function createTaskExecutionV1(options: { + readonly applicationContext: TApplicationContext; + readonly handle: TaskHandle & { readonly generation: "v1" }; + readonly initialTask: TaskV1; + readonly resultCodec: RuntimeCodec; + readonly port: ConnectedMcpSessionPort; + readonly lifecycleSignal: AbortSignal; +}): TaskExecution { + const { applicationContext, handle, initialTask, resultCodec, port } = + options; + return new TaskExecution( + applicationContext, + handle, + port.endpointId, + { generation: "v1", task: initialTask }, + async ( + accept, + waitForTurn, + observe, + signal, + cancelledError, + closedError, + isClosed, + ) => { + let current = initialTask; + let notificationSequence = 0; + while (!terminalStatus(current.status)) { + const turn = await waitForTurn( + notificationSequence, + Math.max( + DEFAULT_TASK_POLL_INTERVAL_MS, + current.pollInterval ?? DEFAULT_TASK_POLL_INTERVAL_MS, + ), + ); + const observed = + turn ?? + (await observe(notificationSequence, (observationSignal) => + dispatchWithRetry( + port, + { method: "tasks/get", params: { taskId: handle.taskId } }, + observationSignal, + "observe", + ).then((response) => ({ + generation: "v1" as const, + task: decodeResult( + GetTaskResultV1Codec, + responseResult(response), + ), + })), + )); + if (observed?.snapshot.generation !== "v1") continue; + notificationSequence = observed.sequence; + current = observed.snapshot.task; + if (!isClosed()) accept({ generation: "v1", task: current }); + } + if (isClosed()) throw closedError; + if (current.status === "cancelled") throw cancelledError; + if (current.status === "failed") + throw new Error(current.statusMessage ?? "Task failed"); + const taskResult = responseResult( + await dispatchWithRetry( + port, + { method: "tasks/result", params: { taskId: handle.taskId } }, + signal, + "observe", + ), + ); + decodeResult(TaskResultV1Codec, taskResult); + return decodeResult(resultCodec, taskResult); + }, + async (signal) => { + const capabilities = port.taskCapabilities; + if ( + capabilities.generation !== "v1" || + capabilities.capabilities.cancel === undefined + ) + throw new TaskCancellationUnsupportedError(); + decodeResult( + CancelTaskResultV1Codec, + responseResult( + await dispatchWithRetry( + port, + { + method: "tasks/cancel", + params: { taskId: handle.taskId }, + }, + signal, + "mutate", + ), + ), + ); + }, + options.lifecycleSignal, + ); +} diff --git a/packages/ext-tasks/src/client/task-protocol-v2.ts b/packages/ext-tasks/src/client/task-protocol-v2.ts new file mode 100644 index 0000000..fdd160e --- /dev/null +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -0,0 +1,263 @@ +/** Generation-specific requester-side V2 task execution. */ + +import type { JsonValue, RuntimeCodec } from "../core/index.js"; +import { + CancelTaskResultV2Codec, + CreateMessageResultV2Codec, + ElicitResultV2Codec, + GetTaskResultV2Codec, + ListRootsResultV2Codec, + UpdateTaskResultV2Codec, + withTaskCapabilityV2, + type DetailedTaskV2, + type InputRequestV2, + type InputResponseV2, + type TaskV2, +} from "../core/v2/index.js"; +import { + JsonRpcResponseError, + type ApplicationInputHandler, + type ApplicationInputRequest, + type TaskHandle, +} from "./api.js"; +import { + DEFAULT_TASK_POLL_INTERVAL_MS, + TaskExecution, + deterministicJson, + terminalStatus, +} from "./execution.js"; +import { + decodeResult, + dispatchWithRetry, + responseResult, + type ConnectedMcpSessionPort, +} from "./port.js"; + +export function createTaskExecutionV2(options: { + readonly applicationContext: TApplicationContext; + readonly handle: TaskHandle & { readonly generation: "v2" }; + readonly initialTask: TaskV2; + readonly initialDetailedTask?: DetailedTaskV2; + readonly resultCodec: RuntimeCodec; + readonly port: ConnectedMcpSessionPort; + readonly lifecycleSignal: AbortSignal; + readonly onInputRequest?: ApplicationInputHandler["handle"]; + readonly reportError: (error: Error) => void; +}): TaskExecution { + const { + applicationContext, + handle, + initialTask, + initialDetailedTask, + resultCodec, + port, + } = options; + return new TaskExecution( + applicationContext, + handle, + port.endpointId, + { generation: "v2", task: initialTask }, + async ( + accept, + waitForTurn, + observe, + signal, + cancelledError, + closedError, + isClosed, + inputSignal, + ) => { + let status = initialTask.status; + let current = initialDetailedTask; + let notificationSequence = 0; + const acquiredInputs = new Map(); + if (current !== undefined) + await acquireInputs( + current, + acquiredInputs, + inputSignal, + signal, + options, + ); + while (!terminalStatus(status)) { + const delayMs = Math.max( + DEFAULT_TASK_POLL_INTERVAL_MS, + current?.pollIntervalMs ?? + initialTask.pollIntervalMs ?? + DEFAULT_TASK_POLL_INTERVAL_MS, + ); + const turn = await waitForTurn(notificationSequence, delayMs); + const observed = + turn ?? + (await observe(notificationSequence, (observationSignal) => + dispatchWithRetry( + port, + { + method: "tasks/get", + params: withTaskCapabilityV2({ taskId: handle.taskId }), + }, + observationSignal, + "observe", + ).then((response) => ({ + generation: "v2" as const, + task: decodeResult( + GetTaskResultV2Codec, + responseResult(response), + ), + })), + )); + if (observed?.snapshot.generation !== "v2") continue; + notificationSequence = observed.sequence; + current = observed.snapshot.task as DetailedTaskV2; + status = current.status; + if (!isClosed()) accept({ generation: "v2", task: current }); + await acquireInputs( + current, + acquiredInputs, + inputSignal, + signal, + options, + ); + } + if (isClosed()) throw closedError; + if (current === undefined) { + current = decodeResult( + GetTaskResultV2Codec, + responseResult( + await dispatchWithRetry( + port, + { + method: "tasks/get", + params: withTaskCapabilityV2({ taskId: handle.taskId }), + }, + signal, + "observe", + ), + ), + ); + if (!isClosed()) accept({ generation: "v2", task: current }); + } + if (current.status === "cancelled") throw cancelledError; + if (current.status === "failed") + throw new JsonRpcResponseError(current.error); + if (current.status !== "completed") + throw new Error(`Unsupported terminal task status: ${current.status}`); + return decodeResult(resultCodec, current.result); + }, + async (signal) => { + decodeResult( + CancelTaskResultV2Codec, + responseResult( + await dispatchWithRetry( + port, + { + method: "tasks/cancel", + params: withTaskCapabilityV2({ taskId: handle.taskId }), + }, + signal, + "mutate", + ), + ), + ); + }, + options.lifecycleSignal, + ); +} + +async function acquireInputs( + task: DetailedTaskV2, + acquiredInputs: Map, + inputSignal: AbortSignal, + signal: AbortSignal, + options: { + readonly applicationContext: TApplicationContext; + readonly onInputRequest?: ApplicationInputHandler["handle"]; + readonly reportError: (error: Error) => void; + readonly port: ConnectedMcpSessionPort; + }, +): Promise { + if (task.status !== "input_required") return; + const inputResponses: Record = {}; + for (const [inputKey, inputRequest] of Object.entries(task.inputRequests)) { + const signature = deterministicJson(inputRequest); + const acquiredSignature = acquiredInputs.get(inputKey); + if (acquiredSignature !== undefined) { + if (acquiredSignature !== signature) + options.reportError( + new Error(`V2 task input key ${inputKey} was reused incompatibly`), + ); + continue; + } + acquiredInputs.set(inputKey, signature); + const request: InputRequestV2 = inputRequest; + const projected: ApplicationInputRequest | undefined = + request.method === "sampling/createMessage" + ? { kind: "sampling", params: request.params } + : request.method === "roots/list" + ? { + kind: "roots", + ...(request.params === undefined + ? {} + : { params: request.params }), + } + : request.method === "elicitation/create" + ? { kind: "elicitation", params: request.params } + : undefined; + if (projected === undefined) { + options.reportError( + new Error(`Unknown V2 task input method for key ${inputKey}`), + ); + continue; + } + let result: unknown; + if (options.onInputRequest === undefined) { + if (request.method !== "elicitation/create") continue; + result = { action: "cancel" }; + } else { + try { + result = await options.onInputRequest(projected, { + lifetime: "task-v2", + taskId: task.taskId, + inputKey, + applicationContext: options.applicationContext, + signal: inputSignal, + }); + } catch { + if (inputSignal.aborted) return; + if (request.method !== "elicitation/create") continue; + result = { action: "cancel" }; + } + } + try { + const responseCodec = + request.method === "sampling/createMessage" + ? CreateMessageResultV2Codec + : request.method === "roots/list" + ? ListRootsResultV2Codec + : ElicitResultV2Codec; + inputResponses[inputKey] = decodeResult( + responseCodec as RuntimeCodec, + result as JsonValue, + ); + } catch (error) { + options.reportError( + error instanceof Error ? error : new Error(String(error)), + ); + } + } + if (inputSignal.aborted || Object.keys(inputResponses).length === 0) return; + decodeResult( + UpdateTaskResultV2Codec, + responseResult( + await dispatchWithRetry( + options.port, + { + method: "tasks/update", + params: withTaskCapabilityV2({ taskId: task.taskId, inputResponses }), + }, + signal, + "mutate", + ), + ), + ); +} diff --git a/packages/ext-tasks/src/client/task-resumption.test.ts b/packages/ext-tasks/src/client/task-resumption.test.ts new file mode 100644 index 0000000..2384a13 --- /dev/null +++ b/packages/ext-tasks/src/client/task-resumption.test.ts @@ -0,0 +1,363 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { expectRecord, type TaskId } from "../core/index.js"; +import { + DispatchError, + InputCorrelationError, + withTasks, + type JsonRpcResponse, + type SessionTaskCapabilities, + type SerializedTaskReference, +} from "./index.js"; +import { + FakePort, + asJson, + formatJson, + asError, +} from "../../test-support/client/fake-port.js"; + +describe("task reference resumption", () => { + it("does not expose reference serialization on immediate executions", async () => { + const port = new FakePort(); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("immediate"); + expect(execution.kind).toBe("immediate"); + expect("serializeReference" in execution).toBe(false); + await session.close(); + }); + + it("rejects endpoint, generation, and operation mismatches before dispatch", async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom("endpoint", "generation", "operation"), + fc.string({ minLength: 1 }), + async (mismatch, suffix) => { + const port = new FakePort( + { generation: "v2", capabilities: {} }, + "endpoint-a", + ); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const reference = { + endpointId: + mismatch === "endpoint" ? `other-${suffix}` : "endpoint-a", + generation: mismatch === "generation" ? "v1" : "v2", + taskId: `task-${suffix}`, + originalOperation: + mismatch === "operation" ? "unsupported/operation" : "tools/call", + } as SerializedTaskReference; + await expect(session.resumeTask(reference)).rejects.toThrow(); + expect(port.requests).toHaveLength(0); + await session.close(); + }, + ), + { numRuns: 20 }, + ); + }); + + it("labels resumed V1 candidates without inventing a tool name", async () => { + const port = new FakePort( + { + generation: "v1", + capabilities: { requests: { tools: { call: {} } }, cancel: {} }, + }, + "resume-endpoint", + ); + let finishOrdinary: ((response: JsonRpcResponse) => void) | undefined; + let getCalls = 0; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tasks/get") { + getCalls += 1; + if (getCalls === 1) + return { + kind: "result", + result: asJson({ + taskId: "resumed-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }), + }; + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ), + ); + } + if (record.method === "tools/call") + return new Promise((resolve) => { + finishOrdinary = resolve; + }); + if (record.method === "tasks/cancel") + return { + kind: "result", + result: asJson({ + taskId: "resumed-task", + status: "cancelled", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }), + }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const errors: Error[] = []; + const session = withTasks(port, { + tools: { + currentTool: (name) => + name === "ordinary" + ? { name, inputSchema: { type: "object" } } + : undefined, + }, + onError: (error) => errors.push(error), + }); + const resumed = await session.resumeTask({ + endpointId: port.endpointId, + generation: "v1", + taskId: "resumed-task" as TaskId, + originalOperation: "tools/call", + }); + const ordinary = session.callTool("ordinary"); + while (finishOrdinary === undefined) await Promise.resolve(); + await port.serve({ method: "elicitation/create", params: {} }); + expect(errors).toHaveLength(1); + const candidates = (errors[0] as InputCorrelationError).candidates; + expect(candidates.map((candidate) => candidate.toolName)).toEqual([ + "ordinary", + "", + ]); + expect(candidates.every((candidate) => !("taskId" in candidate))).toBe( + true, + ); + finishOrdinary({ kind: "result", result: { content: [] } }); + await ordinary; + await resumed.close(); + await session.close(); + }); + + it("roundtrips serialized task references across V1/V2 terminal and nonterminal tasks", async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom("v1", "v2"), + fc.boolean(), + fc.stringMatching(/^[a-z0-9]{1,12}$/), + async (generation, initiallyTerminal, taskSuffix) => { + const taskId = `task-${taskSuffix}`; + const endpointId = `endpoint-${taskSuffix}`; + const capabilities: SessionTaskCapabilities = + generation === "v1" + ? { + generation: "v1", + capabilities: { + requests: { tools: { call: {} } }, + cancel: {}, + }, + } + : { generation: "v2", capabilities: {} }; + const sourcePort = new FakePort(capabilities, endpointId); + sourcePort.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tools/call") + return generation === "v1" + ? { + kind: "result", + result: asJson({ + task: { + taskId, + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + pollInterval: 1000, + }, + }), + } + : { + kind: "result", + result: asJson({ + resultType: "task", + taskId, + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + pollIntervalMs: 1000, + }), + }; + if (method === "tasks/cancel") + return { + kind: "result", + result: asJson( + generation === "v2" ? { resultType: "complete" } : {}, + ), + }; + throw new Error(`unexpected source method ${formatJson(method)}`); + }; + const sourceSession = withTasks(sourcePort, { + tools: { + currentTool: () => + generation === "v1" + ? { + name: "roundtrip", + inputSchema: {}, + execution: { taskSupport: "required" }, + } + : { name: "roundtrip", inputSchema: {} }, + }, + }); + const sourceExecution = await sourceSession.callTool("roundtrip"); + expect(sourceExecution.kind).toBe("task"); + if (sourceExecution.kind !== "task") throw new Error("expected task"); + const reference = sourceExecution.serializeReference(); + expect(reference).toEqual({ + endpointId, + generation, + taskId, + originalOperation: "tools/call", + }); + + const resumedPort = new FakePort(capabilities, endpointId); + let getCalls = 0; + resumedPort.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tasks/get") { + getCalls += 1; + const terminal = initiallyTerminal || getCalls > 1; + return generation === "v1" + ? { + kind: "result", + result: asJson({ + taskId, + status: terminal ? "completed" : "working", + createdAt: "a", + lastUpdatedAt: terminal ? "b" : "a", + ttl: null, + pollInterval: 0, + }), + } + : { + kind: "result", + result: asJson({ + resultType: "complete", + taskId, + status: terminal ? "completed" : "working", + createdAt: "a", + lastUpdatedAt: terminal ? "b" : "a", + ttlMs: null, + pollIntervalMs: 0, + ...(terminal + ? { result: { resultType: "complete", content: [] } } + : {}), + }), + }; + } + if (method === "tasks/result") + return { + kind: "result", + result: asJson({ + content: [{ type: "text", text: taskSuffix }], + }), + }; + if (method === "tasks/cancel") + return { + kind: "result", + result: asJson( + generation === "v2" ? { resultType: "complete" } : {}, + ), + }; + throw new Error(`unexpected resumed method ${formatJson(method)}`); + }; + const applicationContext = { taskSuffix }; + const resumedSession = withTasks( + resumedPort, + { + tools: { currentTool: () => undefined }, + }, + ); + const resumed = await resumedSession.resumeTask(reference, { + applicationContext, + }); + expect(resumed.kind).toBe("task"); + if (resumed.kind !== "task") throw new Error("expected resumed task"); + expect(resumed.applicationContext).toBe(applicationContext); + expect(resumed.serializeReference()).toEqual(reference); + await expect(resumed.result()).resolves.toEqual( + generation === "v1" + ? { content: [{ type: "text", text: taskSuffix }] } + : { resultType: "complete", content: [] }, + ); + expect(getCalls).toBe(initiallyTerminal ? 1 : 2); + const firstRequest = expectRecord(resumedPort.requests[0]); + expect(firstRequest.method).toBe("tasks/get"); + if (generation === "v2") + expect(firstRequest.params).toMatchObject({ + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }); + expect( + resumedPort.requests.some( + (request) => expectRecord(request).method === "tasks/result", + ), + ).toBe(generation === "v1"); + await resumedSession.close(); + await sourceSession.close(); + }, + ), + { numRuns: 12 }, + ); + }); + + it("retries the initial resumed observation once for any DispatchError", async () => { + await fc.assert( + fc.asyncProperty(fc.boolean(), async (retryable) => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let calls = 0; + port.dispatchHandler = async () => { + await Promise.resolve(); + calls += 1; + if (calls === 1) + throw new DispatchError("initial get failed", retryable); + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "retry-resume", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }), + }; + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.resumeTask({ + endpointId: port.endpointId, + generation: "v2", + taskId: "retry-resume" as TaskId, + originalOperation: "tools/call", + }); + await expect(execution.result()).resolves.toMatchObject({ + content: [], + }); + expect(calls).toBe(2); + await session.close(); + }), + { numRuns: 10 }, + ); + }); +}); diff --git a/packages/ext-tasks/src/client/tool-declarations.ts b/packages/ext-tasks/src/client/tool-declarations.ts new file mode 100644 index 0000000..26588c7 --- /dev/null +++ b/packages/ext-tasks/src/client/tool-declarations.ts @@ -0,0 +1,142 @@ +import { isJsonArray, type JsonValue } from "../core/index.js"; +import { ToolV1Codec, type ToolV1 } from "../core/v1/index.js"; +import { ToolV2Codec, type ToolV2 } from "../core/v2/index.js"; +import { JsonRpcResponseError, type ToolDeclarationProvider } from "./api.js"; +import type { ConnectedMcpSessionPort } from "./port.js"; +import { throwIfAborted } from "./input-routing.js"; + +export class ManagedToolDeclarations implements ToolDeclarationProvider { + private tools = new Map(); + private refreshSequence = 0; + private refreshController: AbortController | undefined; + private initialReady: Promise; + private closed = false; + + constructor( + private readonly port: ConnectedMcpSessionPort, + private readonly reportError: (error: Error) => void, + ) { + this.initialReady = this.refresh(); + void this.initialReady.catch(() => {}); + } + + currentTool(name: string): ToolV1 | ToolV2 | undefined { + return this.tools.get(name); + } + + async ensureReady(signal?: AbortSignal): Promise { + throwIfAborted(signal); + const wait = async (): Promise => { + try { + await this.initialReady; + } catch (error) { + if ( + this.closed || + (error instanceof DOMException && error.name === "AbortError") + ) + throw error; + this.initialReady = this.refresh(); + void this.initialReady.catch(() => {}); + await this.initialReady; + } + }; + const waiting = wait(); + if (signal === undefined) return waiting; + let onAbort: (() => void) | undefined; + const aborted = new Promise((_, reject) => { + onAbort = () => + reject( + signal.reason instanceof Error + ? signal.reason + : new DOMException("The operation was aborted", "AbortError"), + ); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + await Promise.race([waiting, aborted]); + } finally { + if (onAbort !== undefined) signal.removeEventListener("abort", onAbort); + } + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.refreshController?.abort(); + } + + onNotification(notification: JsonValue): void { + if (this.closed) return; + if ( + notification === null || + Array.isArray(notification) || + typeof notification !== "object" + ) + return; + const record = notification as Readonly>; + if (record.method !== "notifications/tools/list_changed") return; + void this.refresh().catch((error: unknown) => { + if (!(error instanceof DOMException && error.name === "AbortError")) { + this.reportError( + error instanceof Error + ? error + : new Error("Tool refresh failed", { cause: error }), + ); + } + }); + } + + private async refresh(): Promise { + if (this.closed) + throw new DOMException("Tool declarations are closed", "AbortError"); + const sequence = ++this.refreshSequence; + this.refreshController?.abort(); + const controller = new AbortController(); + this.refreshController = controller; + const decoded = new Map(); + let cursor: string | undefined; + do { + const response = await this.port.dispatch( + { + method: "tools/list", + params: cursor === undefined ? {} : { cursor }, + }, + { signal: controller.signal }, + ); + if (response.kind === "error") + throw new JsonRpcResponseError(response.error); + if ( + response.result === null || + Array.isArray(response.result) || + typeof response.result !== "object" + ) { + throw new Error("tools/list result must be an object"); + } + const result = response.result as Readonly>; + const listed = result.tools; + if (!isJsonArray(listed)) + throw new Error("tools/list result must contain tools"); + for (const value of listed) { + const parsed = + this.port.taskCapabilities.generation === "v1" + ? ToolV1Codec.parse(value) + : this.port.taskCapabilities.generation === "v2" + ? ToolV2Codec.parse(value) + : (() => { + const v2 = ToolV2Codec.parse(value); + return v2.success ? v2 : ToolV1Codec.parse(value); + })(); + if (!parsed.success) throw parsed.error; + if (decoded.has(parsed.value.name)) { + this.reportError( + new Error(`Duplicate tool declaration: ${parsed.value.name}`), + ); + } + decoded.set(parsed.value.name, parsed.value); + } + cursor = + typeof result.nextCursor === "string" ? result.nextCursor : undefined; + } while (cursor !== undefined); + if (sequence === this.refreshSequence) this.tools = decoded; + } +} diff --git a/packages/ext-tasks/src/client/v1-input-task.test.ts b/packages/ext-tasks/src/client/v1-input-task.test.ts new file mode 100644 index 0000000..80fa7e2 --- /dev/null +++ b/packages/ext-tasks/src/client/v1-input-task.test.ts @@ -0,0 +1,690 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { expectRecord, type JsonValue } from "../core/index.js"; +import { + type ServerTaskCapabilitiesV1, + type ToolV1, +} from "../core/v1/index.js"; +import { + InputCorrelationError, + TaskCancellationUnsupportedError, + TaskExecutionClosedError, + withTasks, + type JsonRpcResponse, +} from "./index.js"; +import { + FakePort, + asJson, + formatJson, + asError, +} from "../../test-support/client/fake-port.js"; + +describe("V1 input and task behavior", () => { + it("settles default V1 input declines with method-specific protocol values", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + const errors: Error[] = []; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + onError: (error) => errors.push(error), + }); + await expect( + port.serve({ method: "elicitation/create", params: {} }), + ).resolves.toEqual({ + kind: "result", + result: { action: "cancel" }, + }); + for (const method of ["sampling/createMessage", "roots/list"]) { + await expect(port.serve({ method, params: {} })).resolves.toEqual({ + kind: "error", + error: { code: -32603, message: "Internal error" }, + }); + } + expect(errors).toHaveLength(3); + expect( + errors.every((error) => error instanceof InputCorrelationError), + ).toBe(true); + expect( + errors.map((error) => (error as InputCorrelationError).reason), + ).toEqual(["missing-evidence", "missing-evidence", "missing-evidence"]); + await session.close(); + }); + + it("routes ordinary input requests with the execution context", async () => { + const cases = [ + { + method: "elicitation/create", + result: { action: "accept", content: { value: "ok" } }, + }, + { + method: "sampling/createMessage", + result: { + model: "m", + role: "assistant", + content: { type: "text", text: "ok" }, + }, + }, + { method: "roots/list", result: { roots: [{ uri: "file:///tmp" }] } }, + ] as const; + for (const input of cases) { + const port = new FakePort({ generation: "v1", capabilities: {} }); + const observed: unknown[] = []; + port.dispatchHandler = async () => { + observed.push( + await port.serve({ method: input.method, params: { prompt: "p" } }), + ); + return { kind: "result", result: { content: [] } }; + }; + const session = withTasks<{ readonly marker: string }>(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async (request, context) => { + await Promise.resolve(); + observed.push({ request, context }); + return input.result as never; + }, + }); + await session.callTool("x", undefined, { + applicationContext: { marker: "ctx" }, + }); + expect(observed[0]).toMatchObject({ + request: { params: { prompt: "p" } }, + context: { lifetime: "basic", applicationContext: { marker: "ctx" } }, + }); + expect( + (observed[0] as { context: { executionId: string } }).context + .executionId, + ).toMatch(/^execution-/); + expect(observed[1]).toEqual({ kind: "result", result: input.result }); + await session.close(); + } + }); + + it("fails closed when the input handler rejects", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + let settlement: JsonRpcResponse | undefined; + port.dispatchHandler = async () => { + settlement = await port.serve({ + method: "elicitation/create", + params: {}, + }); + return { kind: "result", result: { content: [] } }; + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async () => { + await Promise.resolve(); + throw new Error("declined"); + }, + }); + await session.callTool("x"); + expect(settlement).toEqual({ + kind: "result", + result: { action: "cancel" }, + }); + await session.close(); + }); + + it("reports ambiguous ordinary input correlation before declining", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + const completions: ((response: JsonRpcResponse) => void)[] = []; + port.dispatchHandler = () => + new Promise((resolve) => completions.push(resolve)); + const errors: Error[] = []; + let handlerCalls = 0; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async () => { + await Promise.resolve(); + handlerCalls += 1; + return { action: "accept" } as never; + }, + onError: (error) => errors.push(error), + }); + const first = session.callTool("first", undefined, { + applicationContext: "one", + }); + const second = session.callTool("second", undefined, { + applicationContext: "two", + }); + await Promise.resolve(); + await expect( + port.serve({ method: "elicitation/create", params: {} }), + ).resolves.toEqual({ + kind: "result", + result: { action: "cancel" }, + }); + expect(handlerCalls).toBe(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(InputCorrelationError); + expect(errors[0]).toMatchObject({ + reason: "ambiguous-matches", + requestKind: "elicitation", + }); + expect( + (errors[0] as InputCorrelationError).candidates.map( + (candidate) => candidate.toolName, + ), + ).toEqual(["first", "second"]); + for (const complete of completions) + complete({ kind: "result", result: { content: [] } }); + await Promise.all([first, second]); + await session.close(); + }); + + it("correlates V1 task inputs across candidate counts and evidence states", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 0, max: 3 }), + fc.constantFrom("absent", "invalid", "matching", "missing"), + fc.constantFrom( + "elicitation/create", + "sampling/createMessage", + "roots/list", + ), + async (candidateCount, evidenceState, method) => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } }, cancel: {} }, + }); + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + const params = expectRecord(record.params); + if (record.method === "tools/call") { + if (typeof params.name !== "string") + throw new Error("tool name required"); + const name = params.name; + return { + kind: "result", + result: asJson({ + task: { + taskId: `task-${name}`, + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }, + }), + }; + } + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ), + ); + if (record.method === "tasks/cancel") + return { + kind: "result", + result: asJson({ + taskId: params.taskId, + status: "cancelled", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }), + }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const errors: Error[] = []; + const observed: unknown[] = []; + const session = withTasks(port, { + tools: { + currentTool: (name) => ({ + name, + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }), + }, + onInputRequest: async (request, context) => { + await Promise.resolve(); + observed.push({ request, context }); + return ( + request.kind === "elicitation" + ? { action: "accept" } + : request.kind === "sampling" + ? { + model: "m", + role: "assistant", + content: { type: "text", text: "ok" }, + } + : { roots: [] } + ) as never; + }, + onError: (error) => errors.push(error), + }); + const executions = await Promise.all( + Array.from({ length: candidateCount }, (_, index) => + session.callTool(String(index), undefined, { + applicationContext: `context-${index}`, + }), + ), + ); + const relatedTask: JsonValue = + evidenceState === "absent" + ? {} + : evidenceState === "invalid" + ? { + _meta: { + "io.modelcontextprotocol/related-task": { taskId: 1 }, + }, + } + : { + _meta: { + "io.modelcontextprotocol/related-task": { + taskId: + evidenceState === "matching" ? "task-0" : "other", + }, + }, + }; + const settlement = await port.serve({ method, params: relatedTask }); + const succeeds = + (evidenceState === "absent" && candidateCount === 1) || + (evidenceState === "matching" && candidateCount > 0); + expect(observed).toHaveLength(succeeds ? 1 : 0); + expect(errors).toHaveLength(succeeds ? 0 : 1); + if (succeeds) { + const entry = expectRecord(asJson(observed[0])); + expect(entry.context).toMatchObject({ + lifetime: "task-v1", + taskId: "task-0", + applicationContext: "context-0", + }); + expect( + (observed[0] as { context: { signal: AbortSignal } }).context + .signal, + ).toBeInstanceOf(AbortSignal); + expect(settlement.kind).toBe("result"); + } else { + const expectedReason = + evidenceState === "invalid" + ? "invalid-evidence" + : evidenceState === "absent" && candidateCount === 0 + ? "missing-evidence" + : evidenceState === "missing" || candidateCount === 0 + ? "zero-matches" + : "ambiguous-matches"; + expect(errors[0]).toBeInstanceOf(InputCorrelationError); + expect(errors[0]).toMatchObject({ reason: expectedReason }); + if (evidenceState === "invalid") { + const candidates = (errors[0] as InputCorrelationError) + .candidates; + expect(candidates).toHaveLength(candidateCount); + expect( + candidates.every((candidate) => !("taskId" in candidate)), + ).toBe(true); + } + expect(settlement).toEqual( + method === "elicitation/create" + ? { kind: "result", result: { action: "cancel" } } + : { + kind: "error", + error: { code: -32603, message: "Internal error" }, + }, + ); + } + await Promise.all(executions.map((execution) => execution.close())); + await session.close(); + }, + ), + { numRuns: 40 }, + ); + }); + + it("conforms exactly to the V1 related-task metadata key", async () => { + const malformedValues: JsonValue[] = [ + null, + [], + "task-0", + {}, + { taskId: null }, + ]; + for (const relatedTask of malformedValues) { + const port = new FakePort({ generation: "v1", capabilities: {} }); + const errors: Error[] = []; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async () => { + await Promise.resolve(); + return { action: "accept" } as never; + }, + onError: (error) => errors.push(error), + }); + await port.serve({ + method: "elicitation/create", + params: { + _meta: { "io.modelcontextprotocol/related-task": relatedTask }, + }, + }); + expect(errors[0]).toMatchObject({ reason: "invalid-evidence" }); + await session.close(); + } + const port = new FakePort({ generation: "v1", capabilities: {} }); + const errors: Error[] = []; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async () => { + await Promise.resolve(); + return { action: "accept" } as never; + }, + onError: (error) => errors.push(error), + }); + await port.serve({ + method: "elicitation/create", + params: { + _meta: { + "modelcontextprotocol.io/related-task": { taskId: "wrong-key" }, + unrelated: true, + }, + }, + }); + expect(errors[0]).toMatchObject({ reason: "missing-evidence" }); + await session.close(); + }); + + it("unregisters a closed V1 task candidate and aborts its handler signal", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } }, cancel: {} }, + }); + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + task: { + taskId: "lifecycle", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ), + ); + return { + kind: "result", + result: asJson({ + taskId: "lifecycle", + status: "cancelled", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }), + }; + }; + const errors: Error[] = []; + let handlerSignal: AbortSignal | undefined; + const session = withTasks(port, { + tools: { + currentTool: () => ({ + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }), + }, + onInputRequest: async (_request, context) => { + await Promise.resolve(); + handlerSignal = context.signal; + return { action: "accept" } as never; + }, + onError: (error) => errors.push(error), + }); + const execution = await session.callTool("x"); + await port.serve({ method: "elicitation/create", params: {} }); + expect(handlerSignal?.aborted).toBe(false); + await execution.close(); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + expect(handlerSignal?.aborted).toBe(true); + await port.serve({ + method: "elicitation/create", + params: { + _meta: { + "io.modelcontextprotocol/related-task": { taskId: "lifecycle" }, + }, + }, + }); + expect(errors.at(-1)).toMatchObject({ reason: "zero-matches" }); + await session.close(); + }); + + it("applies the exhaustive V1 capability-first task augmentation table", async () => { + const support = fc.option( + fc.constantFrom("forbidden", "optional", "required"), + { nil: undefined }, + ); + await fc.assert( + fc.asyncProperty( + fc.boolean(), + support, + fc.boolean(), + async (present, taskSupport, preferTask) => { + const capabilities: ServerTaskCapabilitiesV1 = present + ? { requests: { tools: { call: {} } } } + : {}; + const port = new FakePort({ generation: "v1", capabilities }); + let taskSelected = false; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const record = expectRecord(request); + if (record.method === "tools/call") { + return taskSelected + ? { + kind: "result", + result: asJson({ + task: { + taskId: "property-task", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }, + }), + } + : { kind: "result", result: { content: [] } }; + } + if (record.method === "tasks/result") + return { kind: "result", result: { content: [] } }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + let lookups = 0; + const tool: ToolV1 = { + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport }, + }; + const session = withTasks(port, { + tools: { + currentTool: () => { + lookups += 1; + return tool; + }, + }, + }); + taskSelected = + present && + (taskSupport === "required" || + (taskSupport === "optional" && preferTask)); + const execution = await session.callTool("x", undefined, { + preferTask, + }); + if (taskSelected) { + expect(execution.kind).toBe("task"); + expect(port.requests).toEqual([ + { method: "tools/call", params: { name: "x", task: {} } }, + { method: "tasks/result", params: { taskId: "property-task" } }, + ]); + } else { + expect(execution.kind).toBe("immediate"); + expect(port.requests).toEqual([ + { method: "tools/call", params: { name: "x" } }, + ]); + } + expect(lookups).toBe(1); + await session.close(); + }, + ), + ); + }); + + it("drives a V1 task to a separately retrieved result", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } }, cancel: {} }, + }); + const tool: ToolV1 = { + name: "long", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const record = expectRecord(request); + if (record.method === "tools/call") { + return { + kind: "result", + result: asJson({ + task: { + taskId: "v1-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }, + }), + }; + } + if (record.method === "tasks/get") { + return { + kind: "result", + result: asJson({ + taskId: "v1-task", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }), + }; + } + if (record.method === "tasks/result") { + return { + kind: "result", + result: asJson({ content: [{ type: "text", text: "done" }] }), + }; + } + if (record.method === "tasks/cancel") { + return { + kind: "result", + result: asJson({ + taskId: "v1-task", + status: "cancelled", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }), + }; + } + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { tools: { currentTool: () => tool } }); + const execution = await session.callTool("long"); + expect(execution.kind).toBe("task"); + expect(execution.handle).toEqual({ + generation: "v1", + taskId: "v1-task", + originalOperation: "tools/call", + }); + const snapshots: unknown[] = []; + for await (const snapshot of execution.updates()) snapshots.push(snapshot); + expect(snapshots).toEqual([ + { + generation: "v1", + task: { + taskId: "v1-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }, + }, + { + generation: "v1", + task: { + taskId: "v1-task", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }, + }, + ]); + const first = execution.result(); + expect(execution.result()).toBe(first); + await expect(first).resolves.toEqual({ + content: [{ type: "text", text: "done" }], + }); + await session.close(); + }); + + it("identifies unsupported V1 cancellation without dispatching it", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } } }, + }); + const tool: ToolV1 = { + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + task: { + taskId: "no-cancel", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + }, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => reject(asError(options.signal?.reason)), + { once: true }, + ), + ); + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { tools: { currentTool: () => tool } }); + const execution = await session.callTool("x"); + await expect(execution.cancel()).rejects.toBeInstanceOf( + TaskCancellationUnsupportedError, + ); + expect( + port.requests.some( + (request) => expectRecord(request).method === "tasks/cancel", + ), + ).toBe(false); + await execution.close(); + await expect(execution.result()).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + await session.close(); + }); +}); diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts new file mode 100644 index 0000000..542b562 --- /dev/null +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -0,0 +1,539 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { expectRecord } from "../core/index.js"; +import { withTasks } from "./index.js"; +import { + FakePort, + asJson, + formatJson, + asError, +} from "../../test-support/client/fake-port.js"; + +describe("V2 input and task behavior", () => { + it("drives a V2 task to its inline terminal result", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + const tool = { name: "long", inputSchema: { type: "object" } }; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const record = expectRecord(request); + if (record.method === "tools/call") { + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "v2-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + } + if (record.method === "tasks/get") { + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "v2-task", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { + resultType: "complete", + content: [{ type: "text", text: "done" }], + }, + }), + }; + } + if (record.method === "tasks/cancel") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { tools: { currentTool: () => tool } }); + const execution = await session.callTool("long"); + expect(execution.kind).toBe("task"); + expect(execution.handle).toEqual({ + generation: "v2", + taskId: "v2-task", + originalOperation: "tools/call", + }); + expect(port.requests[0]).toMatchObject({ + method: "tools/call", + params: { + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }, + }); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [{ type: "text", text: "done" }], + }); + await session.close(); + }); + + it("acquires distinct V2 input keys once and submits one valid subset", async () => { + await fc.assert( + fc.asyncProperty( + fc.uniqueArray( + fc.record({ + key: fc.stringMatching(/^[a-z][a-z0-9]{0,7}$/), + kind: fc.constantFrom("sampling", "roots", "elicitation"), + }), + { minLength: 1, maxLength: 8, selector: ({ key }) => key }, + ), + async (inputs) => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + const observed: unknown[] = []; + let getCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "input-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") { + getCalls += 1; + if (getCalls === 1) + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "input-task", + status: "input_required", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + inputRequests: Object.fromEntries( + inputs.map(({ key, kind }) => [ + key, + kind === "sampling" + ? { + method: "sampling/createMessage", + params: { key }, + } + : kind === "roots" + ? { method: "roots/list" } + : { method: "elicitation/create", params: { key } }, + ]), + ), + }), + }; + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "input-task", + status: "completed", + createdAt: "a", + lastUpdatedAt: "c", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }), + }; + } + if (record.method === "tasks/update") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks<{ marker: string }>(port, { + tools: { + currentTool: () => ({ + name: "x", + inputSchema: { type: "object" }, + }), + }, + onInputRequest: async (request, context) => { + await Promise.resolve(); + observed.push({ request, context }); + return ( + request.kind === "sampling" + ? { model: "m", role: "assistant", content: { type: "text" } } + : request.kind === "roots" + ? { roots: [{ uri: "file:///root" }] } + : { action: "cancel" } + ) as never; + }, + }); + const execution = await session.callTool( + "x", + {}, + { + applicationContext: { marker: "context" }, + }, + ); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(observed).toHaveLength(inputs.length); + expect( + observed.map((value) => { + const entry = expectRecord(asJson(value)); + return expectRecord(entry.context).inputKey; + }), + ).toEqual(inputs.map(({ key }) => key)); + const updates = port.requests.filter( + (request) => expectRecord(request).method === "tasks/update", + ); + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ + params: { + taskId: "input-task", + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }, + }); + expect( + Object.keys( + expectRecord(expectRecord(updates[0]).params) + .inputResponses as object, + ), + ).toEqual(inputs.map(({ key }) => key)); + await session.close(); + }, + ), + { numRuns: 25 }, + ); + }); + + it("reports incompatible repeated V2 keys without reacquiring or updating", async () => { + const errors: Error[] = []; + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + let handlerCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "repeat", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (method === "tasks/get") { + getCalls += 1; + if (getCalls <= 2) + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "repeat", + status: "input_required", + createdAt: "a", + lastUpdatedAt: String(getCalls), + ttlMs: null, + inputRequests: { + same: + getCalls === 1 + ? { method: "roots/list" } + : { method: "sampling/createMessage", params: {} }, + }, + }), + }; + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "repeat", + status: "completed", + createdAt: "a", + lastUpdatedAt: "z", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }), + }; + } + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + onInputRequest: async () => { + await Promise.resolve(); + handlerCalls += 1; + throw new Error("declined"); + }, + onError: (error) => errors.push(error), + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toMatchObject({ + resultType: "complete", + }); + expect(handlerCalls).toBe(1); + expect( + errors.some((error) => error.message.includes("reused incompatibly")), + ).toBe(true); + expect( + port.requests.filter( + (request) => expectRecord(request).method === "tasks/update", + ), + ).toEqual([]); + await session.close(); + }); + + it("declines keyed V2 elicitation while withholding sampling and roots", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "decline-input", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (method === "tasks/get") { + getCalls += 1; + return { + kind: "result", + result: asJson( + getCalls === 1 + ? { + resultType: "complete", + taskId: "decline-input", + status: "input_required", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + inputRequests: { + elicit: { method: "elicitation/create", params: {} }, + sample: { method: "sampling/createMessage", params: {} }, + roots: { method: "roots/list" }, + }, + } + : { + resultType: "complete", + taskId: "decline-input", + status: "completed", + createdAt: "a", + lastUpdatedAt: "c", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + ), + }; + } + if (method === "tasks/update") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + onInputRequest: async () => { + await Promise.resolve(); + throw new Error("declined"); + }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toMatchObject({ + resultType: "complete", + }); + const updates = port.requests.filter( + (request) => expectRecord(request).method === "tasks/update", + ); + expect(updates).toHaveLength(1); + expect( + expectRecord(expectRecord(updates[0]).params).inputResponses, + ).toEqual({ + elicit: { action: "cancel" }, + }); + await session.close(); + }); + + it("aborts V2 input handling when a terminal notification arrives", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + let handlerSignal: AbortSignal | undefined; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "terminal-input", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (method === "tasks/get") { + getCalls += 1; + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "terminal-input", + status: "input_required", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + inputRequests: { + key: { method: "elicitation/create", params: {} }, + }, + }), + }; + } + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + onInputRequest: (_request, context) => { + handlerSignal = context.signal; + return new Promise((_resolve, reject) => + context.signal?.addEventListener( + "abort", + () => reject(asError(context.signal?.reason)), + { once: true }, + ), + ); + }, + }); + const execution = await session.callTool("x"); + while (handlerSignal === undefined) + await new Promise((resolve) => setTimeout(resolve, 1)); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "terminal-input", + status: "completed", + createdAt: "a", + lastUpdatedAt: "c", + ttlMs: null, + result: { resultType: "complete", content: [] }, + }, + }), + ); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(handlerSignal.aborted).toBe(true); + expect(getCalls).toBe(1); + expect( + port.requests.some( + (request) => expectRecord(request).method === "tasks/update", + ), + ).toBe(false); + await session.close(); + }); + + it("fetches V2 details when task creation is already terminal", async () => { + await fc.assert( + fc.asyncProperty( + fc.constantFrom("completed", "failed", "cancelled"), + async (status) => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "terminal-at-creation", + status, + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") { + getCalls += 1; + const terminal = { + resultType: "complete", + taskId: "terminal-at-creation", + status, + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + }; + return { + kind: "result", + result: asJson( + status === "completed" + ? { + ...terminal, + result: { resultType: "complete", content: [] }, + } + : status === "failed" + ? { + ...terminal, + error: { code: -32000, message: "task failed" }, + } + : terminal, + ), + }; + } + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ + name: "x", + inputSchema: { type: "object" }, + }), + }, + }); + const execution = await session.callTool("x"); + if (status === "completed") + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + else if (status === "failed") + await expect(execution.result()).rejects.toMatchObject({ + name: "JsonRpcResponseError", + code: -32000, + message: "task failed", + }); + else await expect(execution.result()).rejects.toThrow(/cancel/i); + expect(getCalls).toBe(1); + await session.close(); + }, + ), + { numRuns: 9 }, + ); + }); +}); diff --git a/packages/ext-tasks/src/core/index.test.ts b/packages/ext-tasks/src/core/index.test.ts index 57fc66f..7bc6347 100644 --- a/packages/ext-tasks/src/core/index.test.ts +++ b/packages/ext-tasks/src/core/index.test.ts @@ -1,8 +1,9 @@ import fc from "fast-check"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ProtocolDecodeError, + createRuntimeCodec, isJsonValue, taskId, type JsonValue, @@ -47,6 +48,92 @@ describe("core runtime contracts", () => { expect(isJsonValue(sparse)).toBe(false); }); + it("rejects non-JSON inputs before invoking the decoder", () => { + const sparse: unknown[] = []; + sparse.length = 1; + const cyclic: Record = {}; + cyclic.self = cyclic; + class Exotic {} + const nonJsonValues: readonly unknown[] = [ + undefined, + 1n, + Symbol("x"), + () => undefined, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + new Date(), + new Map(), + sparse, + cyclic, + new Exotic(), + Object.create({ inherited: true }) as object, + /not-json/, + ]; + const decode = vi.fn(() => "decoded"); + const codec = createRuntimeCodec(decode); + + for (const value of nonJsonValues) { + const result = codec.parse(value); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeInstanceOf(ProtocolDecodeError); + expect(result.error.path).toEqual([]); + } + } + expect(decode).not.toHaveBeenCalled(); + }); + + it("invokes the decoder for JSON values and starts it at the root path", () => { + const decode = vi.fn( + (value: JsonValue, path: readonly (string | number)[]) => ({ + value, + path, + }), + ); + const codec = createRuntimeCodec(decode); + const input = { nested: [null, true, 1, "value"] }; + const result = codec.parse(input); + + expect(result).toEqual({ + success: true, + value: { value: input, path: [] }, + }); + expect(decode).toHaveBeenCalledTimes(1); + expect(decode).toHaveBeenCalledWith(input, []); + }); + + it("returns decoder ProtocolDecodeError instances unchanged with their paths", () => { + const error = new ProtocolDecodeError("expected string", [ + "params", + "name", + ]); + const codec = createRuntimeCodec(() => { + throw error; + }); + + const result = codec.parse({}); + expect(result).toEqual({ success: false, error }); + if (!result.success) { + expect(result.error).toBe(error); + expect(result.error.path).toEqual(["params", "name"]); + } + }); + + it("rethrows unexpected decoder errors unchanged", () => { + const error = new Error("programmer failure"); + const codec = createRuntimeCodec(() => { + throw error; + }); + + expect(() => codec.parse({})).toThrow(error); + try { + codec.parse({}); + } catch (caught) { + expect(caught).toBe(error); + } + }); + it("brands task identifiers without changing their wire value", () => { fc.assert( fc.property(fc.string(), (value) => { diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts index 6032c26..11573f3 100644 --- a/packages/ext-tasks/src/core/index.ts +++ b/packages/ext-tasks/src/core/index.ts @@ -1,5 +1,16 @@ import type { TaskV1 } from "./v1/index.js"; import type { DetailedTaskV2, TaskV2 } from "./v2/index.js"; +import { ProtocolDecodeError } from "./internal/codec.js"; +export { + ProtocolDecodeError, + createRuntimeCodec, + expectEnum, + expectNumber, + expectRecord, + expectString, + isJsonArray, + type DecodePath, +} from "./internal/codec.js"; export type TaskId = string & { readonly __taskId: unique symbol }; export type TaskGeneration = "v1" | "v2"; @@ -12,22 +23,9 @@ export type JsonValue = | readonly JsonValue[] | { readonly [key: string]: JsonValue }; -export type DecodePath = readonly (string | number)[]; - -export class ProtocolDecodeError extends Error { - readonly path: DecodePath; - - constructor(message: string, path: DecodePath = []) { - const location = path.length === 0 ? "$" : path.join("."); - super(`${location}: ${message}`); - this.name = "ProtocolDecodeError"; - this.path = path; - } -} - export interface RuntimeCodec { parse( - value: JsonValue, + value: unknown, ): | { readonly success: true; readonly value: T } | { readonly success: false; readonly error: ProtocolDecodeError }; @@ -70,69 +68,3 @@ export function isJsonValue(value: unknown): value is JsonValue { }; return visit(value); } - -export function isJsonArray( - value: JsonValue | undefined, -): value is readonly JsonValue[] { - return Array.isArray(value); -} - -export function createRuntimeCodec( - decode: (value: JsonValue, path: DecodePath) => T, -): RuntimeCodec { - return { - parse(value) { - try { - return { success: true, value: decode(value, []) }; - } catch (error) { - return { - success: false, - error: - error instanceof ProtocolDecodeError - ? error - : new ProtocolDecodeError("invalid protocol value"), - }; - } - }, - }; -} - -export function expectRecord( - value: JsonValue, - path: DecodePath = [], -): Record { - if (value === null || Array.isArray(value) || typeof value !== "object") { - throw new ProtocolDecodeError("expected object", path); - } - return value as Record; -} - -export function expectString( - value: JsonValue | undefined, - path: DecodePath, -): string { - if (typeof value !== "string") - throw new ProtocolDecodeError("expected string", path); - return value; -} - -export function expectNumber( - value: JsonValue | undefined, - path: DecodePath, -): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new ProtocolDecodeError("expected finite number", path); - } - return value; -} - -export function expectEnum( - value: JsonValue | undefined, - values: readonly T[], - path: DecodePath, -): T { - if (typeof value !== "string" || !values.includes(value as T)) { - throw new ProtocolDecodeError(`expected one of ${values.join(", ")}`, path); - } - return value as T; -} diff --git a/packages/ext-tasks/src/core/internal/codec.ts b/packages/ext-tasks/src/core/internal/codec.ts new file mode 100644 index 0000000..24e39ca --- /dev/null +++ b/packages/ext-tasks/src/core/internal/codec.ts @@ -0,0 +1,83 @@ +/** Internal runtime codec implementation helpers. */ +import { isJsonValue, type JsonValue, type RuntimeCodec } from "../index.js"; + +export type DecodePath = readonly (string | number)[]; + +export class ProtocolDecodeError extends Error { + readonly path: DecodePath; + + constructor(message: string, path: DecodePath = []) { + const location = path.length === 0 ? "$" : path.join("."); + super(`${location}: ${message}`); + this.name = "ProtocolDecodeError"; + this.path = path; + } +} + +export function isJsonArray( + value: JsonValue | undefined, +): value is readonly JsonValue[] { + return Array.isArray(value); +} +export function createRuntimeCodec( + decode: (value: JsonValue, path: DecodePath) => T, +): RuntimeCodec { + return { + parse(value) { + if (!isJsonValue(value)) { + return { + success: false, + error: new ProtocolDecodeError("expected JSON value"), + }; + } + try { + return { success: true, value: decode(value, []) }; + } catch (error) { + if (error instanceof ProtocolDecodeError) { + return { success: false, error }; + } + throw error; + } + }, + }; +} + +export function expectRecord( + value: JsonValue, + path: DecodePath = [], +): Record { + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new ProtocolDecodeError("expected object", path); + } + return value as Record; +} + +export function expectString( + value: JsonValue | undefined, + path: DecodePath, +): string { + if (typeof value !== "string") + throw new ProtocolDecodeError("expected string", path); + return value; +} + +export function expectNumber( + value: JsonValue | undefined, + path: DecodePath, +): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new ProtocolDecodeError("expected finite number", path); + } + return value; +} + +export function expectEnum( + value: JsonValue | undefined, + values: readonly T[], + path: DecodePath, +): T { + if (typeof value !== "string" || !values.includes(value as T)) { + throw new ProtocolDecodeError(`expected one of ${values.join(", ")}`, path); + } + return value as T; +} diff --git a/packages/ext-tasks/src/core/v1/codecs.ts b/packages/ext-tasks/src/core/v1/codecs.ts new file mode 100644 index 0000000..2be01cd --- /dev/null +++ b/packages/ext-tasks/src/core/v1/codecs.ts @@ -0,0 +1,434 @@ +/** MCP Tasks V1 runtime codecs. */ +import { + ProtocolDecodeError, + createRuntimeCodec, + expectEnum, + expectNumber, + expectRecord, + expectString, + isJsonArray, + type DecodePath, +} from "../internal/codec.js"; +import { type JsonValue, type RuntimeCodec } from "../index.js"; +import { + TaskStatusesV1, + type CallToolRequestV1, + type CallToolResultV1, + type CancelTaskRequestV1, + type CancelTaskResultV1, + type ContentBlockV1, + type CreateTaskResultV1, + type GetTaskRequestV1, + type GetTaskResultRequestV1, + type GetTaskResultV1, + type JsonRpcRequestIdV1, + type ListTasksRequestV1, + type ListTasksResultV1, + type ServerTaskCapabilitiesV1, + type TaskResultV1, + type TaskStatusNotificationV1, + type TaskStatusV1, + type TaskV1, + type ToolExecutionV1, + type ToolV1, +} from "./wire.js"; +interface JsonRpcRequestV1 { + readonly jsonrpc: "2.0"; + readonly id: JsonRpcRequestIdV1; + readonly method: M; + readonly params: P; +} + +function at(path: DecodePath, key: string | number): DecodePath { + return [...path, key]; +} +function optionalBoolean( + record: Record, + key: string, + path: DecodePath, +): boolean | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== "boolean") + throw new ProtocolDecodeError("expected boolean", at(path, key)); + return value; +} +function jsonRecord( + value: JsonValue | undefined, + path: DecodePath, +): Readonly> { + if (value === undefined) + throw new ProtocolDecodeError("expected object", path); + return expectRecord(value, path); +} +function optionalJsonRecord( + record: Record, + key: string, + path: DecodePath, +) { + return record[key] === undefined + ? undefined + : jsonRecord(record[key], at(path, key)); +} +function literal( + record: Record, + key: string, + expected: string, + path: DecodePath, +): void { + if (record[key] !== expected) + throw new ProtocolDecodeError(`expected ${expected}`, at(path, key)); +} +function decodeId( + value: JsonValue | undefined, + path: DecodePath, +): JsonRpcRequestIdV1 { + if (typeof value !== "string" && typeof value !== "number") + throw new ProtocolDecodeError("expected request id", path); + return value; +} +function expectInteger(value: JsonValue | undefined, path: DecodePath): number { + const number = expectNumber(value, path); + if (!Number.isInteger(number)) + throw new ProtocolDecodeError("expected integer", path); + return number; +} +function decodeContentBlock( + value: JsonValue, + path: DecodePath, +): ContentBlockV1 { + const record = expectRecord(value, path); + const type = expectEnum( + record.type, + ["text", "image", "audio", "resource_link", "resource"] as const, + at(path, "type"), + ); + if (type === "text") { + expectString(record.text, at(path, "text")); + } else if (type === "image" || type === "audio") { + expectString(record.data, at(path, "data")); + expectString(record.mimeType, at(path, "mimeType")); + } else if (type === "resource_link") { + expectString(record.name, at(path, "name")); + expectString(record.uri, at(path, "uri")); + } else { + jsonRecord(record.resource, at(path, "resource")); + } + return record as ContentBlockV1; +} +function decodeTask(value: JsonValue, path: DecodePath): TaskV1 { + const record = expectRecord(value, path); + const ttlValue = record.ttl; + const ttl = + ttlValue === null ? null : expectInteger(ttlValue, at(path, "ttl")); + return { + taskId: expectString(record.taskId, at(path, "taskId")), + status: expectEnum(record.status, TaskStatusesV1, at(path, "status")), + ...(record.statusMessage === undefined + ? {} + : { + statusMessage: expectString( + record.statusMessage, + at(path, "statusMessage"), + ), + }), + createdAt: expectString(record.createdAt, at(path, "createdAt")), + lastUpdatedAt: expectString( + record.lastUpdatedAt, + at(path, "lastUpdatedAt"), + ), + ttl, + ...(record.pollInterval === undefined + ? {} + : { + pollInterval: expectInteger( + record.pollInterval, + at(path, "pollInterval"), + ), + }), + }; +} +function decodeTaskRequest< + M extends "tasks/get" | "tasks/result" | "tasks/cancel", +>( + value: JsonValue, + path: DecodePath, + method: M, +): JsonRpcRequestV1 { + const record = expectRecord(value, path); + literal(record, "jsonrpc", "2.0", path); + literal(record, "method", method, path); + const params = jsonRecord(record.params, at(path, "params")); + return { + jsonrpc: "2.0", + id: decodeId(record.id, at(path, "id")), + method, + params: { + taskId: expectString(params.taskId, at(at(path, "params"), "taskId")), + }, + }; +} + +export const ToolV1Codec: RuntimeCodec = createRuntimeCodec( + (value, path) => { + const record = expectRecord(value, path); + const inputSchema = jsonRecord(record.inputSchema, at(path, "inputSchema")); + literal(inputSchema, "type", "object", at(path, "inputSchema")); + let outputSchema: ToolV1["outputSchema"]; + if (record.outputSchema !== undefined) { + const decoded = jsonRecord(record.outputSchema, at(path, "outputSchema")); + literal(decoded, "type", "object", at(path, "outputSchema")); + outputSchema = decoded as ToolV1["outputSchema"]; + } + let execution: ToolExecutionV1 | undefined; + if (record.execution !== undefined) { + const decoded = jsonRecord(record.execution, at(path, "execution")); + execution = + decoded.taskSupport === undefined + ? {} + : { + taskSupport: expectEnum( + decoded.taskSupport, + ["forbidden", "optional", "required"] as const, + at(at(path, "execution"), "taskSupport"), + ), + }; + } + let icons: ToolV1["icons"]; + if (record.icons !== undefined) { + if (!isJsonArray(record.icons)) + throw new ProtocolDecodeError("expected array", at(path, "icons")); + icons = record.icons.map((icon, index) => + jsonRecord(icon, at(at(path, "icons"), index)), + ); + } + return { + name: expectString(record.name, at(path, "name")), + inputSchema: inputSchema as ToolV1["inputSchema"], + ...(record.title === undefined + ? {} + : { title: expectString(record.title, at(path, "title")) }), + ...(record.description === undefined + ? {} + : { + description: expectString( + record.description, + at(path, "description"), + ), + }), + ...(outputSchema === undefined ? {} : { outputSchema }), + ...(execution === undefined ? {} : { execution }), + ...(record.annotations === undefined + ? {} + : { + annotations: jsonRecord( + record.annotations, + at(path, "annotations"), + ), + }), + ...(icons === undefined ? {} : { icons }), + ...(record._meta === undefined + ? {} + : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), + }; + }, +); +export const ServerTaskCapabilitiesV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + const list = optionalJsonRecord(record, "list", path); + const cancel = optionalJsonRecord(record, "cancel", path); + let requests: ServerTaskCapabilitiesV1["requests"]; + if (record.requests !== undefined) { + const r = jsonRecord(record.requests, at(path, "requests")); + let tools: NonNullable["tools"]; + if (r.tools !== undefined) { + const t = jsonRecord(r.tools, at(at(path, "requests"), "tools")); + tools = + t.call === undefined + ? {} + : { + call: jsonRecord( + t.call, + at(at(at(path, "requests"), "tools"), "call"), + ), + }; + } + requests = tools === undefined ? {} : { tools }; + } + return { + ...(list === undefined ? {} : { list }), + ...(cancel === undefined ? {} : { cancel }), + ...(requests === undefined ? {} : { requests }), + }; + }); +export const CallToolRequestV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + literal(record, "jsonrpc", "2.0", path); + literal(record, "method", "tools/call", path); + const params = jsonRecord(record.params, at(path, "params")); + return { + jsonrpc: "2.0", + id: decodeId(record.id, at(path, "id")), + method: "tools/call", + params: { + name: expectString(params.name, at(at(path, "params"), "name")), + ...(params.arguments === undefined + ? {} + : { + arguments: jsonRecord( + params.arguments, + at(at(path, "params"), "arguments"), + ), + }), + ...(params.task === undefined + ? {} + : { + task: (() => { + const t = jsonRecord( + params.task, + at(at(path, "params"), "task"), + ); + return t.ttl === undefined + ? {} + : { + ttl: expectInteger( + t.ttl, + at(at(at(path, "params"), "task"), "ttl"), + ), + }; + })(), + }), + }, + }; + }); +export const TaskStatusV1Codec: RuntimeCodec = createRuntimeCodec( + (value, path) => expectEnum(value, TaskStatusesV1, path), +); +export const TaskV1Codec: RuntimeCodec = createRuntimeCodec(decodeTask); +export const CreateTaskResultV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + return { + task: decodeTask(record.task, at(path, "task")), + ...(record._meta === undefined + ? {} + : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), + }; + }); +export const CallToolResultV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + if (!isJsonArray(record.content)) + throw new ProtocolDecodeError("expected array", at(path, "content")); + record.content.forEach((item, index) => + decodeContentBlock(item, at(at(path, "content"), index)), + ); + if (record.structuredContent !== undefined) + jsonRecord(record.structuredContent, at(path, "structuredContent")); + if (record.isError !== undefined) optionalBoolean(record, "isError", path); + if (record._meta !== undefined) jsonRecord(record._meta, at(path, "_meta")); + return record as unknown as CallToolResultV1; + }); +export const GetTaskRequestV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => + decodeTaskRequest(v, p, "tasks/get"), + ); +export const GetTaskResultV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => { + const record = expectRecord(v, p); + return { + ...decodeTask(v, p), + ...(record._meta === undefined + ? {} + : { _meta: jsonRecord(record._meta, at(p, "_meta")) }), + }; + }); +export const GetTaskResultRequestV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => + decodeTaskRequest(v, p, "tasks/result"), + ); +export const TaskResultV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => expectRecord(v, p)); +export const CancelTaskRequestV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => + decodeTaskRequest(v, p, "tasks/cancel"), + ); +export const CancelTaskResultV1Codec: RuntimeCodec = + createRuntimeCodec((v, p) => { + const record = expectRecord(v, p); + return { + ...decodeTask(v, p), + ...(record._meta === undefined + ? {} + : { _meta: jsonRecord(record._meta, at(p, "_meta")) }), + }; + }); +export const ListTasksRequestV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + literal(record, "jsonrpc", "2.0", path); + literal(record, "method", "tasks/list", path); + const result: ListTasksRequestV1 = { + jsonrpc: "2.0", + id: decodeId(record.id, at(path, "id")), + method: "tasks/list", + }; + if (record.params === undefined) return result; + const params = jsonRecord(record.params, at(path, "params")); + return { + ...result, + params: { + ...(params.cursor === undefined + ? {} + : { + cursor: expectString( + params.cursor, + at(at(path, "params"), "cursor"), + ), + }), + }, + }; + }); +export const ListTasksResultV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + if (!isJsonArray(record.tasks)) + throw new ProtocolDecodeError("expected array", at(path, "tasks")); + return { + tasks: record.tasks.map((task, index) => + decodeTask(task, at(at(path, "tasks"), index)), + ), + ...(record.nextCursor === undefined + ? {} + : { + nextCursor: expectString(record.nextCursor, at(path, "nextCursor")), + }), + ...(record._meta === undefined + ? {} + : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), + }; + }); +export const TaskStatusNotificationV1Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const record = expectRecord(value, path); + literal(record, "jsonrpc", "2.0", path); + literal(record, "method", "notifications/tasks/status", path); + const paramsRecord = jsonRecord(record.params, at(path, "params")); + return { + jsonrpc: "2.0", + method: "notifications/tasks/status", + params: { + ...decodeTask(record.params, at(path, "params")), + ...(paramsRecord._meta === undefined + ? {} + : { + _meta: jsonRecord( + paramsRecord._meta, + at(at(path, "params"), "_meta"), + ), + }), + }, + }; + }); diff --git a/packages/ext-tasks/src/core/v1/index.test.ts b/packages/ext-tasks/src/core/v1/index.test.ts index 6cf4075..e2b763f 100644 --- a/packages/ext-tasks/src/core/v1/index.test.ts +++ b/packages/ext-tasks/src/core/v1/index.test.ts @@ -57,15 +57,16 @@ const taskRequestArb = ( }); function expectRoundTrip( - codec: { parse(value: never): { success: boolean; value?: unknown } }, + codec: { parse(value: unknown): { success: boolean; value?: unknown } }, value: unknown, ): void { - const decoded = codec.parse(value as never); + const wireValue: unknown = JSON.parse(JSON.stringify(value)); + const decoded = codec.parse(wireValue); expect(decoded.success).toBe(true); - if (decoded.success) expect(decoded.value).toEqual(value); + if (decoded.success) expect(decoded.value).toEqual(wireValue); } -describe("V1 generated wire contracts", () => { +describe("V1 runtime wire contracts", () => { it("round-trips Tasks and rejects missing fields, fractions, and unknown statuses", () => { fc.assert( fc.property(taskArb, (task) => { @@ -104,12 +105,11 @@ describe("V1 generated wire contracts", () => { .double({ noNaN: true, noDefaultInfinity: true }) .filter((n) => !Number.isInteger(n)), (task, fraction) => { + expect(TaskV1Codec.parse({ ...task, ttl: fraction }).success).toBe( + false, + ); expect( - TaskV1Codec.parse({ ...task, ttl: fraction } as never).success, - ).toBe(false); - expect( - TaskV1Codec.parse({ ...task, pollInterval: fraction } as never) - .success, + TaskV1Codec.parse({ ...task, pollInterval: fraction }).success, ).toBe(false); }, ), diff --git a/packages/ext-tasks/src/core/v1/index.ts b/packages/ext-tasks/src/core/v1/index.ts index 0377b33..cfb73cc 100644 --- a/packages/ext-tasks/src/core/v1/index.ts +++ b/packages/ext-tasks/src/core/v1/index.ts @@ -1,607 +1,54 @@ -/** MCP Tasks V1 wire declarations, codecs, and generation-specific guards. */ -import { - ProtocolDecodeError, - createRuntimeCodec, - expectEnum, - expectNumber, - expectRecord, - expectString, - isJsonArray, - type DecodePath, - type JsonValue, - type RuntimeCodec, -} from "../index.js"; - -export const TaskStatusesV1 = [ - "working", - "input_required", - "completed", - "failed", - "cancelled", -] as const; -export type TaskStatusV1 = (typeof TaskStatusesV1)[number]; -export type TaskSupportV1 = "forbidden" | "optional" | "required"; -export type TaskEligibleMethodV1 = "tools/call"; -export type JsonRpcRequestIdV1 = string | number; - -export interface TaskMetadataV1 { - readonly ttl?: number; -} -export interface TaskV1 { - readonly taskId: string; - readonly status: TaskStatusV1; - readonly statusMessage?: string; - readonly createdAt: string; - readonly lastUpdatedAt: string; - /** Normative V1 source permits null for unlimited retention; the pinned JSON Schema omitted this union. */ - readonly ttl: number | null; - readonly pollInterval?: number; -} - -export interface CreateTaskResultV1 { - readonly task: TaskV1; - readonly _meta?: Readonly>; -} - -export interface ToolExecutionV1 { - readonly taskSupport?: TaskSupportV1; -} -export interface ToolV1 { - readonly name: string; - readonly title?: string; - readonly description?: string; - readonly inputSchema: Readonly> & { - readonly type: "object"; - }; - readonly outputSchema?: Readonly> & { - readonly type: "object"; - }; - readonly execution?: ToolExecutionV1; - readonly annotations?: Readonly>; - readonly icons?: readonly Readonly>[]; - readonly _meta?: Readonly>; -} - -export type ContentBlockV1 = - | { - readonly type: "text"; - readonly text: string; - readonly [key: string]: JsonValue; - } - | { - readonly type: "image" | "audio"; - readonly data: string; - readonly mimeType: string; - readonly [key: string]: JsonValue; - } - | { - readonly type: "resource_link"; - readonly name: string; - readonly uri: string; - readonly [key: string]: JsonValue; - } - | { - readonly type: "resource"; - readonly resource: Readonly>; - readonly [key: string]: JsonValue; - }; -export interface CallToolRequestV1 { - readonly jsonrpc: "2.0"; - readonly id: JsonRpcRequestIdV1; - readonly method: "tools/call"; - readonly params: { - readonly name: string; - readonly arguments?: Readonly>; - readonly task?: TaskMetadataV1; - }; -} - -export interface CallToolResultV1 { - readonly content: readonly ContentBlockV1[]; - readonly structuredContent?: Readonly>; - readonly isError?: boolean; - readonly _meta?: Readonly>; -} - -export interface ServerTaskCapabilitiesV1 { - readonly list?: Readonly>; - readonly cancel?: Readonly>; - readonly requests?: { - readonly tools?: { readonly call?: Readonly> }; - }; -} -export interface ServerCapabilitiesV1 { - readonly tasks?: ServerTaskCapabilitiesV1; -} - -interface JsonRpcRequestV1 { - readonly jsonrpc: "2.0"; - readonly id: JsonRpcRequestIdV1; - readonly method: M; - readonly params: P; -} -export type GetTaskRequestV1 = JsonRpcRequestV1< - "tasks/get", - { readonly taskId: string } ->; -export type GetTaskResultV1 = TaskV1 & { - readonly _meta?: Readonly>; -}; -export type GetTaskResultRequestV1 = JsonRpcRequestV1< - "tasks/result", - { readonly taskId: string } ->; -export type TaskResultV1 = Readonly>; -export interface ListTasksRequestV1 { - readonly jsonrpc: "2.0"; - readonly id: JsonRpcRequestIdV1; - readonly method: "tasks/list"; - readonly params?: { readonly cursor?: string }; -} -export interface ListTasksResultV1 { - readonly tasks: readonly TaskV1[]; - readonly nextCursor?: string; - readonly _meta?: Readonly>; -} -export type CancelTaskRequestV1 = JsonRpcRequestV1< - "tasks/cancel", - { readonly taskId: string } ->; -export type CancelTaskResultV1 = TaskV1 & { - readonly _meta?: Readonly>; -}; -export interface TaskStatusNotificationV1 { - readonly jsonrpc: "2.0"; - readonly method: "notifications/tasks/status"; - readonly params: TaskV1 & { - readonly _meta?: Readonly>; - }; -} - -export interface CallToolAsTaskRequestV1 { - readonly method: "tools/call"; - readonly params: { - readonly name: string; - readonly arguments?: Readonly>; - readonly task: Record; - }; -} - -function at(path: DecodePath, key: string | number): DecodePath { - return [...path, key]; -} -function optionalBoolean( - record: Record, - key: string, - path: DecodePath, -): boolean | undefined { - const value = record[key]; - if (value === undefined) return undefined; - if (typeof value !== "boolean") - throw new ProtocolDecodeError("expected boolean", at(path, key)); - return value; -} -function jsonRecord( - value: JsonValue | undefined, - path: DecodePath, -): Readonly> { - if (value === undefined) - throw new ProtocolDecodeError("expected object", path); - return expectRecord(value, path); -} -function optionalJsonRecord( - record: Record, - key: string, - path: DecodePath, -) { - return record[key] === undefined - ? undefined - : jsonRecord(record[key], at(path, key)); -} -function literal( - record: Record, - key: string, - expected: string, - path: DecodePath, -): void { - if (record[key] !== expected) - throw new ProtocolDecodeError(`expected ${expected}`, at(path, key)); -} -function decodeId( - value: JsonValue | undefined, - path: DecodePath, -): JsonRpcRequestIdV1 { - if (typeof value !== "string" && typeof value !== "number") - throw new ProtocolDecodeError("expected request id", path); - return value; -} -function expectInteger(value: JsonValue | undefined, path: DecodePath): number { - const number = expectNumber(value, path); - if (!Number.isInteger(number)) - throw new ProtocolDecodeError("expected integer", path); - return number; -} -function decodeContentBlock( - value: JsonValue, - path: DecodePath, -): ContentBlockV1 { - const record = expectRecord(value, path); - const type = expectEnum( - record.type, - ["text", "image", "audio", "resource_link", "resource"] as const, - at(path, "type"), - ); - if (type === "text") { - expectString(record.text, at(path, "text")); - } else if (type === "image" || type === "audio") { - expectString(record.data, at(path, "data")); - expectString(record.mimeType, at(path, "mimeType")); - } else if (type === "resource_link") { - expectString(record.name, at(path, "name")); - expectString(record.uri, at(path, "uri")); - } else { - jsonRecord(record.resource, at(path, "resource")); - } - return record as ContentBlockV1; -} -function decodeTask(value: JsonValue, path: DecodePath): TaskV1 { - const record = expectRecord(value, path); - const ttlValue = record.ttl; - const ttl = - ttlValue === null ? null : expectInteger(ttlValue, at(path, "ttl")); - return { - taskId: expectString(record.taskId, at(path, "taskId")), - status: expectEnum(record.status, TaskStatusesV1, at(path, "status")), - ...(record.statusMessage === undefined - ? {} - : { - statusMessage: expectString( - record.statusMessage, - at(path, "statusMessage"), - ), - }), - createdAt: expectString(record.createdAt, at(path, "createdAt")), - lastUpdatedAt: expectString( - record.lastUpdatedAt, - at(path, "lastUpdatedAt"), - ), - ttl, - ...(record.pollInterval === undefined - ? {} - : { - pollInterval: expectInteger( - record.pollInterval, - at(path, "pollInterval"), - ), - }), - }; -} -function decodeTaskRequest< - M extends "tasks/get" | "tasks/result" | "tasks/cancel", ->( - value: JsonValue, - path: DecodePath, - method: M, -): JsonRpcRequestV1 { - const record = expectRecord(value, path); - literal(record, "jsonrpc", "2.0", path); - literal(record, "method", method, path); - const params = jsonRecord(record.params, at(path, "params")); - return { - jsonrpc: "2.0", - id: decodeId(record.id, at(path, "id")), - method, - params: { - taskId: expectString(params.taskId, at(at(path, "params"), "taskId")), - }, - }; -} - -export const ToolV1Codec: RuntimeCodec = createRuntimeCodec( - (value, path) => { - const record = expectRecord(value, path); - const inputSchema = jsonRecord(record.inputSchema, at(path, "inputSchema")); - literal(inputSchema, "type", "object", at(path, "inputSchema")); - let outputSchema: ToolV1["outputSchema"]; - if (record.outputSchema !== undefined) { - const decoded = jsonRecord(record.outputSchema, at(path, "outputSchema")); - literal(decoded, "type", "object", at(path, "outputSchema")); - outputSchema = decoded as ToolV1["outputSchema"]; - } - let execution: ToolExecutionV1 | undefined; - if (record.execution !== undefined) { - const decoded = jsonRecord(record.execution, at(path, "execution")); - execution = - decoded.taskSupport === undefined - ? {} - : { - taskSupport: expectEnum( - decoded.taskSupport, - ["forbidden", "optional", "required"] as const, - at(at(path, "execution"), "taskSupport"), - ), - }; - } - let icons: ToolV1["icons"]; - if (record.icons !== undefined) { - if (!isJsonArray(record.icons)) - throw new ProtocolDecodeError("expected array", at(path, "icons")); - icons = record.icons.map((icon, index) => - jsonRecord(icon, at(at(path, "icons"), index)), - ); - } - return { - name: expectString(record.name, at(path, "name")), - inputSchema: inputSchema as ToolV1["inputSchema"], - ...(record.title === undefined - ? {} - : { title: expectString(record.title, at(path, "title")) }), - ...(record.description === undefined - ? {} - : { - description: expectString( - record.description, - at(path, "description"), - ), - }), - ...(outputSchema === undefined ? {} : { outputSchema }), - ...(execution === undefined ? {} : { execution }), - ...(record.annotations === undefined - ? {} - : { - annotations: jsonRecord( - record.annotations, - at(path, "annotations"), - ), - }), - ...(icons === undefined ? {} : { icons }), - ...(record._meta === undefined - ? {} - : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), - }; - }, -); -export const ServerTaskCapabilitiesV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - const list = optionalJsonRecord(record, "list", path); - const cancel = optionalJsonRecord(record, "cancel", path); - let requests: ServerTaskCapabilitiesV1["requests"]; - if (record.requests !== undefined) { - const r = jsonRecord(record.requests, at(path, "requests")); - let tools: NonNullable["tools"]; - if (r.tools !== undefined) { - const t = jsonRecord(r.tools, at(at(path, "requests"), "tools")); - tools = - t.call === undefined - ? {} - : { - call: jsonRecord( - t.call, - at(at(at(path, "requests"), "tools"), "call"), - ), - }; - } - requests = tools === undefined ? {} : { tools }; - } - return { - ...(list === undefined ? {} : { list }), - ...(cancel === undefined ? {} : { cancel }), - ...(requests === undefined ? {} : { requests }), - }; - }); -export const CallToolRequestV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - literal(record, "jsonrpc", "2.0", path); - literal(record, "method", "tools/call", path); - const params = jsonRecord(record.params, at(path, "params")); - return { - jsonrpc: "2.0", - id: decodeId(record.id, at(path, "id")), - method: "tools/call", - params: { - name: expectString(params.name, at(at(path, "params"), "name")), - ...(params.arguments === undefined - ? {} - : { - arguments: jsonRecord( - params.arguments, - at(at(path, "params"), "arguments"), - ), - }), - ...(params.task === undefined - ? {} - : { - task: (() => { - const t = jsonRecord( - params.task, - at(at(path, "params"), "task"), - ); - return t.ttl === undefined - ? {} - : { - ttl: expectInteger( - t.ttl, - at(at(at(path, "params"), "task"), "ttl"), - ), - }; - })(), - }), - }, - }; - }); -export const TaskStatusV1Codec: RuntimeCodec = createRuntimeCodec( - (value, path) => expectEnum(value, TaskStatusesV1, path), -); -export const TaskV1Codec: RuntimeCodec = createRuntimeCodec(decodeTask); -export const CreateTaskResultV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - return { - task: decodeTask(record.task, at(path, "task")), - ...(record._meta === undefined - ? {} - : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), - }; - }); -export const CallToolResultV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - if (!isJsonArray(record.content)) - throw new ProtocolDecodeError("expected array", at(path, "content")); - record.content.forEach((item, index) => - decodeContentBlock(item, at(at(path, "content"), index)), - ); - if (record.structuredContent !== undefined) - jsonRecord(record.structuredContent, at(path, "structuredContent")); - if (record.isError !== undefined) optionalBoolean(record, "isError", path); - if (record._meta !== undefined) jsonRecord(record._meta, at(path, "_meta")); - return record as unknown as CallToolResultV1; - }); -export const GetTaskRequestV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => - decodeTaskRequest(v, p, "tasks/get"), - ); -export const GetTaskResultV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => { - const record = expectRecord(v, p); - return { - ...decodeTask(v, p), - ...(record._meta === undefined - ? {} - : { _meta: jsonRecord(record._meta, at(p, "_meta")) }), - }; - }); -export const GetTaskResultRequestV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => - decodeTaskRequest(v, p, "tasks/result"), - ); -export const TaskResultV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => expectRecord(v, p)); -export const CancelTaskRequestV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => - decodeTaskRequest(v, p, "tasks/cancel"), - ); -export const CancelTaskResultV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => { - const record = expectRecord(v, p); - return { - ...decodeTask(v, p), - ...(record._meta === undefined - ? {} - : { _meta: jsonRecord(record._meta, at(p, "_meta")) }), - }; - }); -export const ListTasksRequestV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - literal(record, "jsonrpc", "2.0", path); - literal(record, "method", "tasks/list", path); - const result: ListTasksRequestV1 = { - jsonrpc: "2.0", - id: decodeId(record.id, at(path, "id")), - method: "tasks/list", - }; - if (record.params === undefined) return result; - const params = jsonRecord(record.params, at(path, "params")); - return { - ...result, - params: { - ...(params.cursor === undefined - ? {} - : { - cursor: expectString( - params.cursor, - at(at(path, "params"), "cursor"), - ), - }), - }, - }; - }); -export const ListTasksResultV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - if (!isJsonArray(record.tasks)) - throw new ProtocolDecodeError("expected array", at(path, "tasks")); - return { - tasks: record.tasks.map((task, index) => - decodeTask(task, at(at(path, "tasks"), index)), - ), - ...(record.nextCursor === undefined - ? {} - : { - nextCursor: expectString(record.nextCursor, at(path, "nextCursor")), - }), - ...(record._meta === undefined - ? {} - : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), - }; - }); -export const TaskStatusNotificationV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - literal(record, "jsonrpc", "2.0", path); - literal(record, "method", "notifications/tasks/status", path); - const paramsRecord = jsonRecord(record.params, at(path, "params")); - return { - jsonrpc: "2.0", - method: "notifications/tasks/status", - params: { - ...decodeTask(record.params, at(path, "params")), - ...(paramsRecord._meta === undefined - ? {} - : { - _meta: jsonRecord( - paramsRecord._meta, - at(at(path, "params"), "_meta"), - ), - }), - }, - }; - }); - -export function hasTaskListCapabilityV1( - capabilities: ServerTaskCapabilitiesV1, -): boolean { - return capabilities.list !== undefined; -} -export function hasTaskCancelCapabilityV1( - capabilities: ServerTaskCapabilitiesV1, -): boolean { - return capabilities.cancel !== undefined; -} -export function hasTaskToolCallCapabilityV1( - capabilities: ServerTaskCapabilitiesV1, -): boolean { - return capabilities.requests?.tools?.call !== undefined; -} -export function isTaskEligibleMethodV1( - method: string, -): method is TaskEligibleMethodV1 { - return method === "tools/call"; -} -export function shouldCallToolAsTaskV1( - capabilities: ServerTaskCapabilitiesV1, - tool: ToolV1, - preferTask = false, -): boolean { - if (!hasTaskToolCallCapabilityV1(capabilities)) return false; - return ( - tool.execution?.taskSupport === "required" || - (tool.execution?.taskSupport === "optional" && preferTask) - ); -} -export function callToolAsTaskV1( - name: string, - arguments_?: Readonly>, -): CallToolAsTaskRequestV1 { - return { - method: "tools/call", - params: { - name, - ...(arguments_ === undefined ? {} : { arguments: arguments_ }), - task: {}, - }, - }; -} +/** MCP Tasks V1 public API. */ +export { + TaskStatusesV1, + type CallToolAsTaskRequestV1, + type CallToolRequestV1, + type CallToolResultV1, + type CancelTaskRequestV1, + type CancelTaskResultV1, + type ContentBlockV1, + type CreateTaskResultV1, + type GetTaskRequestV1, + type GetTaskResultRequestV1, + type GetTaskResultV1, + type JsonRpcRequestIdV1, + type ListTasksRequestV1, + type ListTasksResultV1, + type ServerCapabilitiesV1, + type ServerTaskCapabilitiesV1, + type TaskEligibleMethodV1, + type TaskMetadataV1, + type TaskResultV1, + type TaskStatusNotificationV1, + type TaskStatusV1, + type TaskSupportV1, + type TaskV1, + type ToolExecutionV1, + type ToolV1, +} from "./wire.js"; +export { + CallToolRequestV1Codec, + CallToolResultV1Codec, + CancelTaskRequestV1Codec, + CancelTaskResultV1Codec, + CreateTaskResultV1Codec, + GetTaskRequestV1Codec, + GetTaskResultRequestV1Codec, + GetTaskResultV1Codec, + ListTasksRequestV1Codec, + ListTasksResultV1Codec, + ServerTaskCapabilitiesV1Codec, + TaskResultV1Codec, + TaskStatusNotificationV1Codec, + TaskStatusV1Codec, + TaskV1Codec, + ToolV1Codec, +} from "./codecs.js"; +export { + callToolAsTaskV1, + hasTaskCancelCapabilityV1, + hasTaskListCapabilityV1, + hasTaskToolCallCapabilityV1, + isTaskEligibleMethodV1, + shouldCallToolAsTaskV1, +} from "./negotiation.js"; diff --git a/packages/ext-tasks/src/core/v1/negotiation.ts b/packages/ext-tasks/src/core/v1/negotiation.ts new file mode 100644 index 0000000..a4d1cd8 --- /dev/null +++ b/packages/ext-tasks/src/core/v1/negotiation.ts @@ -0,0 +1,52 @@ +/** MCP Tasks V1 capability negotiation and task request helpers. */ +import { type JsonValue } from "../index.js"; +import { + type CallToolAsTaskRequestV1, + type ServerTaskCapabilitiesV1, + type TaskEligibleMethodV1, + type ToolV1, +} from "./wire.js"; +export function hasTaskListCapabilityV1( + capabilities: ServerTaskCapabilitiesV1, +): boolean { + return capabilities.list !== undefined; +} +export function hasTaskCancelCapabilityV1( + capabilities: ServerTaskCapabilitiesV1, +): boolean { + return capabilities.cancel !== undefined; +} +export function hasTaskToolCallCapabilityV1( + capabilities: ServerTaskCapabilitiesV1, +): boolean { + return capabilities.requests?.tools?.call !== undefined; +} +export function isTaskEligibleMethodV1( + method: string, +): method is TaskEligibleMethodV1 { + return method === "tools/call"; +} +export function shouldCallToolAsTaskV1( + capabilities: ServerTaskCapabilitiesV1, + tool: ToolV1, + preferTask = false, +): boolean { + if (!hasTaskToolCallCapabilityV1(capabilities)) return false; + return ( + tool.execution?.taskSupport === "required" || + (tool.execution?.taskSupport === "optional" && preferTask) + ); +} +export function callToolAsTaskV1( + name: string, + arguments_?: Readonly>, +): CallToolAsTaskRequestV1 { + return { + method: "tools/call", + params: { + name, + ...(arguments_ === undefined ? {} : { arguments: arguments_ }), + task: {}, + }, + }; +} diff --git a/packages/ext-tasks/src/core/v1/wire.ts b/packages/ext-tasks/src/core/v1/wire.ts new file mode 100644 index 0000000..2e5817c --- /dev/null +++ b/packages/ext-tasks/src/core/v1/wire.ts @@ -0,0 +1,157 @@ +/** MCP Tasks V1 wire declarations. */ +import { type JsonValue } from "../index.js"; + +export const TaskStatusesV1 = [ + "working", + "input_required", + "completed", + "failed", + "cancelled", +] as const; +export type TaskStatusV1 = (typeof TaskStatusesV1)[number]; +export type TaskSupportV1 = "forbidden" | "optional" | "required"; +export type TaskEligibleMethodV1 = "tools/call"; +export type JsonRpcRequestIdV1 = string | number; + +export interface TaskMetadataV1 { + readonly ttl?: number; +} +export interface TaskV1 { + readonly taskId: string; + readonly status: TaskStatusV1; + readonly statusMessage?: string; + readonly createdAt: string; + readonly lastUpdatedAt: string; + /** Normative V1 source permits null for unlimited retention; the pinned JSON Schema omitted this union. */ + readonly ttl: number | null; + readonly pollInterval?: number; +} + +export interface CreateTaskResultV1 { + readonly task: TaskV1; + readonly _meta?: Readonly>; +} + +export interface ToolExecutionV1 { + readonly taskSupport?: TaskSupportV1; +} +export interface ToolV1 { + readonly name: string; + readonly title?: string; + readonly description?: string; + readonly inputSchema: Readonly> & { + readonly type: "object"; + }; + readonly outputSchema?: Readonly> & { + readonly type: "object"; + }; + readonly execution?: ToolExecutionV1; + readonly annotations?: Readonly>; + readonly icons?: readonly Readonly>[]; + readonly _meta?: Readonly>; +} + +export type ContentBlockV1 = + | { + readonly type: "text"; + readonly text: string; + readonly [key: string]: JsonValue; + } + | { + readonly type: "image" | "audio"; + readonly data: string; + readonly mimeType: string; + readonly [key: string]: JsonValue; + } + | { + readonly type: "resource_link"; + readonly name: string; + readonly uri: string; + readonly [key: string]: JsonValue; + } + | { + readonly type: "resource"; + readonly resource: Readonly>; + readonly [key: string]: JsonValue; + }; +export interface CallToolRequestV1 { + readonly jsonrpc: "2.0"; + readonly id: JsonRpcRequestIdV1; + readonly method: "tools/call"; + readonly params: { + readonly name: string; + readonly arguments?: Readonly>; + readonly task?: TaskMetadataV1; + }; +} + +export interface CallToolResultV1 { + readonly content: readonly ContentBlockV1[]; + readonly structuredContent?: Readonly>; + readonly isError?: boolean; + readonly _meta?: Readonly>; +} + +export interface ServerTaskCapabilitiesV1 { + readonly list?: Readonly>; + readonly cancel?: Readonly>; + readonly requests?: { + readonly tools?: { readonly call?: Readonly> }; + }; +} +export interface ServerCapabilitiesV1 { + readonly tasks?: ServerTaskCapabilitiesV1; +} + +interface JsonRpcRequestV1 { + readonly jsonrpc: "2.0"; + readonly id: JsonRpcRequestIdV1; + readonly method: M; + readonly params: P; +} +export type GetTaskRequestV1 = JsonRpcRequestV1< + "tasks/get", + { readonly taskId: string } +>; +export type GetTaskResultV1 = TaskV1 & { + readonly _meta?: Readonly>; +}; +export type GetTaskResultRequestV1 = JsonRpcRequestV1< + "tasks/result", + { readonly taskId: string } +>; +export type TaskResultV1 = Readonly>; +export interface ListTasksRequestV1 { + readonly jsonrpc: "2.0"; + readonly id: JsonRpcRequestIdV1; + readonly method: "tasks/list"; + readonly params?: { readonly cursor?: string }; +} +export interface ListTasksResultV1 { + readonly tasks: readonly TaskV1[]; + readonly nextCursor?: string; + readonly _meta?: Readonly>; +} +export type CancelTaskRequestV1 = JsonRpcRequestV1< + "tasks/cancel", + { readonly taskId: string } +>; +export type CancelTaskResultV1 = TaskV1 & { + readonly _meta?: Readonly>; +}; +export interface TaskStatusNotificationV1 { + readonly jsonrpc: "2.0"; + readonly method: "notifications/tasks/status"; + readonly params: TaskV1 & { + readonly _meta?: Readonly>; + }; +} + +export interface CallToolAsTaskRequestV1 { + readonly method: "tools/call"; + readonly params: { + readonly name: string; + readonly arguments?: Readonly>; + readonly task: Record; + }; +} diff --git a/packages/ext-tasks/src/core/v2/codecs.ts b/packages/ext-tasks/src/core/v2/codecs.ts new file mode 100644 index 0000000..f9ce28e --- /dev/null +++ b/packages/ext-tasks/src/core/v2/codecs.ts @@ -0,0 +1,680 @@ +/** MCP Tasks V2 runtime codecs. */ +import { + ProtocolDecodeError, + createRuntimeCodec, + expectEnum, + expectNumber, + expectRecord, + expectString, + isJsonArray, + type DecodePath, +} from "../internal/codec.js"; +import { type JsonValue, type RuntimeCodec } from "../index.js"; +import { + type CallToolResultV2, + type ContentBlockV2, + type CancelTaskRequestV2, + type CancelTaskResultV2, + type CancelledTaskV2, + type CompletedTaskV2, + type CreateMessageRequestV2, + type CreateMessageResultV2, + type CreateTaskResultV2, + type DetailedTaskV2, + type ElicitRequestV2, + type ElicitResultV2, + type ErrorV2, + type FailedTaskV2, + type GetTaskRequestV2, + type GetTaskResultV2, + type InputRequestV2, + type InputRequestsV2, + type InputRequiredTaskV2, + type InputResponseV2, + type InputResponsesV2, + type ListRootsRequestV2, + type ListRootsResultV2, + type RequestIdV2, + type TaskStatusNotificationParamsV2, + type TaskStatusNotificationV2, + type TaskSubscriptionAcknowledgedNotificationsV2, + type TaskSubscriptionNotificationsV2, + type TasksExtensionCapabilityV2, + type TaskV2, + type ToolV2, + type UpdateTaskRequestV2, + type UpdateTaskResultV2, + type WorkingTaskV2, +} from "./wire.js"; +const statuses = [ + "working", + "input_required", + "completed", + "failed", + "cancelled", +] as const; +const inputMethods = [ + "sampling/createMessage", + "roots/list", + "elicitation/create", +] as const; + +function has(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} +function expectInteger(value: JsonValue | undefined, path: DecodePath): number { + const number = expectNumber(value, path); + if (!Number.isInteger(number)) + throw new ProtocolDecodeError("expected integer", path); + return number; +} +function expectRequestId( + value: JsonValue | undefined, + path: DecodePath, +): RequestIdV2 { + if (typeof value === "string") return value; + return expectInteger(value, path); +} +function expectConst( + value: JsonValue | undefined, + expected: string, + path: DecodePath, +): void { + if (value !== expected) + throw new ProtocolDecodeError(`expected ${expected}`, path); +} +function optionalRecord(value: JsonValue | undefined, path: DecodePath) { + return value === undefined ? undefined : expectRecord(value, path); +} +function optionalString( + object: Record, + key: string, + path: DecodePath, +): void { + if (object[key] !== undefined) expectString(object[key], [...path, key]); +} +function optionalBoolean( + object: Record, + key: string, + path: DecodePath, +): void { + if (object[key] !== undefined && typeof object[key] !== "boolean") + throw new ProtocolDecodeError("expected boolean", [...path, key]); +} +function optionalStringArray( + object: Record, + key: string, + path: DecodePath, +): void { + const value = object[key]; + if ( + value !== undefined && + (!Array.isArray(value) || !value.every((item) => typeof item === "string")) + ) { + throw new ProtocolDecodeError("expected string array", [...path, key]); + } +} + +function decodeAnnotations(value: JsonValue, path: DecodePath): void { + const object = expectRecord(value, path); + if (object.audience !== undefined) { + if ( + !Array.isArray(object.audience) || + !object.audience.every((role) => role === "user" || role === "assistant") + ) { + throw new ProtocolDecodeError("expected role array", [ + ...path, + "audience", + ]); + } + } + if (object.priority !== undefined) { + const priority = expectNumber(object.priority, [...path, "priority"]); + if (priority < 0 || priority > 1) + throw new ProtocolDecodeError("expected number from 0 to 1", [ + ...path, + "priority", + ]); + } + optionalString(object, "lastModified", path); +} + +function decodeIcon(value: JsonValue, path: DecodePath): void { + const object = expectRecord(value, path); + expectString(object.src, [...path, "src"]); + optionalString(object, "mimeType", path); + optionalStringArray(object, "sizes", path); + if (object.theme !== undefined) + expectEnum(object.theme, ["light", "dark"], [...path, "theme"]); +} + +function decodeImplementation(value: JsonValue, path: DecodePath): void { + const object = expectRecord(value, path); + expectString(object.name, [...path, "name"]); + expectString(object.version, [...path, "version"]); + optionalString(object, "title", path); + optionalString(object, "description", path); + optionalString(object, "websiteUrl", path); + if (object.icons !== undefined) { + if (!isJsonArray(object.icons)) + throw new ProtocolDecodeError("expected array", [...path, "icons"]); + object.icons.forEach((icon, index) => + decodeIcon(icon, [...path, "icons", index]), + ); + } +} + +function decodeContentBlock( + value: JsonValue, + path: DecodePath, +): ContentBlockV2 { + const object = expectRecord(value, path); + const type = expectEnum( + object.type, + ["text", "image", "audio", "resource_link", "resource"], + [...path, "type"], + ); + if (type === "text") expectString(object.text, [...path, "text"]); + else if (type === "image" || type === "audio") { + expectString(object.data, [...path, "data"]); + expectString(object.mimeType, [...path, "mimeType"]); + } else if (type === "resource_link") { + expectString(object.name, [...path, "name"]); + expectString(object.uri, [...path, "uri"]); + optionalString(object, "title", path); + optionalString(object, "description", path); + optionalString(object, "mimeType", path); + if (object.size !== undefined) + expectInteger(object.size, [...path, "size"]); + if (object.icons !== undefined) { + if (!isJsonArray(object.icons)) + throw new ProtocolDecodeError("expected array", [...path, "icons"]); + object.icons.forEach((icon, index) => + decodeIcon(icon, [...path, "icons", index]), + ); + } + } else { + const resource = expectRecord(object.resource, [...path, "resource"]); + expectString(resource.uri, [...path, "resource", "uri"]); + optionalString(resource, "mimeType", [...path, "resource"]); + optionalRecord(resource._meta, [...path, "resource", "_meta"]); + const hasText = resource.text !== undefined; + const hasBlob = resource.blob !== undefined; + if (!hasText && !hasBlob) + throw new ProtocolDecodeError("expected text or blob", [ + ...path, + "resource", + ]); + if (hasText) expectString(resource.text, [...path, "resource", "text"]); + if (hasBlob) expectString(resource.blob, [...path, "resource", "blob"]); + } + if (object.annotations !== undefined) + decodeAnnotations(object.annotations, [...path, "annotations"]); + optionalRecord(object._meta, [...path, "_meta"]); + return object as ContentBlockV2; +} + +function decodeTool(value: JsonValue, path: DecodePath): ToolV2 { + const object = expectRecord(value, path); + expectString(object.name, [...path, "name"]); + optionalString(object, "title", path); + optionalString(object, "description", path); + const inputSchema = expectRecord(object.inputSchema, [ + ...path, + "inputSchema", + ]); + expectConst(inputSchema.type, "object", [...path, "inputSchema", "type"]); + optionalString(inputSchema, "$schema", [...path, "inputSchema"]); + if (object.outputSchema !== undefined) { + const outputSchema = expectRecord(object.outputSchema, [ + ...path, + "outputSchema", + ]); + optionalString(outputSchema, "$schema", [...path, "outputSchema"]); + } + if (object.annotations !== undefined) { + const annotations = expectRecord(object.annotations, [ + ...path, + "annotations", + ]); + optionalString(annotations, "title", [...path, "annotations"]); + for (const key of [ + "readOnlyHint", + "destructiveHint", + "idempotentHint", + "openWorldHint", + ]) + optionalBoolean(annotations, key, [...path, "annotations"]); + } + if (object.icons !== undefined) { + if (!isJsonArray(object.icons)) + throw new ProtocolDecodeError("expected array", [...path, "icons"]); + object.icons.forEach((icon, index) => + decodeIcon(icon, [...path, "icons", index]), + ); + } + optionalRecord(object._meta, [...path, "_meta"]); + return object as ToolV2; +} + +function decodeCallToolResult( + value: JsonValue, + path: DecodePath, +): CallToolResultV2 { + const object = expectRecord(value, path); + expectString(object.resultType, [...path, "resultType"]); + if (!isJsonArray(object.content)) + throw new ProtocolDecodeError("expected array", [...path, "content"]); + object.content.forEach((block, index) => + decodeContentBlock(block, [...path, "content", index]), + ); + optionalBoolean(object, "isError", path); + const meta = optionalRecord(object._meta, [...path, "_meta"]); + if (meta?.["io.modelcontextprotocol/serverInfo"] !== undefined) { + decodeImplementation(meta["io.modelcontextprotocol/serverInfo"], [ + ...path, + "_meta", + "io.modelcontextprotocol/serverInfo", + ]); + } + return object as CallToolResultV2; +} + +function decodeTask(value: JsonValue, path: DecodePath): TaskV2 { + const object = expectRecord(value, path); + const ttl = object.ttlMs; + if (!has(object, "ttlMs")) + throw new ProtocolDecodeError("required field", [...path, "ttlMs"]); + // Task is a closed wire shape; wrappers preserve extension data through `_meta`. + const task: TaskV2 = { + taskId: expectString(object.taskId, [...path, "taskId"]), + status: expectEnum(object.status, statuses, [...path, "status"]), + createdAt: expectString(object.createdAt, [...path, "createdAt"]), + lastUpdatedAt: expectString(object.lastUpdatedAt, [ + ...path, + "lastUpdatedAt", + ]), + ttlMs: ttl === null ? null : expectInteger(ttl, [...path, "ttlMs"]), + ...(object.statusMessage === undefined + ? {} + : { + statusMessage: expectString(object.statusMessage, [ + ...path, + "statusMessage", + ]), + }), + ...(object.pollIntervalMs === undefined + ? {} + : { + pollIntervalMs: expectInteger(object.pollIntervalMs, [ + ...path, + "pollIntervalMs", + ]), + }), + }; + return task; +} + +function decodeError(value: JsonValue, path: DecodePath): ErrorV2 { + const object = expectRecord(value, path); + return { + code: expectInteger(object.code, [...path, "code"]), + message: expectString(object.message, [...path, "message"]), + ...(has(object, "data") ? { data: object.data } : {}), + }; +} + +function decodeInputRequest( + value: JsonValue, + path: DecodePath, +): InputRequestV2 { + const object = expectRecord(value, path); + const method = expectEnum(object.method, inputMethods, [...path, "method"]); + if (method === "roots/list") { + return { + method, + ...(object.params === undefined + ? {} + : { params: expectRecord(object.params, [...path, "params"]) }), + }; + } + return { + method, + params: expectRecord(object.params, [...path, "params"]), + }; +} +function decodeInputRequests( + value: JsonValue, + path: DecodePath, +): InputRequestsV2 { + const object = expectRecord(value, path); + return Object.fromEntries( + Object.entries(object).map(([key, request]) => [ + key, + decodeInputRequest(request, [...path, key]), + ]), + ); +} + +function decodeInputResponse( + value: JsonValue, + path: DecodePath, +): InputResponseV2 { + const object = expectRecord(value, path); + if (has(object, "action")) { + expectEnum( + object.action, + ["accept", "decline", "cancel"], + [...path, "action"], + ); + } else if (has(object, "roots")) { + if (!Array.isArray(object.roots)) + throw new ProtocolDecodeError("expected array", [...path, "roots"]); + } else { + if (!has(object, "content")) + throw new ProtocolDecodeError("required field", [...path, "content"]); + expectString(object.model, [...path, "model"]); + expectEnum(object.role, ["user", "assistant"], [...path, "role"]); + } + return object as InputResponseV2; +} +function decodeInputResponses( + value: JsonValue, + path: DecodePath, +): InputResponsesV2 { + const object = expectRecord(value, path); + return Object.fromEntries( + Object.entries(object).map(([key, response]) => [ + key, + decodeInputResponse(response, [...path, key]), + ]), + ); +} + +function decodeDetailedTask( + value: JsonValue, + path: DecodePath, +): DetailedTaskV2 { + const object = expectRecord(value, path); + const task = decodeTask(value, path); + switch (task.status) { + case "input_required": + return { + ...task, + status: task.status, + inputRequests: decodeInputRequests(object.inputRequests, [ + ...path, + "inputRequests", + ]), + }; + case "completed": + return { + ...task, + status: task.status, + result: expectRecord(object.result, [...path, "result"]), + }; + case "failed": + return { + ...task, + status: task.status, + error: decodeError(object.error, [...path, "error"]), + }; + case "working": + return { ...task, status: task.status }; + case "cancelled": + return { ...task, status: task.status }; + } +} + +function decodeRpcRequest(value: JsonValue, path: DecodePath, method: string) { + const object = expectRecord(value, path); + expectConst(object.jsonrpc, "2.0", [...path, "jsonrpc"]); + expectConst(object.method, method, [...path, "method"]); + return { + object, + id: expectRequestId(object.id, [...path, "id"]), + params: expectRecord(object.params, [...path, "params"]), + }; +} +function decodeCompleteResult(value: JsonValue, path: DecodePath) { + const object = expectRecord(value, path); + expectConst(object.resultType, "complete", [...path, "resultType"]); + optionalRecord(object._meta, [...path, "_meta"]); + return object; +} + +export const ToolV2Codec: RuntimeCodec = + createRuntimeCodec(decodeTool); +export const CallToolResultV2Codec: RuntimeCodec = + createRuntimeCodec(decodeCallToolResult); +export const TaskV2Codec: RuntimeCodec = + createRuntimeCodec(decodeTask); +export const DetailedTaskV2Codec: RuntimeCodec = + createRuntimeCodec(decodeDetailedTask); +export const ErrorV2Codec: RuntimeCodec = + createRuntimeCodec(decodeError); +export const InputRequestV2Codec: RuntimeCodec = + createRuntimeCodec(decodeInputRequest); +export const InputRequestsV2Codec: RuntimeCodec = + createRuntimeCodec(decodeInputRequests); +export const InputResponseV2Codec: RuntimeCodec = + createRuntimeCodec(decodeInputResponse); +export const InputResponsesV2Codec: RuntimeCodec = + createRuntimeCodec(decodeInputResponses); +export const CreateMessageRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const request = decodeInputRequest(value, path); + if (request.method !== "sampling/createMessage") + throw new ProtocolDecodeError("expected sampling/createMessage", [ + ...path, + "method", + ]); + return request; + }); +export const ListRootsRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const request = decodeInputRequest(value, path); + if (request.method !== "roots/list") + throw new ProtocolDecodeError("expected roots/list", [...path, "method"]); + return request; + }); +export const ElicitRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const request = decodeInputRequest(value, path); + if (request.method !== "elicitation/create") + throw new ProtocolDecodeError("expected elicitation/create", [ + ...path, + "method", + ]); + return request; + }); +export const CreateMessageResultV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const response = decodeInputResponse(value, path); + if ( + !("content" in response) || + !("model" in response) || + !("role" in response) + ) + throw new ProtocolDecodeError("expected sampling result", path); + return response as CreateMessageResultV2; + }); +export const ListRootsResultV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const response = decodeInputResponse(value, path); + if (!("roots" in response)) + throw new ProtocolDecodeError("expected roots result", path); + return response as ListRootsResultV2; + }); +export const ElicitResultV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const response = decodeInputResponse(value, path); + if (!("action" in response)) + throw new ProtocolDecodeError("expected elicitation result", path); + return response as ElicitResultV2; + }); +export const CreateTaskResultV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + expectConst(object.resultType, "task", [...path, "resultType"]); + optionalRecord(object._meta, [...path, "_meta"]); + return { + ...decodeTask(value, path), + resultType: "task", + ...(object._meta === undefined + ? {} + : { _meta: expectRecord(object._meta, [...path, "_meta"]) }), + }; + }); +export const GetTaskRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const { id, params } = decodeRpcRequest(value, path, "tasks/get"); + return { + jsonrpc: "2.0", + id, + method: "tasks/get", + params: { + taskId: expectString(params.taskId, [...path, "params", "taskId"]), + }, + }; + }); +export const UpdateTaskRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const { id, params } = decodeRpcRequest(value, path, "tasks/update"); + return { + jsonrpc: "2.0", + id, + method: "tasks/update", + params: { + taskId: expectString(params.taskId, [...path, "params", "taskId"]), + inputResponses: decodeInputResponses(params.inputResponses, [ + ...path, + "params", + "inputResponses", + ]), + }, + }; + }); +export const CancelTaskRequestV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const { id, params } = decodeRpcRequest(value, path, "tasks/cancel"); + return { + jsonrpc: "2.0", + id, + method: "tasks/cancel", + params: { + taskId: expectString(params.taskId, [...path, "params", "taskId"]), + }, + }; + }); +export const GetTaskResultV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const object = decodeCompleteResult(value, path); + return { + ...decodeDetailedTask(value, path), + resultType: "complete", + ...(object._meta === undefined + ? {} + : { _meta: expectRecord(object._meta, [...path, "_meta"]) }), + }; + }); +export const UpdateTaskResultV2Codec: RuntimeCodec = + createRuntimeCodec( + (value, path) => decodeCompleteResult(value, path) as UpdateTaskResultV2, + ); +export const CancelTaskResultV2Codec: RuntimeCodec = + createRuntimeCodec( + (value, path) => decodeCompleteResult(value, path) as CancelTaskResultV2, + ); +export const WorkingTaskV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const task = decodeDetailedTask(value, path); + if (task.status !== "working") + throw new ProtocolDecodeError("expected working", [...path, "status"]); + return task; + }); +export const InputRequiredTaskV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const task = decodeDetailedTask(value, path); + if (task.status !== "input_required") + throw new ProtocolDecodeError("expected input_required", [ + ...path, + "status", + ]); + return task; + }); +export const CompletedTaskV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const task = decodeDetailedTask(value, path); + if (task.status !== "completed") + throw new ProtocolDecodeError("expected completed", [...path, "status"]); + return task; + }); +export const FailedTaskV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const task = decodeDetailedTask(value, path); + if (task.status !== "failed") + throw new ProtocolDecodeError("expected failed", [...path, "status"]); + return task; + }); +export const CancelledTaskV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const task = decodeDetailedTask(value, path); + if (task.status !== "cancelled") + throw new ProtocolDecodeError("expected cancelled", [...path, "status"]); + return task; + }); +function decodeTaskStatusNotificationParams( + value: JsonValue, + path: DecodePath, +): TaskStatusNotificationParamsV2 { + const object = expectRecord(value, path); + return { + ...decodeDetailedTask(value, path), + ...(object._meta === undefined + ? {} + : { _meta: expectRecord(object._meta, [...path, "_meta"]) }), + }; +} + +export const TaskStatusNotificationParamsV2Codec: RuntimeCodec = + createRuntimeCodec( + decodeTaskStatusNotificationParams, + ); +export const TaskSubscriptionNotificationsV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + if (object.taskIds === undefined) return {}; + if ( + !Array.isArray(object.taskIds) || + !object.taskIds.every((id) => typeof id === "string") + ) + throw new ProtocolDecodeError("expected string array", [ + ...path, + "taskIds", + ]); + return { taskIds: object.taskIds }; + }); +export const TaskSubscriptionAcknowledgedNotificationsV2Codec: RuntimeCodec = + TaskSubscriptionNotificationsV2Codec; +export const TasksExtensionCapabilityV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + if (Object.keys(object).length !== 0) + throw new ProtocolDecodeError("expected empty object", path); + return {}; + }); +export const TaskStatusNotificationV2Codec: RuntimeCodec = + createRuntimeCodec((value, path) => { + const object = expectRecord(value, path); + expectConst(object.jsonrpc, "2.0", [...path, "jsonrpc"]); + expectConst(object.method, "notifications/tasks", [...path, "method"]); + return { + jsonrpc: "2.0", + method: "notifications/tasks", + params: decodeTaskStatusNotificationParams(object.params, [ + ...path, + "params", + ]), + }; + }); diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts index 34e1bb7..5a5e0bd 100644 --- a/packages/ext-tasks/src/core/v2/index.test.ts +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -1,7 +1,9 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import type { JsonValue } from "../index.js"; +import { ProtocolDecodeError, type JsonValue } from "../index.js"; + +import * as coreV2 from "./index.js"; import { CallToolResultV2Codec, @@ -14,7 +16,9 @@ import { GetTaskResultV2Codec, InputRequestsV2Codec, InputResponsesV2Codec, + TaskStatusNotificationParamsV2Codec, TaskStatusNotificationV2Codec, + TasksExtensionCapabilityV2Codec, TaskV2Codec, ToolV2Codec, UpdateTaskRequestV2Codec, @@ -22,9 +26,11 @@ import { contributeTaskFilterV2, hasTaskClientCapabilityV2, hasTaskServerCapabilityV2, - isEligibleTaskResultV2, + isToolCallTaskResultV2, readAcceptedTaskIdsV2, withTaskCapabilityV2, + type CallToolResultV2, + type TasksExtensionCapabilityV2, type TaskStatusV2, } from "./index.js"; @@ -49,7 +55,7 @@ const taskFor = (status: TaskStatusV2) => const asJson = (value: unknown): JsonValue => JSON.parse(JSON.stringify(value)) as JsonValue; -describe("V2 generated wire contracts", () => { +describe("V2 runtime wire contracts", () => { it("accepts every valid base Task and rejects missing required fields, invalid integers, and statuses", () => { fc.assert( fc.property(baseTask, (task) => { @@ -101,6 +107,31 @@ describe("V2 generated wire contracts", () => { ); }); + it("keeps Task closed while preserving wrapper metadata", () => { + const decoded = TaskV2Codec.parse({ + taskId: "task", + status: "working", + createdAt: "created", + lastUpdatedAt: "updated", + ttlMs: null, + vendorHint: 1, + }); + expect(decoded.success).toBe(true); + if (decoded.success) expect("vendorHint" in decoded.value).toBe(false); + + const notification = TaskStatusNotificationParamsV2Codec.parse({ + taskId: "task", + status: "working", + createdAt: "created", + lastUpdatedAt: "updated", + ttlMs: null, + _meta: { vendorHint: 1 }, + }); + expect(notification.success).toBe(true); + if (notification.success) + expect(notification.value._meta).toEqual({ vendorHint: 1 }); + }); + it("enforces status-owned DetailedTask payloads", () => { fc.assert( fc.property(taskFor("working"), (task) => { @@ -499,12 +530,10 @@ describe("V2 generated wire contracts", () => { it("discriminates Task creation only for eligible tools/call results", () => { fc.assert( fc.property(baseTask, (task) => { - const result = { ...task, resultType: "task" }; - expect(CreateTaskResultV2Codec.parse(asJson(result)).success).toBe( - true, - ); - expect(isEligibleTaskResultV2("tools/call", result)).toBe(true); - expect(isEligibleTaskResultV2("prompts/get", result)).toBe(false); + const result = asJson({ ...task, resultType: "task" }); + expect(CreateTaskResultV2Codec.parse(result).success).toBe(true); + expect(isToolCallTaskResultV2("tools/call", result)).toBe(true); + expect(isToolCallTaskResultV2("prompts/get", result)).toBe(false); }), ); expect( @@ -535,6 +564,87 @@ describe("V2 generated wire contracts", () => { ).toBe(false); }); + it("preserves optional notification params _meta as a strict JSON record", () => { + fc.assert( + fc.property( + taskFor("working"), + fc.dictionary(fc.string(), fc.jsonValue()), + (task, meta) => { + const params = asJson({ ...task, _meta: meta }); + const paramsResult = + TaskStatusNotificationParamsV2Codec.parse(params); + expect(paramsResult.success).toBe(true); + if (paramsResult.success) + expect(paramsResult.value._meta).toEqual(asJson(meta)); + + const notificationResult = TaskStatusNotificationV2Codec.parse({ + jsonrpc: "2.0", + method: "notifications/tasks", + params, + }); + expect(notificationResult.success).toBe(true); + if (notificationResult.success) + expect(notificationResult.value.params._meta).toEqual(asJson(meta)); + }, + ), + ); + + for (const meta of [null, [], "meta", 1, true] as const) { + const params = { + taskId: "task", + status: "working", + createdAt: "created", + lastUpdatedAt: "updated", + ttlMs: null, + _meta: meta, + }; + const paramsResult = TaskStatusNotificationParamsV2Codec.parse(params); + expect(paramsResult.success).toBe(false); + if (!paramsResult.success) { + expect(paramsResult.error).toBeInstanceOf(ProtocolDecodeError); + expect(paramsResult.error.path).toEqual(["_meta"]); + } + + const notificationResult = TaskStatusNotificationV2Codec.parse({ + jsonrpc: "2.0", + method: "notifications/tasks", + params, + }); + expect(notificationResult.success).toBe(false); + if (!notificationResult.success) + expect(notificationResult.error.path).toEqual(["params", "_meta"]); + } + }); + + it("exports only the canonical V2 task result and capability names", () => { + const capability: TasksExtensionCapabilityV2 = {}; + const result: CallToolResultV2 = { resultType: "complete", content: [] }; + + expect(TasksExtensionCapabilityV2Codec.parse(capability)).toEqual({ + success: true, + value: {}, + }); + expect(CallToolResultV2Codec.parse(result).success).toBe(true); + expect( + isToolCallTaskResultV2("tools/call", { + taskId: "task", + resultType: "task", + status: "working", + createdAt: "created", + lastUpdatedAt: "updated", + ttlMs: null, + }), + ).toBe(true); + for (const removed of [ + "ToolCallResultV2Codec", + "isEligibleTaskResultV2", + "TaskExtensionCapabilitiesV2Codec", + "supportsTasksExtensionV2", + ]) { + expect(removed in coreV2).toBe(false); + } + }); + it("contributes task IDs without changing unrelated filters or prior notification fields", () => { fc.assert( fc.property( diff --git a/packages/ext-tasks/src/core/v2/index.ts b/packages/ext-tasks/src/core/v2/index.ts index 0cca42a..8ccd6f0 100644 --- a/packages/ext-tasks/src/core/v2/index.ts +++ b/packages/ext-tasks/src/core/v2/index.ts @@ -1,979 +1,91 @@ -/** MCP Tasks V2 wire declarations, codecs, guards, and request bindings. */ -import { - ProtocolDecodeError, - createRuntimeCodec, - expectEnum, - expectNumber, - expectRecord, - expectString, - isJsonArray, - type DecodePath, - type JsonValue, - type RuntimeCodec, -} from "../index.js"; - -export const TASKS_EXTENSION_ID_V2 = "io.modelcontextprotocol/tasks" as const; -export const CLIENT_CAPABILITIES_META_KEY_V2 = - "io.modelcontextprotocol/clientCapabilities" as const; - -type OpenObjectV2 = Readonly>; -type ToolAnnotationsV2 = OpenObjectV2 & { - readonly title?: string; - readonly readOnlyHint?: boolean; - readonly destructiveHint?: boolean; - readonly idempotentHint?: boolean; - readonly openWorldHint?: boolean; -}; -type IconV2 = OpenObjectV2 & { - readonly src: string; - readonly mimeType?: string; - readonly sizes?: readonly string[]; - readonly theme?: "light" | "dark"; -}; -type ContentBlockV2 = OpenObjectV2 & - ( - | { readonly type: "text"; readonly text: string } - | { - readonly type: "image" | "audio"; - readonly data: string; - readonly mimeType: string; - } - | { - readonly type: "resource_link"; - readonly name: string; - readonly uri: string; - } - | { readonly type: "resource"; readonly resource: OpenObjectV2 } - ); - -export type ToolV2 = OpenObjectV2 & { - readonly name: string; - readonly title?: string; - readonly description?: string; - readonly inputSchema: OpenObjectV2 & { - readonly type: "object"; - readonly $schema?: string; - }; - readonly outputSchema?: OpenObjectV2 & { readonly $schema?: string }; - readonly annotations?: ToolAnnotationsV2; - readonly icons?: readonly IconV2[]; - readonly _meta?: OpenObjectV2; -}; -export type RequestIdV2 = string | number; -export type TaskStatusV2 = - "working" | "input_required" | "completed" | "failed" | "cancelled"; - -export type TaskEligibleMethodV2 = "tools/call"; - -export interface TaskV2 { - readonly taskId: string; - readonly status: TaskStatusV2; - readonly statusMessage?: string; - readonly createdAt: string; - readonly lastUpdatedAt: string; - readonly ttlMs: number | null; - readonly pollIntervalMs?: number; -} - -export interface WorkingTaskV2 extends TaskV2 { - readonly status: "working"; -} -export interface InputRequiredTaskV2 extends TaskV2 { - readonly status: "input_required"; - readonly inputRequests: InputRequestsV2; -} -export interface CompletedTaskV2 extends TaskV2 { - readonly status: "completed"; - readonly result: Readonly>; -} -export interface FailedTaskV2 extends TaskV2 { - readonly status: "failed"; - readonly error: ErrorV2; -} -export interface CancelledTaskV2 extends TaskV2 { - readonly status: "cancelled"; -} -export type DetailedTaskV2 = - | WorkingTaskV2 - | InputRequiredTaskV2 - | CompletedTaskV2 - | FailedTaskV2 - | CancelledTaskV2; - -export interface ErrorV2 { - readonly code: number; - readonly message: string; - readonly data?: JsonValue; -} - -export interface CreateMessageRequestV2 { - readonly method: "sampling/createMessage"; - readonly params: Readonly>; -} -export interface ListRootsRequestV2 { - readonly method: "roots/list"; - readonly params?: Readonly>; -} -export interface ElicitRequestV2 { - readonly method: "elicitation/create"; - readonly params: Readonly>; -} -export type InputRequestV2 = - CreateMessageRequestV2 | ListRootsRequestV2 | ElicitRequestV2; -export type InputRequestsV2 = Readonly>; - -export interface CreateMessageResultV2 extends Readonly< - Record -> { - readonly content: JsonValue; - readonly model: string; - readonly role: "user" | "assistant"; -} -export interface ListRootsResultV2 extends Readonly> { - readonly roots: readonly JsonValue[]; -} -export interface ElicitResultV2 extends Readonly> { - readonly action: "accept" | "decline" | "cancel"; -} -export type InputResponseV2 = - CreateMessageResultV2 | ListRootsResultV2 | ElicitResultV2; -export type InputResponsesV2 = Readonly>; - -export interface CreateTaskResultV2 extends TaskV2 { - readonly resultType: "task"; - readonly _meta?: Readonly>; -} -export type CallToolResultV2 = OpenObjectV2 & { - readonly resultType: string; - readonly content: readonly ContentBlockV2[]; - readonly structuredContent?: JsonValue; - readonly isError?: boolean; - readonly _meta?: OpenObjectV2; -}; -/** @deprecated Use CallToolResultV2. */ -export type ToolCallResultV2 = CallToolResultV2; -export type EligibleTaskResultV2 = ToolCallResultV2 | CreateTaskResultV2; - -interface JsonRpcRequestV2 { - readonly jsonrpc: "2.0"; - readonly id: RequestIdV2; -} -export interface GetTaskRequestV2 extends JsonRpcRequestV2 { - readonly method: "tasks/get"; - readonly params: { readonly taskId: string }; -} -export interface UpdateTaskRequestV2 extends JsonRpcRequestV2 { - readonly method: "tasks/update"; - readonly params: { - readonly taskId: string; - readonly inputResponses: InputResponsesV2; - }; -} -export interface CancelTaskRequestV2 extends JsonRpcRequestV2 { - readonly method: "tasks/cancel"; - readonly params: { readonly taskId: string }; -} -export type GetTaskResultV2 = DetailedTaskV2 & { - readonly resultType: "complete"; - readonly _meta?: Readonly>; -}; -export interface UpdateTaskResultV2 extends Readonly< - Record -> { - readonly resultType: "complete"; -} -export interface CancelTaskResultV2 extends Readonly< - Record -> { - readonly resultType: "complete"; -} - -export type TaskStatusNotificationParamsV2 = DetailedTaskV2 & { - readonly _meta?: Readonly>; -}; -export interface TaskStatusNotificationV2 { - readonly jsonrpc: "2.0"; - readonly method: "notifications/tasks"; - readonly params: TaskStatusNotificationParamsV2; -} -export interface TaskSubscriptionNotificationsV2 { - readonly taskIds?: readonly string[]; -} -export interface TaskSubscriptionAcknowledgedNotificationsV2 { - readonly taskIds?: readonly string[]; -} -export type TaskExtensionCapabilitiesV2 = Readonly>; -export type TasksExtensionCapabilityV2 = TaskExtensionCapabilitiesV2; - -export interface ClientTaskCapabilityEnvelopeV2 { - readonly extensions: { - readonly [TASKS_EXTENSION_ID_V2]: TaskExtensionCapabilitiesV2; - }; -} -export interface ServerTaskCapabilityEnvelopeV2 { - readonly extensions?: Readonly>; -} - -const statuses = [ - "working", - "input_required", - "completed", - "failed", - "cancelled", -] as const; -const inputMethods = [ - "sampling/createMessage", - "roots/list", - "elicitation/create", -] as const; - -function has(record: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(record, key); -} -function expectInteger(value: JsonValue | undefined, path: DecodePath): number { - const number = expectNumber(value, path); - if (!Number.isInteger(number)) - throw new ProtocolDecodeError("expected integer", path); - return number; -} -function expectRequestId( - value: JsonValue | undefined, - path: DecodePath, -): RequestIdV2 { - if (typeof value === "string") return value; - return expectInteger(value, path); -} -function expectConst( - value: JsonValue | undefined, - expected: string, - path: DecodePath, -): void { - if (value !== expected) - throw new ProtocolDecodeError(`expected ${expected}`, path); -} -function optionalRecord(value: JsonValue | undefined, path: DecodePath) { - return value === undefined ? undefined : expectRecord(value, path); -} -function optionalString( - object: Record, - key: string, - path: DecodePath, -): void { - if (object[key] !== undefined) expectString(object[key], [...path, key]); -} -function optionalBoolean( - object: Record, - key: string, - path: DecodePath, -): void { - if (object[key] !== undefined && typeof object[key] !== "boolean") - throw new ProtocolDecodeError("expected boolean", [...path, key]); -} -function optionalStringArray( - object: Record, - key: string, - path: DecodePath, -): void { - const value = object[key]; - if ( - value !== undefined && - (!Array.isArray(value) || !value.every((item) => typeof item === "string")) - ) { - throw new ProtocolDecodeError("expected string array", [...path, key]); - } -} - -function decodeAnnotations(value: JsonValue, path: DecodePath): void { - const object = expectRecord(value, path); - if (object.audience !== undefined) { - if ( - !Array.isArray(object.audience) || - !object.audience.every((role) => role === "user" || role === "assistant") - ) { - throw new ProtocolDecodeError("expected role array", [ - ...path, - "audience", - ]); - } - } - if (object.priority !== undefined) { - const priority = expectNumber(object.priority, [...path, "priority"]); - if (priority < 0 || priority > 1) - throw new ProtocolDecodeError("expected number from 0 to 1", [ - ...path, - "priority", - ]); - } - optionalString(object, "lastModified", path); -} - -function decodeIcon(value: JsonValue, path: DecodePath): void { - const object = expectRecord(value, path); - expectString(object.src, [...path, "src"]); - optionalString(object, "mimeType", path); - optionalStringArray(object, "sizes", path); - if (object.theme !== undefined) - expectEnum(object.theme, ["light", "dark"], [...path, "theme"]); -} - -function decodeImplementation(value: JsonValue, path: DecodePath): void { - const object = expectRecord(value, path); - expectString(object.name, [...path, "name"]); - expectString(object.version, [...path, "version"]); - optionalString(object, "title", path); - optionalString(object, "description", path); - optionalString(object, "websiteUrl", path); - if (object.icons !== undefined) { - if (!isJsonArray(object.icons)) - throw new ProtocolDecodeError("expected array", [...path, "icons"]); - object.icons.forEach((icon, index) => - decodeIcon(icon, [...path, "icons", index]), - ); - } -} - -function decodeContentBlock( - value: JsonValue, - path: DecodePath, -): ContentBlockV2 { - const object = expectRecord(value, path); - const type = expectEnum( - object.type, - ["text", "image", "audio", "resource_link", "resource"], - [...path, "type"], - ); - if (type === "text") expectString(object.text, [...path, "text"]); - else if (type === "image" || type === "audio") { - expectString(object.data, [...path, "data"]); - expectString(object.mimeType, [...path, "mimeType"]); - } else if (type === "resource_link") { - expectString(object.name, [...path, "name"]); - expectString(object.uri, [...path, "uri"]); - optionalString(object, "title", path); - optionalString(object, "description", path); - optionalString(object, "mimeType", path); - if (object.size !== undefined) - expectInteger(object.size, [...path, "size"]); - if (object.icons !== undefined) { - if (!isJsonArray(object.icons)) - throw new ProtocolDecodeError("expected array", [...path, "icons"]); - object.icons.forEach((icon, index) => - decodeIcon(icon, [...path, "icons", index]), - ); - } - } else { - const resource = expectRecord(object.resource, [...path, "resource"]); - expectString(resource.uri, [...path, "resource", "uri"]); - optionalString(resource, "mimeType", [...path, "resource"]); - optionalRecord(resource._meta, [...path, "resource", "_meta"]); - const hasText = resource.text !== undefined; - const hasBlob = resource.blob !== undefined; - if (!hasText && !hasBlob) - throw new ProtocolDecodeError("expected text or blob", [ - ...path, - "resource", - ]); - if (hasText) expectString(resource.text, [...path, "resource", "text"]); - if (hasBlob) expectString(resource.blob, [...path, "resource", "blob"]); - } - if (object.annotations !== undefined) - decodeAnnotations(object.annotations, [...path, "annotations"]); - optionalRecord(object._meta, [...path, "_meta"]); - return object as ContentBlockV2; -} - -function decodeTool(value: JsonValue, path: DecodePath): ToolV2 { - const object = expectRecord(value, path); - expectString(object.name, [...path, "name"]); - optionalString(object, "title", path); - optionalString(object, "description", path); - const inputSchema = expectRecord(object.inputSchema, [ - ...path, - "inputSchema", - ]); - expectConst(inputSchema.type, "object", [...path, "inputSchema", "type"]); - optionalString(inputSchema, "$schema", [...path, "inputSchema"]); - if (object.outputSchema !== undefined) { - const outputSchema = expectRecord(object.outputSchema, [ - ...path, - "outputSchema", - ]); - optionalString(outputSchema, "$schema", [...path, "outputSchema"]); - } - if (object.annotations !== undefined) { - const annotations = expectRecord(object.annotations, [ - ...path, - "annotations", - ]); - optionalString(annotations, "title", [...path, "annotations"]); - for (const key of [ - "readOnlyHint", - "destructiveHint", - "idempotentHint", - "openWorldHint", - ]) - optionalBoolean(annotations, key, [...path, "annotations"]); - } - if (object.icons !== undefined) { - if (!isJsonArray(object.icons)) - throw new ProtocolDecodeError("expected array", [...path, "icons"]); - object.icons.forEach((icon, index) => - decodeIcon(icon, [...path, "icons", index]), - ); - } - optionalRecord(object._meta, [...path, "_meta"]); - return object as ToolV2; -} - -function decodeCallToolResult( - value: JsonValue, - path: DecodePath, -): CallToolResultV2 { - const object = expectRecord(value, path); - expectString(object.resultType, [...path, "resultType"]); - if (!isJsonArray(object.content)) - throw new ProtocolDecodeError("expected array", [...path, "content"]); - object.content.forEach((block, index) => - decodeContentBlock(block, [...path, "content", index]), - ); - optionalBoolean(object, "isError", path); - const meta = optionalRecord(object._meta, [...path, "_meta"]); - if (meta?.["io.modelcontextprotocol/serverInfo"] !== undefined) { - decodeImplementation(meta["io.modelcontextprotocol/serverInfo"], [ - ...path, - "_meta", - "io.modelcontextprotocol/serverInfo", - ]); - } - return object as CallToolResultV2; -} - -function decodeTask(value: JsonValue, path: DecodePath): TaskV2 { - const object = expectRecord(value, path); - const ttl = object.ttlMs; - if (!has(object, "ttlMs")) - throw new ProtocolDecodeError("required field", [...path, "ttlMs"]); - const task: TaskV2 = { - taskId: expectString(object.taskId, [...path, "taskId"]), - status: expectEnum(object.status, statuses, [...path, "status"]), - createdAt: expectString(object.createdAt, [...path, "createdAt"]), - lastUpdatedAt: expectString(object.lastUpdatedAt, [ - ...path, - "lastUpdatedAt", - ]), - ttlMs: ttl === null ? null : expectInteger(ttl, [...path, "ttlMs"]), - ...(object.statusMessage === undefined - ? {} - : { - statusMessage: expectString(object.statusMessage, [ - ...path, - "statusMessage", - ]), - }), - ...(object.pollIntervalMs === undefined - ? {} - : { - pollIntervalMs: expectInteger(object.pollIntervalMs, [ - ...path, - "pollIntervalMs", - ]), - }), - }; - return task; -} - -function decodeError(value: JsonValue, path: DecodePath): ErrorV2 { - const object = expectRecord(value, path); - return { - code: expectInteger(object.code, [...path, "code"]), - message: expectString(object.message, [...path, "message"]), - ...(has(object, "data") ? { data: object.data } : {}), - }; -} - -function decodeInputRequest( - value: JsonValue, - path: DecodePath, -): InputRequestV2 { - const object = expectRecord(value, path); - const method = expectEnum(object.method, inputMethods, [...path, "method"]); - if (method === "roots/list") { - return { - method, - ...(object.params === undefined - ? {} - : { params: expectRecord(object.params, [...path, "params"]) }), - }; - } - return { - method, - params: expectRecord(object.params, [...path, "params"]), - }; -} -function decodeInputRequests( - value: JsonValue, - path: DecodePath, -): InputRequestsV2 { - const object = expectRecord(value, path); - return Object.fromEntries( - Object.entries(object).map(([key, request]) => [ - key, - decodeInputRequest(request, [...path, key]), - ]), - ); -} - -function decodeInputResponse( - value: JsonValue, - path: DecodePath, -): InputResponseV2 { - const object = expectRecord(value, path); - if (has(object, "action")) { - expectEnum( - object.action, - ["accept", "decline", "cancel"], - [...path, "action"], - ); - } else if (has(object, "roots")) { - if (!Array.isArray(object.roots)) - throw new ProtocolDecodeError("expected array", [...path, "roots"]); - } else { - if (!has(object, "content")) - throw new ProtocolDecodeError("required field", [...path, "content"]); - expectString(object.model, [...path, "model"]); - expectEnum(object.role, ["user", "assistant"], [...path, "role"]); - } - return object as InputResponseV2; -} -function decodeInputResponses( - value: JsonValue, - path: DecodePath, -): InputResponsesV2 { - const object = expectRecord(value, path); - return Object.fromEntries( - Object.entries(object).map(([key, response]) => [ - key, - decodeInputResponse(response, [...path, key]), - ]), - ); -} - -function decodeDetailedTask( - value: JsonValue, - path: DecodePath, -): DetailedTaskV2 { - const object = expectRecord(value, path); - const task = decodeTask(value, path); - switch (task.status) { - case "input_required": - return { - ...task, - status: task.status, - inputRequests: decodeInputRequests(object.inputRequests, [ - ...path, - "inputRequests", - ]), - }; - case "completed": - return { - ...task, - status: task.status, - result: expectRecord(object.result, [...path, "result"]), - }; - case "failed": - return { - ...task, - status: task.status, - error: decodeError(object.error, [...path, "error"]), - }; - case "working": - return { ...task, status: task.status }; - case "cancelled": - return { ...task, status: task.status }; - } -} - -function decodeRpcRequest(value: JsonValue, path: DecodePath, method: string) { - const object = expectRecord(value, path); - expectConst(object.jsonrpc, "2.0", [...path, "jsonrpc"]); - expectConst(object.method, method, [...path, "method"]); - return { - object, - id: expectRequestId(object.id, [...path, "id"]), - params: expectRecord(object.params, [...path, "params"]), - }; -} -function decodeCompleteResult(value: JsonValue, path: DecodePath) { - const object = expectRecord(value, path); - expectConst(object.resultType, "complete", [...path, "resultType"]); - optionalRecord(object._meta, [...path, "_meta"]); - return object; -} - -export const ToolV2Codec: RuntimeCodec = - createRuntimeCodec(decodeTool); -export const CallToolResultV2Codec: RuntimeCodec = - createRuntimeCodec(decodeCallToolResult); -/** @deprecated Use CallToolResultV2Codec. */ -export const ToolCallResultV2Codec: RuntimeCodec = - CallToolResultV2Codec; -export const TaskV2Codec: RuntimeCodec = - createRuntimeCodec(decodeTask); -export const DetailedTaskV2Codec: RuntimeCodec = - createRuntimeCodec(decodeDetailedTask); -export const ErrorV2Codec: RuntimeCodec = - createRuntimeCodec(decodeError); -export const InputRequestV2Codec: RuntimeCodec = - createRuntimeCodec(decodeInputRequest); -export const InputRequestsV2Codec: RuntimeCodec = - createRuntimeCodec(decodeInputRequests); -export const InputResponseV2Codec: RuntimeCodec = - createRuntimeCodec(decodeInputResponse); -export const InputResponsesV2Codec: RuntimeCodec = - createRuntimeCodec(decodeInputResponses); -export const CreateMessageRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const request = decodeInputRequest(value, path); - if (request.method !== "sampling/createMessage") - throw new ProtocolDecodeError("expected sampling/createMessage", [ - ...path, - "method", - ]); - return request; - }); -export const ListRootsRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const request = decodeInputRequest(value, path); - if (request.method !== "roots/list") - throw new ProtocolDecodeError("expected roots/list", [...path, "method"]); - return request; - }); -export const ElicitRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const request = decodeInputRequest(value, path); - if (request.method !== "elicitation/create") - throw new ProtocolDecodeError("expected elicitation/create", [ - ...path, - "method", - ]); - return request; - }); -export const CreateMessageResultV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const response = decodeInputResponse(value, path); - if ( - !("content" in response) || - !("model" in response) || - !("role" in response) - ) - throw new ProtocolDecodeError("expected sampling result", path); - return response as CreateMessageResultV2; - }); -export const ListRootsResultV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const response = decodeInputResponse(value, path); - if (!("roots" in response)) - throw new ProtocolDecodeError("expected roots result", path); - return response as ListRootsResultV2; - }); -export const ElicitResultV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const response = decodeInputResponse(value, path); - if (!("action" in response)) - throw new ProtocolDecodeError("expected elicitation result", path); - return response as ElicitResultV2; - }); -export const CreateTaskResultV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - expectConst(object.resultType, "task", [...path, "resultType"]); - optionalRecord(object._meta, [...path, "_meta"]); - return { - ...decodeTask(value, path), - resultType: "task", - ...(object._meta === undefined - ? {} - : { _meta: expectRecord(object._meta, [...path, "_meta"]) }), - }; - }); -export const GetTaskRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const { id, params } = decodeRpcRequest(value, path, "tasks/get"); - return { - jsonrpc: "2.0", - id, - method: "tasks/get", - params: { - taskId: expectString(params.taskId, [...path, "params", "taskId"]), - }, - }; - }); -export const UpdateTaskRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const { id, params } = decodeRpcRequest(value, path, "tasks/update"); - return { - jsonrpc: "2.0", - id, - method: "tasks/update", - params: { - taskId: expectString(params.taskId, [...path, "params", "taskId"]), - inputResponses: decodeInputResponses(params.inputResponses, [ - ...path, - "params", - "inputResponses", - ]), - }, - }; - }); -export const CancelTaskRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const { id, params } = decodeRpcRequest(value, path, "tasks/cancel"); - return { - jsonrpc: "2.0", - id, - method: "tasks/cancel", - params: { - taskId: expectString(params.taskId, [...path, "params", "taskId"]), - }, - }; - }); -export const GetTaskResultV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const object = decodeCompleteResult(value, path); - return { - ...decodeDetailedTask(value, path), - resultType: "complete", - ...(object._meta === undefined - ? {} - : { _meta: expectRecord(object._meta, [...path, "_meta"]) }), - }; - }); -export const UpdateTaskResultV2Codec: RuntimeCodec = - createRuntimeCodec( - (value, path) => decodeCompleteResult(value, path) as UpdateTaskResultV2, - ); -export const CancelTaskResultV2Codec: RuntimeCodec = - createRuntimeCodec( - (value, path) => decodeCompleteResult(value, path) as CancelTaskResultV2, - ); -export const WorkingTaskV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const task = decodeDetailedTask(value, path); - if (task.status !== "working") - throw new ProtocolDecodeError("expected working", [...path, "status"]); - return task; - }); -export const InputRequiredTaskV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const task = decodeDetailedTask(value, path); - if (task.status !== "input_required") - throw new ProtocolDecodeError("expected input_required", [ - ...path, - "status", - ]); - return task; - }); -export const CompletedTaskV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const task = decodeDetailedTask(value, path); - if (task.status !== "completed") - throw new ProtocolDecodeError("expected completed", [...path, "status"]); - return task; - }); -export const FailedTaskV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const task = decodeDetailedTask(value, path); - if (task.status !== "failed") - throw new ProtocolDecodeError("expected failed", [...path, "status"]); - return task; - }); -export const CancelledTaskV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const task = decodeDetailedTask(value, path); - if (task.status !== "cancelled") - throw new ProtocolDecodeError("expected cancelled", [...path, "status"]); - return task; - }); -export const TaskStatusNotificationParamsV2Codec: RuntimeCodec = - createRuntimeCodec(decodeDetailedTask); -export const TaskSubscriptionNotificationsV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - if (object.taskIds === undefined) return {}; - if ( - !Array.isArray(object.taskIds) || - !object.taskIds.every((id) => typeof id === "string") - ) - throw new ProtocolDecodeError("expected string array", [ - ...path, - "taskIds", - ]); - return { taskIds: object.taskIds }; - }); -export const TaskSubscriptionAcknowledgedNotificationsV2Codec: RuntimeCodec = - TaskSubscriptionNotificationsV2Codec; -export const TaskExtensionCapabilitiesV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - if (Object.keys(object).length !== 0) - throw new ProtocolDecodeError("expected empty object", path); - return {}; - }); -export const TasksExtensionCapabilityV2Codec: RuntimeCodec = - TaskExtensionCapabilitiesV2Codec; -export const TaskStatusNotificationV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - expectConst(object.jsonrpc, "2.0", [...path, "jsonrpc"]); - expectConst(object.method, "notifications/tasks", [...path, "method"]); - return { - jsonrpc: "2.0", - method: "notifications/tasks", - params: decodeDetailedTask(object.params, [...path, "params"]), - }; - }); - -function parsed( - codec: { parse(value: JsonValue): { success: boolean } }, - value: unknown, -): value is T { - return value !== undefined && codec.parse(value as JsonValue).success; -} -export const isTaskV2: (value: unknown) => value is TaskV2 = ( - value: unknown, -): value is TaskV2 => parsed(TaskV2Codec, value); -export const isDetailedTaskV2: (value: unknown) => value is DetailedTaskV2 = ( - value: unknown, -): value is DetailedTaskV2 => - parsed(DetailedTaskV2Codec, value); -export const isCreateTaskResultV2: ( - value: unknown, -) => value is CreateTaskResultV2 = ( - value: unknown, -): value is CreateTaskResultV2 => - parsed(CreateTaskResultV2Codec, value); -export const isGetTaskRequestV2: ( - value: unknown, -) => value is GetTaskRequestV2 = (value: unknown): value is GetTaskRequestV2 => - parsed(GetTaskRequestV2Codec, value); -export const isUpdateTaskRequestV2: ( - value: unknown, -) => value is UpdateTaskRequestV2 = ( - value: unknown, -): value is UpdateTaskRequestV2 => - parsed(UpdateTaskRequestV2Codec, value); -export const isCancelTaskRequestV2: ( - value: unknown, -) => value is CancelTaskRequestV2 = ( - value: unknown, -): value is CancelTaskRequestV2 => - parsed(CancelTaskRequestV2Codec, value); -export const isTaskStatusNotificationV2: ( - value: unknown, -) => value is TaskStatusNotificationV2 = ( - value: unknown, -): value is TaskStatusNotificationV2 => - parsed(TaskStatusNotificationV2Codec, value); - -export function isToolCallTaskResultV2( - method: string, - value: unknown, -): value is CreateTaskResultV2 { - return method === "tools/call" && isCreateTaskResultV2(value); -} -export const isEligibleTaskResultV2: typeof isToolCallTaskResultV2 = - isToolCallTaskResultV2; - -export function hasTaskClientCapabilityV2(value: unknown): boolean { - if (value === null || typeof value !== "object" || Array.isArray(value)) - return false; - const meta = (value as { _meta?: unknown })._meta; - if (meta === null || typeof meta !== "object" || Array.isArray(meta)) - return false; - const capabilities = (meta as Record)[ - CLIENT_CAPABILITIES_META_KEY_V2 - ]; - if ( - capabilities === null || - typeof capabilities !== "object" || - Array.isArray(capabilities) - ) - return false; - const extensions = (capabilities as { extensions?: unknown }).extensions; - return ( - extensions !== null && - typeof extensions === "object" && - !Array.isArray(extensions) && - Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2) - ); -} -export function hasTaskServerCapabilityV2( - value: unknown, -): value is ServerTaskCapabilityEnvelopeV2 { - if (value === null || typeof value !== "object" || Array.isArray(value)) - return false; - const extensions = (value as { extensions?: unknown }).extensions; - return ( - extensions !== null && - typeof extensions === "object" && - !Array.isArray(extensions) && - Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2) - ); -} -export const supportsTasksExtensionV2: typeof hasTaskServerCapabilityV2 = - hasTaskServerCapabilityV2; - -export function withTaskCapabilityV2< - T extends Readonly>, ->(params: T): T & Readonly> { - const wireMeta = params._meta; - const base = - wireMeta !== null && - typeof wireMeta === "object" && - !Array.isArray(wireMeta) - ? wireMeta - : {}; - const capability = { extensions: { [TASKS_EXTENSION_ID_V2]: {} } }; - return { - ...params, - _meta: { ...base, [CLIENT_CAPABILITIES_META_KEY_V2]: capability }, - }; -} - -export function contributeTaskFilterV2< - T extends Readonly>, ->( - filter: T, - taskIds: readonly string[], -): T & { - readonly notifications: Readonly> & { - readonly taskIds: readonly string[]; - }; -} { - const notifications = filter.notifications; - const prior: Readonly> = - notifications !== null && - typeof notifications === "object" && - !Array.isArray(notifications) - ? (notifications as Readonly>) - : {}; - return { - ...filter, - notifications: { ...prior, taskIds: [...new Set(taskIds)] }, - }; -} -export function readAcceptedTaskIdsV2(value: unknown): readonly string[] { - if (value === null || typeof value !== "object" || Array.isArray(value)) - return []; - const notifications = (value as { notifications?: unknown }).notifications; - if ( - notifications === null || - typeof notifications !== "object" || - Array.isArray(notifications) - ) - return []; - const ids = (notifications as { taskIds?: unknown }).taskIds; - return Array.isArray(ids) && ids.every((id) => typeof id === "string") - ? [...ids] - : []; -} +/** MCP Tasks V2 public API. */ +export { + CLIENT_CAPABILITIES_META_KEY_V2, + TASKS_EXTENSION_ID_V2, + type CallToolResultV2, + type CancelTaskRequestV2, + type CancelTaskResultV2, + type CancelledTaskV2, + type ClientTaskCapabilityEnvelopeV2, + type CompletedTaskV2, + type CreateMessageRequestV2, + type CreateMessageResultV2, + type CreateTaskResultV2, + type DetailedTaskV2, + type ElicitRequestV2, + type ElicitResultV2, + type ErrorV2, + type FailedTaskV2, + type GetTaskRequestV2, + type GetTaskResultV2, + type InputRequestV2, + type InputRequestsV2, + type InputRequiredTaskV2, + type InputResponseV2, + type InputResponsesV2, + type ListRootsRequestV2, + type ListRootsResultV2, + type RequestIdV2, + type ServerTaskCapabilityEnvelopeV2, + type TaskEligibleMethodV2, + type TaskStatusNotificationParamsV2, + type TaskStatusNotificationV2, + type TaskStatusV2, + type TaskSubscriptionAcknowledgedNotificationsV2, + type TaskSubscriptionNotificationsV2, + type TasksExtensionCapabilityV2, + type TaskV2, + type ToolV2, + type UpdateTaskRequestV2, + type UpdateTaskResultV2, + type WorkingTaskV2, +} from "./wire.js"; +export { + CallToolResultV2Codec, + CancelTaskRequestV2Codec, + CancelTaskResultV2Codec, + CancelledTaskV2Codec, + CompletedTaskV2Codec, + CreateMessageRequestV2Codec, + CreateMessageResultV2Codec, + CreateTaskResultV2Codec, + DetailedTaskV2Codec, + ElicitRequestV2Codec, + ElicitResultV2Codec, + ErrorV2Codec, + FailedTaskV2Codec, + GetTaskRequestV2Codec, + GetTaskResultV2Codec, + InputRequestV2Codec, + InputRequestsV2Codec, + InputRequiredTaskV2Codec, + InputResponseV2Codec, + InputResponsesV2Codec, + ListRootsRequestV2Codec, + ListRootsResultV2Codec, + TaskStatusNotificationParamsV2Codec, + TaskStatusNotificationV2Codec, + TaskSubscriptionAcknowledgedNotificationsV2Codec, + TaskSubscriptionNotificationsV2Codec, + TasksExtensionCapabilityV2Codec, + TaskV2Codec, + ToolV2Codec, + UpdateTaskRequestV2Codec, + UpdateTaskResultV2Codec, + WorkingTaskV2Codec, +} from "./codecs.js"; +export { + contributeTaskFilterV2, + hasTaskClientCapabilityV2, + hasTaskServerCapabilityV2, + isCancelTaskRequestV2, + isCreateTaskResultV2, + isDetailedTaskV2, + isGetTaskRequestV2, + isTaskStatusNotificationV2, + isTaskV2, + isToolCallTaskResultV2, + isUpdateTaskRequestV2, + readAcceptedTaskIdsV2, + withTaskCapabilityV2, +} from "./integration.js"; diff --git a/packages/ext-tasks/src/core/v2/integration.ts b/packages/ext-tasks/src/core/v2/integration.ts new file mode 100644 index 0000000..be2dd3a --- /dev/null +++ b/packages/ext-tasks/src/core/v2/integration.ts @@ -0,0 +1,163 @@ +/** MCP Tasks V2 guards, capability integration, and subscription helpers. */ +import { type JsonValue } from "../index.js"; +import { + CreateTaskResultV2Codec, + DetailedTaskV2Codec, + GetTaskRequestV2Codec, + TaskStatusNotificationV2Codec, + TaskV2Codec, + UpdateTaskRequestV2Codec, + CancelTaskRequestV2Codec, +} from "./codecs.js"; +import { + CLIENT_CAPABILITIES_META_KEY_V2, + TASKS_EXTENSION_ID_V2, + type CreateTaskResultV2, + type DetailedTaskV2, + type GetTaskRequestV2, + type ServerTaskCapabilityEnvelopeV2, + type TaskStatusNotificationV2, + type TaskV2, + type UpdateTaskRequestV2, + type CancelTaskRequestV2, +} from "./wire.js"; +function parsed( + codec: { parse(value: unknown): { success: boolean } }, + value: unknown, +): value is T { + return codec.parse(value).success; +} +export const isTaskV2: (value: unknown) => value is TaskV2 = ( + value: unknown, +): value is TaskV2 => parsed(TaskV2Codec, value); +export const isDetailedTaskV2: (value: unknown) => value is DetailedTaskV2 = ( + value: unknown, +): value is DetailedTaskV2 => + parsed(DetailedTaskV2Codec, value); +export const isCreateTaskResultV2: ( + value: unknown, +) => value is CreateTaskResultV2 = ( + value: unknown, +): value is CreateTaskResultV2 => + parsed(CreateTaskResultV2Codec, value); +export const isGetTaskRequestV2: ( + value: unknown, +) => value is GetTaskRequestV2 = (value: unknown): value is GetTaskRequestV2 => + parsed(GetTaskRequestV2Codec, value); +export const isUpdateTaskRequestV2: ( + value: unknown, +) => value is UpdateTaskRequestV2 = ( + value: unknown, +): value is UpdateTaskRequestV2 => + parsed(UpdateTaskRequestV2Codec, value); +export const isCancelTaskRequestV2: ( + value: unknown, +) => value is CancelTaskRequestV2 = ( + value: unknown, +): value is CancelTaskRequestV2 => + parsed(CancelTaskRequestV2Codec, value); +export const isTaskStatusNotificationV2: ( + value: unknown, +) => value is TaskStatusNotificationV2 = ( + value: unknown, +): value is TaskStatusNotificationV2 => + parsed(TaskStatusNotificationV2Codec, value); + +export function isToolCallTaskResultV2( + method: string, + value: unknown, +): value is CreateTaskResultV2 { + return method === "tools/call" && isCreateTaskResultV2(value); +} + +export function hasTaskClientCapabilityV2(value: unknown): boolean { + if (value === null || typeof value !== "object" || Array.isArray(value)) + return false; + const meta = (value as { _meta?: unknown })._meta; + if (meta === null || typeof meta !== "object" || Array.isArray(meta)) + return false; + const capabilities = (meta as Record)[ + CLIENT_CAPABILITIES_META_KEY_V2 + ]; + if ( + capabilities === null || + typeof capabilities !== "object" || + Array.isArray(capabilities) + ) + return false; + const extensions = (capabilities as { extensions?: unknown }).extensions; + return ( + extensions !== null && + typeof extensions === "object" && + !Array.isArray(extensions) && + Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2) + ); +} +export function hasTaskServerCapabilityV2( + value: unknown, +): value is ServerTaskCapabilityEnvelopeV2 { + if (value === null || typeof value !== "object" || Array.isArray(value)) + return false; + const extensions = (value as { extensions?: unknown }).extensions; + return ( + extensions !== null && + typeof extensions === "object" && + !Array.isArray(extensions) && + Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2) + ); +} + +export function withTaskCapabilityV2< + T extends Readonly>, +>(params: T): T & Readonly> { + const wireMeta = params._meta; + const base = + wireMeta !== null && + typeof wireMeta === "object" && + !Array.isArray(wireMeta) + ? wireMeta + : {}; + const capability = { extensions: { [TASKS_EXTENSION_ID_V2]: {} } }; + return { + ...params, + _meta: { ...base, [CLIENT_CAPABILITIES_META_KEY_V2]: capability }, + }; +} + +export function contributeTaskFilterV2< + T extends Readonly>, +>( + filter: T, + taskIds: readonly string[], +): T & { + readonly notifications: Readonly> & { + readonly taskIds: readonly string[]; + }; +} { + const notifications = filter.notifications; + const prior: Readonly> = + notifications !== null && + typeof notifications === "object" && + !Array.isArray(notifications) + ? (notifications as Readonly>) + : {}; + return { + ...filter, + notifications: { ...prior, taskIds: [...new Set(taskIds)] }, + }; +} +export function readAcceptedTaskIdsV2(value: unknown): readonly string[] { + if (value === null || typeof value !== "object" || Array.isArray(value)) + return []; + const notifications = (value as { notifications?: unknown }).notifications; + if ( + notifications === null || + typeof notifications !== "object" || + Array.isArray(notifications) + ) + return []; + const ids = (notifications as { taskIds?: unknown }).taskIds; + return Array.isArray(ids) && ids.every((id) => typeof id === "string") + ? [...ids] + : []; +} diff --git a/packages/ext-tasks/src/core/v2/wire.ts b/packages/ext-tasks/src/core/v2/wire.ts new file mode 100644 index 0000000..5f20fb8 --- /dev/null +++ b/packages/ext-tasks/src/core/v2/wire.ts @@ -0,0 +1,200 @@ +/** MCP Tasks V2 wire declarations. */ +import { type JsonValue } from "../index.js"; + +export const TASKS_EXTENSION_ID_V2 = "io.modelcontextprotocol/tasks" as const; +export const CLIENT_CAPABILITIES_META_KEY_V2 = + "io.modelcontextprotocol/clientCapabilities" as const; + +type OpenObjectV2 = Readonly>; +type ToolAnnotationsV2 = OpenObjectV2 & { + readonly title?: string; + readonly readOnlyHint?: boolean; + readonly destructiveHint?: boolean; + readonly idempotentHint?: boolean; + readonly openWorldHint?: boolean; +}; +type IconV2 = OpenObjectV2 & { + readonly src: string; + readonly mimeType?: string; + readonly sizes?: readonly string[]; + readonly theme?: "light" | "dark"; +}; +export type ContentBlockV2 = OpenObjectV2 & + ( + | { readonly type: "text"; readonly text: string } + | { + readonly type: "image" | "audio"; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly name: string; + readonly uri: string; + } + | { readonly type: "resource"; readonly resource: OpenObjectV2 } + ); + +export type ToolV2 = OpenObjectV2 & { + readonly name: string; + readonly title?: string; + readonly description?: string; + readonly inputSchema: OpenObjectV2 & { + readonly type: "object"; + readonly $schema?: string; + }; + readonly outputSchema?: OpenObjectV2 & { readonly $schema?: string }; + readonly annotations?: ToolAnnotationsV2; + readonly icons?: readonly IconV2[]; + readonly _meta?: OpenObjectV2; +}; +export type RequestIdV2 = string | number; +export type TaskStatusV2 = + "working" | "input_required" | "completed" | "failed" | "cancelled"; + +export type TaskEligibleMethodV2 = "tools/call"; + +export interface TaskV2 { + readonly taskId: string; + readonly status: TaskStatusV2; + readonly statusMessage?: string; + readonly createdAt: string; + readonly lastUpdatedAt: string; + readonly ttlMs: number | null; + readonly pollIntervalMs?: number; +} + +export interface WorkingTaskV2 extends TaskV2 { + readonly status: "working"; +} +export interface InputRequiredTaskV2 extends TaskV2 { + readonly status: "input_required"; + readonly inputRequests: InputRequestsV2; +} +export interface CompletedTaskV2 extends TaskV2 { + readonly status: "completed"; + readonly result: Readonly>; +} +export interface FailedTaskV2 extends TaskV2 { + readonly status: "failed"; + readonly error: ErrorV2; +} +export interface CancelledTaskV2 extends TaskV2 { + readonly status: "cancelled"; +} +export type DetailedTaskV2 = + | WorkingTaskV2 + | InputRequiredTaskV2 + | CompletedTaskV2 + | FailedTaskV2 + | CancelledTaskV2; + +export interface ErrorV2 { + readonly code: number; + readonly message: string; + readonly data?: JsonValue; +} + +export interface CreateMessageRequestV2 { + readonly method: "sampling/createMessage"; + readonly params: Readonly>; +} +export interface ListRootsRequestV2 { + readonly method: "roots/list"; + readonly params?: Readonly>; +} +export interface ElicitRequestV2 { + readonly method: "elicitation/create"; + readonly params: Readonly>; +} +export type InputRequestV2 = + CreateMessageRequestV2 | ListRootsRequestV2 | ElicitRequestV2; +export type InputRequestsV2 = Readonly>; + +export interface CreateMessageResultV2 extends Readonly< + Record +> { + readonly content: JsonValue; + readonly model: string; + readonly role: "user" | "assistant"; +} +export interface ListRootsResultV2 extends Readonly> { + readonly roots: readonly JsonValue[]; +} +export interface ElicitResultV2 extends Readonly> { + readonly action: "accept" | "decline" | "cancel"; +} +export type InputResponseV2 = + CreateMessageResultV2 | ListRootsResultV2 | ElicitResultV2; +export type InputResponsesV2 = Readonly>; + +export interface CreateTaskResultV2 extends TaskV2 { + readonly resultType: "task"; + readonly _meta?: Readonly>; +} +export type CallToolResultV2 = OpenObjectV2 & { + readonly resultType: string; + readonly content: readonly ContentBlockV2[]; + readonly structuredContent?: JsonValue; + readonly isError?: boolean; + readonly _meta?: OpenObjectV2; +}; + +interface JsonRpcRequestV2 { + readonly jsonrpc: "2.0"; + readonly id: RequestIdV2; +} +export interface GetTaskRequestV2 extends JsonRpcRequestV2 { + readonly method: "tasks/get"; + readonly params: { readonly taskId: string }; +} +export interface UpdateTaskRequestV2 extends JsonRpcRequestV2 { + readonly method: "tasks/update"; + readonly params: { + readonly taskId: string; + readonly inputResponses: InputResponsesV2; + }; +} +export interface CancelTaskRequestV2 extends JsonRpcRequestV2 { + readonly method: "tasks/cancel"; + readonly params: { readonly taskId: string }; +} +export type GetTaskResultV2 = DetailedTaskV2 & { + readonly resultType: "complete"; + readonly _meta?: Readonly>; +}; +export interface UpdateTaskResultV2 extends Readonly< + Record +> { + readonly resultType: "complete"; +} +export interface CancelTaskResultV2 extends Readonly< + Record +> { + readonly resultType: "complete"; +} + +export type TaskStatusNotificationParamsV2 = DetailedTaskV2 & { + readonly _meta?: Readonly>; +}; +export interface TaskStatusNotificationV2 { + readonly jsonrpc: "2.0"; + readonly method: "notifications/tasks"; + readonly params: TaskStatusNotificationParamsV2; +} +export interface TaskSubscriptionNotificationsV2 { + readonly taskIds?: readonly string[]; +} +export interface TaskSubscriptionAcknowledgedNotificationsV2 { + readonly taskIds?: readonly string[]; +} +export type TasksExtensionCapabilityV2 = Readonly>; + +export interface ClientTaskCapabilityEnvelopeV2 { + readonly extensions: { + readonly [TASKS_EXTENSION_ID_V2]: TasksExtensionCapabilityV2; + }; +} +export interface ServerTaskCapabilityEnvelopeV2 { + readonly extensions?: Readonly>; +} diff --git a/packages/ext-tasks/src/server/index.test.ts b/packages/ext-tasks/src/server/index.test.ts deleted file mode 100644 index a2d80c1..0000000 --- a/packages/ext-tasks/src/server/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import * as server from "./index.js"; - -describe("server entry point", () => { - it("can be imported", () => { - expect(server).toBeTypeOf("object"); - }); -}); diff --git a/packages/ext-tasks/src/server/index.ts b/packages/ext-tasks/src/server/index.ts deleted file mode 100644 index 976d248..0000000 --- a/packages/ext-tasks/src/server/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Reserved entry point for future receiver-side MCP Tasks lifecycle support. - */ -export {}; diff --git a/packages/ext-tasks/test-support/client/fake-port.ts b/packages/ext-tasks/test-support/client/fake-port.ts new file mode 100644 index 0000000..420650d --- /dev/null +++ b/packages/ext-tasks/test-support/client/fake-port.ts @@ -0,0 +1,93 @@ +import { type JsonValue } from "../../src/core/index.js"; +import { + type ConnectedMcpSessionPort, + type IncomingServerRequest, + type JsonRpcResponse, + type SessionTaskCapabilities, +} from "../../src/client/index.js"; + +export const asJson = (value: unknown): JsonValue => + JSON.parse(JSON.stringify(value)) as JsonValue; + +export const formatJson = (value: unknown): string => + JSON.stringify(value) ?? "undefined"; + +export const asError = (reason: unknown): Error => + reason instanceof Error ? reason : new Error(formatJson(reason)); + +export class FakePort implements ConnectedMcpSessionPort { + readonly endpointId: string; + readonly requests: JsonValue[] = []; + readonly taskCapabilities: SessionTaskCapabilities; + invalidated = false; + response: JsonRpcResponse = { kind: "result", result: { content: [] } }; + dispatchHandler?: ( + request: JsonValue, + options?: { readonly signal?: AbortSignal }, + ) => Promise; + private requestHandler?: ( + incoming: IncomingServerRequest, + ) => Promise; + private notificationListener?: (notification: JsonValue) => void; + private invalidationListener?: (reason: unknown) => void; + listenerDisposals = 0; + + constructor( + taskCapabilities: SessionTaskCapabilities = { generation: "none" }, + endpointId = "fake-endpoint", + ) { + this.taskCapabilities = taskCapabilities; + this.endpointId = endpointId; + } + + async dispatch( + request: JsonValue, + options?: { readonly signal?: AbortSignal }, + ): Promise { + this.requests.push(request); + return this.dispatchHandler === undefined + ? this.response + : this.dispatchHandler(request, options); + } + + onServerRequest( + handler: (incoming: IncomingServerRequest) => Promise, + ): () => void { + this.requestHandler = handler; + return () => { + this.requestHandler = undefined; + this.listenerDisposals += 1; + }; + } + + onNotification(listener: (notification: JsonValue) => void): () => void { + this.notificationListener = listener; + return () => { + this.notificationListener = undefined; + this.listenerDisposals += 1; + }; + } + + onInvalidated(listener: (reason: unknown) => void): () => void { + this.invalidationListener = listener; + return () => { + this.invalidationListener = undefined; + this.listenerDisposals += 1; + }; + } + + invalidate(reason: unknown): void { + this.invalidated = true; + this.invalidationListener?.(reason); + } + + async serve(request: JsonValue): Promise { + if (this.requestHandler === undefined) + throw new Error("request handler is not installed"); + return this.requestHandler({ request, requestContext: {} }); + } + + notify(notification: JsonValue): void { + this.notificationListener?.(notification); + } +} diff --git a/packages/ext-tasks/tsconfig.eslint.json b/packages/ext-tasks/tsconfig.eslint.json index e3e203b..8791751 100644 --- a/packages/ext-tasks/tsconfig.eslint.json +++ b/packages/ext-tasks/tsconfig.eslint.json @@ -7,5 +7,5 @@ "isolatedDeclarations": false, "noEmit": true }, - "include": ["src/**/*.ts", "vitest.config.ts"] + "include": ["src/**/*.ts", "test-support/**/*.ts", "vitest.config.ts"] } diff --git a/packages/ext-tasks/tsconfig.test.json b/packages/ext-tasks/tsconfig.test.json index 679649a..b71f450 100644 --- a/packages/ext-tasks/tsconfig.test.json +++ b/packages/ext-tasks/tsconfig.test.json @@ -7,5 +7,5 @@ "isolatedDeclarations": false, "noEmit": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test-support/**/*.ts"] } From 241f27d90eb96a7f3283a29776f43558061fc891 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 4 Sep 2026 22:36:55 -0700 Subject: [PATCH 10/33] fix: prevent stale SDK artifacts in package --- packages/ext-tasks/package.json | 2 +- packages/ext-tasks/scripts/check-exports.mjs | 34 ++++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/ext-tasks/package.json b/packages/ext-tasks/package.json index ca99a7d..2993a70 100644 --- a/packages/ext-tasks/package.json +++ b/packages/ext-tasks/package.json @@ -65,7 +65,7 @@ }, "homepage": "https://github.com/modelcontextprotocol/ext-tasks#readme", "scripts": { - "clean": "tsc -b tsconfig.json --clean", + "clean": "tsc -b tsconfig.json --clean && node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "npm run clean && tsc -b tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit", "test": "vitest run", diff --git a/packages/ext-tasks/scripts/check-exports.mjs b/packages/ext-tasks/scripts/check-exports.mjs index ab96a2f..90960f8 100644 --- a/packages/ext-tasks/scripts/check-exports.mjs +++ b/packages/ext-tasks/scripts/check-exports.mjs @@ -170,7 +170,7 @@ async function checkBuiltContract() { assert.deepEqual( sorted(Object.keys(manifest.exports ?? {})), sorted(expectedSubpaths), - "exports must contain exactly the four public subpaths and no root export", + "exports must contain exactly the public subpaths and no root export", ); const typeMappings = manifest.typesVersions?.["*"] ?? {}; @@ -252,6 +252,11 @@ async function checkPackedContract() { false, "Tarball includes source, test-support, or test files", ); + assert.equal( + packedPaths.some((path) => /^dist\/server\//u.test(path)), + false, + "Tarball includes removed server artifacts", + ); assert.equal( packedPaths.some((path) => /(?:^|\/)package\.json$/u.test(path)), true, @@ -369,18 +374,33 @@ async function checkPackedContract() { ); } - const installedFiles = await listFiles( - join(consumerDirectory, "node_modules", ...packageName.split("/")), + const installedPackageDirectory = join( + consumerDirectory, + "node_modules", + ...packageName.split("/"), + ); + const installedRelativePaths = ( + await listFiles(installedPackageDirectory) + ).map((path) => + relative(installedPackageDirectory, path).replaceAll("\\", "/"), ); assert.equal( - installedFiles.some((path) => - /(?:^|\/)(?:test-support|tests?)(?:\/|$)/u.test( - relative(consumerDirectory, path), - ), + installedRelativePaths.includes("dist/client/index.js"), + true, + "Installed package inventory is missing dist/client/index.js", + ); + assert.equal( + installedRelativePaths.some((path) => + /(?:^|\/)(?:test-support|tests?)(?:\/|$)/u.test(path), ), false, "Installed package includes test support", ); + assert.equal( + installedRelativePaths.some((path) => /^dist\/server\//u.test(path)), + false, + "Installed package includes removed server artifacts", + ); console.log(`Validated packed consumer contract: ${filename}`); } finally { await rm(temporaryDirectory, { recursive: true, force: true }); From 00e0fb70a42aff73e7715d9fec14edf6492aa5f4 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 4 Sep 2026 23:09:39 -0700 Subject: [PATCH 11/33] fix: require built client artifact in tarball --- packages/ext-tasks/scripts/check-exports.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ext-tasks/scripts/check-exports.mjs b/packages/ext-tasks/scripts/check-exports.mjs index 90960f8..a5dba04 100644 --- a/packages/ext-tasks/scripts/check-exports.mjs +++ b/packages/ext-tasks/scripts/check-exports.mjs @@ -245,6 +245,11 @@ async function checkPackedContract() { ]); const [{ filename, files }] = JSON.parse(packOutput); const packedPaths = files.map(({ path }) => path); + assert.equal( + packedPaths.includes("dist/client/index.js"), + true, + "Tarball is missing dist/client/index.js", + ); assert.equal( packedPaths.some((path) => /(^|\/)(src|test-support|tests?)(\/|$)/u.test(path), From 8757d5f7729a75508b2c51dde3a39e9fb80ede86 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Sat, 5 Sep 2026 00:01:49 -0700 Subject: [PATCH 12/33] refactor: consolidate codec helpers and require JSDoc - Share generation-neutral codec validation and path helpers - Preserve generation-specific request and wire decoding - Require and add JSDoc for exported functions --- package-lock.json | 237 +++++++++++++++++- packages/ext-tasks/eslint.config.mjs | 5 + packages/ext-tasks/package.json | 1 + packages/ext-tasks/src/client/execution.ts | 4 + .../ext-tasks/src/client/input-routing.ts | 4 + packages/ext-tasks/src/client/port.ts | 5 + .../src/client/sdk-client-adapter.ts | 3 + packages/ext-tasks/src/client/session.ts | 1 + .../ext-tasks/src/client/task-protocol-v1.ts | 1 + .../ext-tasks/src/client/task-protocol-v2.ts | 1 + packages/ext-tasks/src/core/index.ts | 5 + packages/ext-tasks/src/core/internal/codec.ts | 63 +++++ packages/ext-tasks/src/core/v1/codecs.ts | 23 +- packages/ext-tasks/src/core/v1/negotiation.ts | 11 + packages/ext-tasks/src/core/v2/codecs.ts | 28 +-- packages/ext-tasks/src/core/v2/integration.ts | 22 ++ 16 files changed, 372 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index 46a6a5b..8297605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -456,6 +456,33 @@ } } }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.96.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.96.0.tgz", + "integrity": "sha512-nvtxrDqAJOKMPSsMHqshCnHvnCqFnJTsKpTaTSaZoUi9VxGVe2JvgQeAY/Qvh2wyqXByIAqNTgI7h9elXzn0yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.9", + "@typescript-eslint/types": "^8.67.0", + "comment-parser": "1.4.8", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~9.2.0" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + } + }, + "node_modules/@es-joy/resolve.exports": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz", + "integrity": "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -2077,6 +2104,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/base62": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", + "integrity": "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2452,6 +2492,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3162,6 +3212,16 @@ "node": ">=14" } }, + "node_modules/are-docs-informative": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.1.1.tgz", + "integrity": "sha512-sqRsNQBwbKLRX0jV5Cu5uzmtflf892n4Vukz7T659ebL4pz3mpOqCMU7lxMoBTFwnp10E3YB5ZcyHM41W5bcDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3412,6 +3472,16 @@ "node": ">= 10" } }, + "node_modules/comment-parser": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.8.tgz", + "integrity": "sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -4289,6 +4359,49 @@ } } }, + "node_modules/eslint-plugin-jsdoc": { + "version": "64.3.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-64.3.5.tgz", + "integrity": "sha512-BqRUwfREoBH+7pzDD+uIQ3aema5P7BnPYcHj3mU0DLm+p/5TEHdimkXgnQfYl0AGgB9PZxZW/Lk7D69FTJSLXw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.96.0", + "@es-joy/resolve.exports": "1.2.0", + "@typescript-eslint/utils": "^8.69.0", + "are-docs-informative": "^0.1.1", + "comment-parser": "1.4.8", + "debug": "^4.4.3", + "escape-string-regexp": "^5.0.0", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "html-entities": "^2.6.0", + "object-deep-merge": "^2.0.1", + "parse-imports-exports": "^0.2.4", + "semver": "^7.8.5", + "spdx-expression-parse": "^5.0.0", + "to-valid-identifier": "^1.0.0" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -4759,6 +4872,23 @@ "dev": true, "license": "MIT" }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -4937,6 +5067,20 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-9.2.1.tgz", + "integrity": "sha512-V4Ww4EHnTcTLSOMoB0FsF72JhQvcAsriCm/LWnxJeGWoxIjEL2l9na11abQok5SYShq8m0Gl02el/xAbTCulvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.9", + "@types/node": "^26.4.0" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5754,6 +5898,13 @@ "license": "MIT", "optional": true }, + "node_modules/object-deep-merge": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.1.tgz", + "integrity": "sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==", + "dev": true, + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -5853,6 +6004,23 @@ "dev": true, "license": "MIT" }, + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -6100,6 +6268,19 @@ "dev": true, "license": "MIT" }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -6258,9 +6439,9 @@ "peer": true }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -6388,6 +6569,31 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-5.0.0.tgz", + "integrity": "sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/speakingurl": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", @@ -6790,6 +6996,23 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-valid-identifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", + "integrity": "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/base62": "^1.0.0", + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -6961,6 +7184,13 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -8004,6 +8234,7 @@ "@eslint/js": "^10.0.1", "@modelcontextprotocol/client": "^2.0.0", "eslint": "^10.10.0", + "eslint-plugin-jsdoc": "^64.3.5", "fast-check": "^4.9.0", "globals": "^17.12.0", "prettier": "^3.9.6", diff --git a/packages/ext-tasks/eslint.config.mjs b/packages/ext-tasks/eslint.config.mjs index 0c2ba9a..9b83e1b 100644 --- a/packages/ext-tasks/eslint.config.mjs +++ b/packages/ext-tasks/eslint.config.mjs @@ -1,4 +1,5 @@ import eslint from "@eslint/js"; +import jsdoc from "eslint-plugin-jsdoc"; import globals from "globals"; import tseslint from "typescript-eslint"; @@ -10,6 +11,10 @@ export default tseslint.config( ...tseslint.configs.recommendedTypeChecked, { files: ["src/**/*.ts", "test-support/**/*.ts", "vitest.config.ts"], + plugins: { jsdoc }, + rules: { + "jsdoc/require-jsdoc": ["error", { publicOnly: true }], + }, languageOptions: { globals: globals.browser, parserOptions: { diff --git a/packages/ext-tasks/package.json b/packages/ext-tasks/package.json index 2993a70..db4ef20 100644 --- a/packages/ext-tasks/package.json +++ b/packages/ext-tasks/package.json @@ -83,6 +83,7 @@ "@eslint/js": "^10.0.1", "@modelcontextprotocol/client": "^2.0.0", "eslint": "^10.10.0", + "eslint-plugin-jsdoc": "^64.3.5", "fast-check": "^4.9.0", "globals": "^17.12.0", "prettier": "^3.9.6", diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts index 61b761e..2c51740 100644 --- a/packages/ext-tasks/src/client/execution.ts +++ b/packages/ext-tasks/src/client/execution.ts @@ -19,12 +19,14 @@ import type { SessionTaskCapabilities } from "./port.js"; import { linkAbortSignals, withAbort } from "./port.js"; import { throwIfAborted } from "./input-routing.js"; +/** Selects the default tool-result codec for the negotiated task generation. */ export function defaultResultCodec( generation: SessionTaskCapabilities["generation"], ): RuntimeCodec { return generation === "v2" ? CallToolResultV2Codec : CallToolResultV1Codec; } +/** Normalizes an invalidation or abort reason to an Error instance. */ export function reasonAsError(reason: unknown): Error { if (reason instanceof Error) return reason; return new Error( @@ -330,6 +332,7 @@ export class TaskExecution< } } +/** Produces stable JSON-like text by sorting object keys recursively. */ export function deterministicJson(value: unknown): string { if (value === null || typeof value !== "object") { const encoded = JSON.stringify(value); @@ -345,6 +348,7 @@ export function deterministicJson(value: unknown): string { .join(",")}}`; } +/** Returns whether a V1 task status is terminal. */ export function terminalStatus(status: TaskV1["status"]): boolean { return ( status === "completed" || status === "failed" || status === "cancelled" diff --git a/packages/ext-tasks/src/client/input-routing.ts b/packages/ext-tasks/src/client/input-routing.ts index dd8c8a1..ba40fd3 100644 --- a/packages/ext-tasks/src/client/input-routing.ts +++ b/packages/ext-tasks/src/client/input-routing.ts @@ -1,6 +1,7 @@ import type { JsonValue, TaskGeneration, TaskId } from "../core/index.js"; import type { IncomingServerRequest, JsonRpcResponse } from "./port.js"; +/** Returns object-valued request parameters, defaulting an omitted value to empty. */ export function requestParams( request: Readonly>, ): Readonly> { @@ -36,10 +37,12 @@ export interface V1TaskInputCandidate { let nextExecutionId = 0; +/** Allocates a process-local identifier for an ordinary tool execution. */ export function nextExecutionIdentifier(): string { return `execution-${++nextExecutionId}`; } +/** Returns the conservative fallback response for an unhandled server request. */ export function defaultServerRequestResponse( incoming: IncomingServerRequest, ): JsonRpcResponse { @@ -56,6 +59,7 @@ export function defaultServerRequestResponse( return { kind: "error", error: { code: -32603, message: "Internal error" } }; } +/** Throws the abort reason when a signal has already been aborted. */ export function throwIfAborted(signal: AbortSignal | undefined): void { if (signal?.aborted !== true) return; throw signal.reason instanceof Error diff --git a/packages/ext-tasks/src/client/port.ts b/packages/ext-tasks/src/client/port.ts index 432a74a..a276f55 100644 --- a/packages/ext-tasks/src/client/port.ts +++ b/packages/ext-tasks/src/client/port.ts @@ -48,6 +48,7 @@ export class DispatchError extends Error { this.retryable = retryable; } } +/** Races a promise against an optional abort signal and releases its listener. */ export async function withAbort( promise: Promise, signal?: AbortSignal, @@ -71,6 +72,7 @@ export async function withAbort( } } +/** Links abort signals into one disposable lifecycle. */ export function linkAbortSignals( ...signals: readonly (AbortSignal | undefined)[] ): { @@ -99,6 +101,7 @@ export function linkAbortSignals( }; } +/** Dispatches a request with the retry policy for its observation or mutation intent. */ export async function dispatchWithRetry( port: ConnectedMcpSessionPort, request: JsonValue, @@ -120,12 +123,14 @@ export async function dispatchWithRetry( } } +/** Decodes a successful JSON value or throws its protocol decode error. */ export function decodeResult(codec: RuntimeCodec, value: JsonValue): T { const decoded = codec.parse(value); if (!decoded.success) throw decoded.error; return decoded.value; } +/** Unwraps a JSON-RPC result or throws the response error. */ export function responseResult(response: JsonRpcResponse): JsonValue { if (response.kind === "error") throw new JsonRpcResponseError(response.error); return response.result; diff --git a/packages/ext-tasks/src/client/sdk-client-adapter.ts b/packages/ext-tasks/src/client/sdk-client-adapter.ts index 719f4ef..d58b2b0 100644 --- a/packages/ext-tasks/src/client/sdk-client-adapter.ts +++ b/packages/ext-tasks/src/client/sdk-client-adapter.ts @@ -93,6 +93,7 @@ type ClientPublicSurface = Pick< const adaptedClients = new WeakSet(); +/** Returns whether a value implements the connected MCP session port contract. */ export function isConnectedMcpSessionPort( value: unknown, ): value is ConnectedMcpSessionPort { @@ -109,6 +110,7 @@ export function isConnectedMcpSessionPort( ); } +/** Returns whether a value exposes the MCP SDK client methods required by this adapter. */ export function isClientPublicSurface( value: unknown, ): value is ClientPublicSurface { @@ -279,6 +281,7 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { } } +/** Creates a disposable connected session port backed by an MCP SDK client. */ export function createSessionPortFromClient( client: Client, endpointId: string, diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index 83ba5c7..5e25155 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -639,6 +639,7 @@ class PortTaskEnabledSession< } } +/** Adds task execution support to a connected session port or MCP SDK client. */ export function withTasks( session: ConnectedMcpSessionPort, options?: WithTasksOptions, diff --git a/packages/ext-tasks/src/client/task-protocol-v1.ts b/packages/ext-tasks/src/client/task-protocol-v1.ts index 7c09e5c..5e83419 100644 --- a/packages/ext-tasks/src/client/task-protocol-v1.ts +++ b/packages/ext-tasks/src/client/task-protocol-v1.ts @@ -20,6 +20,7 @@ import { type ConnectedMcpSessionPort, } from "./port.js"; +/** Creates an execution controller for an existing V1 task. */ export function createTaskExecutionV1(options: { readonly applicationContext: TApplicationContext; readonly handle: TaskHandle & { readonly generation: "v1" }; diff --git a/packages/ext-tasks/src/client/task-protocol-v2.ts b/packages/ext-tasks/src/client/task-protocol-v2.ts index fdd160e..6cac35c 100644 --- a/packages/ext-tasks/src/client/task-protocol-v2.ts +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -33,6 +33,7 @@ import { type ConnectedMcpSessionPort, } from "./port.js"; +/** Creates an execution controller for an existing V2 task. */ export function createTaskExecutionV2(options: { readonly applicationContext: TApplicationContext; readonly handle: TaskHandle & { readonly generation: "v2" }; diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts index 11573f3..db8be6b 100644 --- a/packages/ext-tasks/src/core/index.ts +++ b/packages/ext-tasks/src/core/index.ts @@ -35,10 +35,15 @@ export type TaskSnapshot = | { readonly generation: "v1"; readonly task: TaskV1 } | { readonly generation: "v2"; readonly task: TaskV2 | DetailedTaskV2 }; +/** Brands a string as a task identifier without runtime validation or transformation. */ export function taskId(value: string): TaskId { return value as TaskId; } +/** + * Checks recursively whether a value is JSON-compatible, rejecting non-finite numbers, + * sparse arrays, non-plain objects, and cyclic references. + */ export function isJsonValue(value: unknown): value is JsonValue { const visiting = new WeakSet(); const visit = (candidate: unknown): boolean => { diff --git a/packages/ext-tasks/src/core/internal/codec.ts b/packages/ext-tasks/src/core/internal/codec.ts index 24e39ca..7732a34 100644 --- a/packages/ext-tasks/src/core/internal/codec.ts +++ b/packages/ext-tasks/src/core/internal/codec.ts @@ -14,11 +14,14 @@ export class ProtocolDecodeError extends Error { } } +/** Returns whether a JSON value is an array. */ export function isJsonArray( value: JsonValue | undefined, ): value is readonly JsonValue[] { return Array.isArray(value); } + +/** Creates a runtime codec from a path-aware decoder. */ export function createRuntimeCodec( decode: (value: JsonValue, path: DecodePath) => T, ): RuntimeCodec { @@ -42,6 +45,7 @@ export function createRuntimeCodec( }; } +/** Requires a JSON object at the supplied decode path. */ export function expectRecord( value: JsonValue, path: DecodePath = [], @@ -52,6 +56,7 @@ export function expectRecord( return value as Record; } +/** Requires a string at the supplied decode path. */ export function expectString( value: JsonValue | undefined, path: DecodePath, @@ -61,6 +66,7 @@ export function expectString( return value; } +/** Requires a finite number at the supplied decode path. */ export function expectNumber( value: JsonValue | undefined, path: DecodePath, @@ -71,6 +77,7 @@ export function expectNumber( return value; } +/** Requires one of the allowed string values at the supplied decode path. */ export function expectEnum( value: JsonValue | undefined, values: readonly T[], @@ -81,3 +88,59 @@ export function expectEnum( } return value as T; } + +/** Appends a property or index to a decode path. */ +export function childPath(path: DecodePath, key: string | number): DecodePath { + return [...path, key]; +} + +/** Returns whether a decoded object defines an own property. */ +export function hasOwn( + record: Readonly>, + key: string, +): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +/** Requires an integer at the supplied decode path. */ +export function expectInteger( + value: JsonValue | undefined, + path: DecodePath, +): number { + const number = expectNumber(value, path); + if (!Number.isInteger(number)) { + throw new ProtocolDecodeError("expected integer", path); + } + return number; +} + +/** Requires a specific literal value at the supplied decode path. */ +export function expectLiteral( + value: JsonValue | undefined, + expected: string | number | boolean | null, + path: DecodePath, +): void { + if (value !== expected) { + throw new ProtocolDecodeError(`expected ${String(expected)}`, path); + } +} + +/** Decodes an optional JSON object at the supplied path. */ +export function expectOptionalRecord( + value: JsonValue | undefined, + path: DecodePath, +): Record | undefined { + return value === undefined ? undefined : expectRecord(value, path); +} + +/** Decodes an optional boolean at the supplied path. */ +export function expectOptionalBoolean( + value: JsonValue | undefined, + path: DecodePath, +): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value !== "boolean") { + throw new ProtocolDecodeError("expected boolean", path); + } + return value; +} diff --git a/packages/ext-tasks/src/core/v1/codecs.ts b/packages/ext-tasks/src/core/v1/codecs.ts index 2be01cd..633909e 100644 --- a/packages/ext-tasks/src/core/v1/codecs.ts +++ b/packages/ext-tasks/src/core/v1/codecs.ts @@ -1,9 +1,12 @@ /** MCP Tasks V1 runtime codecs. */ import { ProtocolDecodeError, + childPath as at, createRuntimeCodec, expectEnum, - expectNumber, + expectInteger, + expectLiteral, + expectOptionalBoolean, expectRecord, expectString, isJsonArray, @@ -39,19 +42,12 @@ interface JsonRpcRequestV1 { readonly params: P; } -function at(path: DecodePath, key: string | number): DecodePath { - return [...path, key]; -} function optionalBoolean( record: Record, key: string, path: DecodePath, ): boolean | undefined { - const value = record[key]; - if (value === undefined) return undefined; - if (typeof value !== "boolean") - throw new ProtocolDecodeError("expected boolean", at(path, key)); - return value; + return expectOptionalBoolean(record[key], at(path, key)); } function jsonRecord( value: JsonValue | undefined, @@ -76,8 +72,7 @@ function literal( expected: string, path: DecodePath, ): void { - if (record[key] !== expected) - throw new ProtocolDecodeError(`expected ${expected}`, at(path, key)); + expectLiteral(record[key], expected, at(path, key)); } function decodeId( value: JsonValue | undefined, @@ -87,12 +82,6 @@ function decodeId( throw new ProtocolDecodeError("expected request id", path); return value; } -function expectInteger(value: JsonValue | undefined, path: DecodePath): number { - const number = expectNumber(value, path); - if (!Number.isInteger(number)) - throw new ProtocolDecodeError("expected integer", path); - return number; -} function decodeContentBlock( value: JsonValue, path: DecodePath, diff --git a/packages/ext-tasks/src/core/v1/negotiation.ts b/packages/ext-tasks/src/core/v1/negotiation.ts index a4d1cd8..22fb283 100644 --- a/packages/ext-tasks/src/core/v1/negotiation.ts +++ b/packages/ext-tasks/src/core/v1/negotiation.ts @@ -6,26 +6,34 @@ import { type TaskEligibleMethodV1, type ToolV1, } from "./wire.js"; +/** Checks whether the server advertises task listing by defining its list capability. */ export function hasTaskListCapabilityV1( capabilities: ServerTaskCapabilitiesV1, ): boolean { return capabilities.list !== undefined; } +/** Checks whether the server advertises task cancellation by defining its cancel capability. */ export function hasTaskCancelCapabilityV1( capabilities: ServerTaskCapabilitiesV1, ): boolean { return capabilities.cancel !== undefined; } +/** Checks whether the server defines task support for tool-call requests. */ export function hasTaskToolCallCapabilityV1( capabilities: ServerTaskCapabilitiesV1, ): boolean { return capabilities.requests?.tools?.call !== undefined; } +/** Narrows a method string to the sole V1 task-eligible method, `tools/call`. */ export function isTaskEligibleMethodV1( method: string, ): method is TaskEligibleMethodV1 { return method === "tools/call"; } +/** + * Chooses task execution only when the server supports task tool calls and the tool + * requires tasks, or optionally supports them while the caller explicitly prefers tasks. + */ export function shouldCallToolAsTaskV1( capabilities: ServerTaskCapabilitiesV1, tool: ToolV1, @@ -37,6 +45,9 @@ export function shouldCallToolAsTaskV1( (tool.execution?.taskSupport === "optional" && preferTask) ); } +/** + * Builds a `tools/call` task request, omitting `arguments` when none are supplied. + */ export function callToolAsTaskV1( name: string, arguments_?: Readonly>, diff --git a/packages/ext-tasks/src/core/v2/codecs.ts b/packages/ext-tasks/src/core/v2/codecs.ts index f9ce28e..b632432 100644 --- a/packages/ext-tasks/src/core/v2/codecs.ts +++ b/packages/ext-tasks/src/core/v2/codecs.ts @@ -3,9 +3,14 @@ import { ProtocolDecodeError, createRuntimeCodec, expectEnum, + expectInteger, + expectLiteral as expectConst, expectNumber, + expectOptionalBoolean, + expectOptionalRecord as optionalRecord, expectRecord, expectString, + hasOwn as has, isJsonArray, type DecodePath, } from "../internal/codec.js"; @@ -59,15 +64,6 @@ const inputMethods = [ "elicitation/create", ] as const; -function has(record: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(record, key); -} -function expectInteger(value: JsonValue | undefined, path: DecodePath): number { - const number = expectNumber(value, path); - if (!Number.isInteger(number)) - throw new ProtocolDecodeError("expected integer", path); - return number; -} function expectRequestId( value: JsonValue | undefined, path: DecodePath, @@ -75,17 +71,6 @@ function expectRequestId( if (typeof value === "string") return value; return expectInteger(value, path); } -function expectConst( - value: JsonValue | undefined, - expected: string, - path: DecodePath, -): void { - if (value !== expected) - throw new ProtocolDecodeError(`expected ${expected}`, path); -} -function optionalRecord(value: JsonValue | undefined, path: DecodePath) { - return value === undefined ? undefined : expectRecord(value, path); -} function optionalString( object: Record, key: string, @@ -98,8 +83,7 @@ function optionalBoolean( key: string, path: DecodePath, ): void { - if (object[key] !== undefined && typeof object[key] !== "boolean") - throw new ProtocolDecodeError("expected boolean", [...path, key]); + expectOptionalBoolean(object[key], [...path, key]); } function optionalStringArray( object: Record, diff --git a/packages/ext-tasks/src/core/v2/integration.ts b/packages/ext-tasks/src/core/v2/integration.ts index be2dd3a..ead0d99 100644 --- a/packages/ext-tasks/src/core/v2/integration.ts +++ b/packages/ext-tasks/src/core/v2/integration.ts @@ -63,6 +63,9 @@ export const isTaskStatusNotificationV2: ( ): value is TaskStatusNotificationV2 => parsed(TaskStatusNotificationV2Codec, value); +/** + * Recognizes a decoded task-creation result only when it belongs to `tools/call`. + */ export function isToolCallTaskResultV2( method: string, value: unknown, @@ -70,6 +73,10 @@ export function isToolCallTaskResultV2( return method === "tools/call" && isCreateTaskResultV2(value); } +/** + * Checks for the tasks extension inside object-shaped client capability metadata, + * returning false for malformed or missing containers. + */ export function hasTaskClientCapabilityV2(value: unknown): boolean { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; @@ -93,6 +100,9 @@ export function hasTaskClientCapabilityV2(value: unknown): boolean { Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2) ); } +/** + * Narrows an object-shaped server capability envelope when its extensions own the tasks key. + */ export function hasTaskServerCapabilityV2( value: unknown, ): value is ServerTaskCapabilityEnvelopeV2 { @@ -107,6 +117,10 @@ export function hasTaskServerCapabilityV2( ); } +/** + * Returns a copy with the client tasks capability installed in `_meta`, preserving existing + * object-shaped metadata and replacing malformed metadata with a fresh object. + */ export function withTaskCapabilityV2< T extends Readonly>, >(params: T): T & Readonly> { @@ -124,6 +138,10 @@ export function withTaskCapabilityV2< }; } +/** + * Returns a filter with deduplicated task IDs, preserving existing object-shaped + * notification fields and replacing malformed notification data. + */ export function contributeTaskFilterV2< T extends Readonly>, >( @@ -146,6 +164,10 @@ export function contributeTaskFilterV2< notifications: { ...prior, taskIds: [...new Set(taskIds)] }, }; } +/** + * Copies task IDs from an accepted notification filter, or returns an empty array when + * any enclosing value is malformed or any ID is not a string. + */ export function readAcceptedTaskIdsV2(value: unknown): readonly string[] { if (value === null || typeof value !== "object" || Array.isArray(value)) return []; From 02ce4ce56a3295b3c61e963131f7de3937f7d39b Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Sat, 5 Sep 2026 13:29:08 -0700 Subject: [PATCH 13/33] refactor: consolidate complete codec helpers --- packages/ext-tasks/src/core/internal/codec.ts | 45 ++++++++---- packages/ext-tasks/src/core/v1/codecs.ts | 40 ++--------- packages/ext-tasks/src/core/v2/codecs.ts | 70 +++++++++---------- 3 files changed, 72 insertions(+), 83 deletions(-) diff --git a/packages/ext-tasks/src/core/internal/codec.ts b/packages/ext-tasks/src/core/internal/codec.ts index 7732a34..daaacea 100644 --- a/packages/ext-tasks/src/core/internal/codec.ts +++ b/packages/ext-tasks/src/core/internal/codec.ts @@ -56,6 +56,17 @@ export function expectRecord( return value as Record; } +/** Requires a possibly absent value to be a JSON object. */ +export function expectRequiredRecord( + value: JsonValue | undefined, + path: DecodePath, +): Record { + if (value === undefined) { + throw new ProtocolDecodeError("expected object", path); + } + return expectRecord(value, path); +} + /** Requires a string at the supplied decode path. */ export function expectString( value: JsonValue | undefined, @@ -114,33 +125,41 @@ export function expectInteger( return number; } -/** Requires a specific literal value at the supplied decode path. */ -export function expectLiteral( - value: JsonValue | undefined, +/** Requires a specific literal-valued property on a decoded object. */ +export function expectLiteralProperty( + record: Readonly>, + key: string, expected: string | number | boolean | null, path: DecodePath, ): void { - if (value !== expected) { - throw new ProtocolDecodeError(`expected ${String(expected)}`, path); + const propertyPath = childPath(path, key); + if (record[key] !== expected) { + throw new ProtocolDecodeError(`expected ${String(expected)}`, propertyPath); } } -/** Decodes an optional JSON object at the supplied path. */ -export function expectOptionalRecord( - value: JsonValue | undefined, +/** Decodes an optional object-valued property on a decoded object. */ +export function expectOptionalRecordProperty( + record: Readonly>, + key: string, path: DecodePath, ): Record | undefined { - return value === undefined ? undefined : expectRecord(value, path); + const value = record[key]; + return value === undefined + ? undefined + : expectRecord(value, childPath(path, key)); } -/** Decodes an optional boolean at the supplied path. */ -export function expectOptionalBoolean( - value: JsonValue | undefined, +/** Decodes an optional boolean-valued property on a decoded object. */ +export function expectOptionalBooleanProperty( + record: Readonly>, + key: string, path: DecodePath, ): boolean | undefined { + const value = record[key]; if (value === undefined) return undefined; if (typeof value !== "boolean") { - throw new ProtocolDecodeError("expected boolean", path); + throw new ProtocolDecodeError("expected boolean", childPath(path, key)); } return value; } diff --git a/packages/ext-tasks/src/core/v1/codecs.ts b/packages/ext-tasks/src/core/v1/codecs.ts index 633909e..8775c41 100644 --- a/packages/ext-tasks/src/core/v1/codecs.ts +++ b/packages/ext-tasks/src/core/v1/codecs.ts @@ -5,9 +5,11 @@ import { createRuntimeCodec, expectEnum, expectInteger, - expectLiteral, - expectOptionalBoolean, + expectLiteralProperty as literal, + expectOptionalBooleanProperty as optionalBoolean, + expectOptionalRecordProperty as optionalJsonRecord, expectRecord, + expectRequiredRecord as jsonRecord, expectString, isJsonArray, type DecodePath, @@ -42,38 +44,6 @@ interface JsonRpcRequestV1 { readonly params: P; } -function optionalBoolean( - record: Record, - key: string, - path: DecodePath, -): boolean | undefined { - return expectOptionalBoolean(record[key], at(path, key)); -} -function jsonRecord( - value: JsonValue | undefined, - path: DecodePath, -): Readonly> { - if (value === undefined) - throw new ProtocolDecodeError("expected object", path); - return expectRecord(value, path); -} -function optionalJsonRecord( - record: Record, - key: string, - path: DecodePath, -) { - return record[key] === undefined - ? undefined - : jsonRecord(record[key], at(path, key)); -} -function literal( - record: Record, - key: string, - expected: string, - path: DecodePath, -): void { - expectLiteral(record[key], expected, at(path, key)); -} function decodeId( value: JsonValue | undefined, path: DecodePath, @@ -316,7 +286,7 @@ export const CallToolResultV1Codec: RuntimeCodec = ); if (record.structuredContent !== undefined) jsonRecord(record.structuredContent, at(path, "structuredContent")); - if (record.isError !== undefined) optionalBoolean(record, "isError", path); + optionalBoolean(record, "isError", path); if (record._meta !== undefined) jsonRecord(record._meta, at(path, "_meta")); return record as unknown as CallToolResultV1; }); diff --git a/packages/ext-tasks/src/core/v2/codecs.ts b/packages/ext-tasks/src/core/v2/codecs.ts index b632432..cbecfdf 100644 --- a/packages/ext-tasks/src/core/v2/codecs.ts +++ b/packages/ext-tasks/src/core/v2/codecs.ts @@ -4,13 +4,14 @@ import { createRuntimeCodec, expectEnum, expectInteger, - expectLiteral as expectConst, + expectLiteralProperty, expectNumber, - expectOptionalBoolean, - expectOptionalRecord as optionalRecord, + expectOptionalBooleanProperty, + expectOptionalRecordProperty, expectRecord, + expectRequiredRecord, expectString, - hasOwn as has, + hasOwn, isJsonArray, type DecodePath, } from "../internal/codec.js"; @@ -78,13 +79,6 @@ function optionalString( ): void { if (object[key] !== undefined) expectString(object[key], [...path, key]); } -function optionalBoolean( - object: Record, - key: string, - path: DecodePath, -): void { - expectOptionalBoolean(object[key], [...path, key]); -} function optionalStringArray( object: Record, key: string, @@ -178,10 +172,13 @@ function decodeContentBlock( ); } } else { - const resource = expectRecord(object.resource, [...path, "resource"]); + const resource = expectRequiredRecord(object.resource, [ + ...path, + "resource", + ]); expectString(resource.uri, [...path, "resource", "uri"]); optionalString(resource, "mimeType", [...path, "resource"]); - optionalRecord(resource._meta, [...path, "resource", "_meta"]); + expectOptionalRecordProperty(resource, "_meta", [...path, "resource"]); const hasText = resource.text !== undefined; const hasBlob = resource.blob !== undefined; if (!hasText && !hasBlob) @@ -194,7 +191,7 @@ function decodeContentBlock( } if (object.annotations !== undefined) decodeAnnotations(object.annotations, [...path, "annotations"]); - optionalRecord(object._meta, [...path, "_meta"]); + expectOptionalRecordProperty(object, "_meta", path); return object as ContentBlockV2; } @@ -207,7 +204,10 @@ function decodeTool(value: JsonValue, path: DecodePath): ToolV2 { ...path, "inputSchema", ]); - expectConst(inputSchema.type, "object", [...path, "inputSchema", "type"]); + expectLiteralProperty(inputSchema, "type", "object", [ + ...path, + "inputSchema", + ]); optionalString(inputSchema, "$schema", [...path, "inputSchema"]); if (object.outputSchema !== undefined) { const outputSchema = expectRecord(object.outputSchema, [ @@ -228,7 +228,7 @@ function decodeTool(value: JsonValue, path: DecodePath): ToolV2 { "idempotentHint", "openWorldHint", ]) - optionalBoolean(annotations, key, [...path, "annotations"]); + expectOptionalBooleanProperty(annotations, key, [...path, "annotations"]); } if (object.icons !== undefined) { if (!isJsonArray(object.icons)) @@ -237,7 +237,7 @@ function decodeTool(value: JsonValue, path: DecodePath): ToolV2 { decodeIcon(icon, [...path, "icons", index]), ); } - optionalRecord(object._meta, [...path, "_meta"]); + expectOptionalRecordProperty(object, "_meta", path); return object as ToolV2; } @@ -252,8 +252,8 @@ function decodeCallToolResult( object.content.forEach((block, index) => decodeContentBlock(block, [...path, "content", index]), ); - optionalBoolean(object, "isError", path); - const meta = optionalRecord(object._meta, [...path, "_meta"]); + expectOptionalBooleanProperty(object, "isError", path); + const meta = expectOptionalRecordProperty(object, "_meta", path); if (meta?.["io.modelcontextprotocol/serverInfo"] !== undefined) { decodeImplementation(meta["io.modelcontextprotocol/serverInfo"], [ ...path, @@ -267,7 +267,7 @@ function decodeCallToolResult( function decodeTask(value: JsonValue, path: DecodePath): TaskV2 { const object = expectRecord(value, path); const ttl = object.ttlMs; - if (!has(object, "ttlMs")) + if (!hasOwn(object, "ttlMs")) throw new ProtocolDecodeError("required field", [...path, "ttlMs"]); // Task is a closed wire shape; wrappers preserve extension data through `_meta`. const task: TaskV2 = { @@ -304,7 +304,7 @@ function decodeError(value: JsonValue, path: DecodePath): ErrorV2 { return { code: expectInteger(object.code, [...path, "code"]), message: expectString(object.message, [...path, "message"]), - ...(has(object, "data") ? { data: object.data } : {}), + ...(hasOwn(object, "data") ? { data: object.data } : {}), }; } @@ -324,7 +324,7 @@ function decodeInputRequest( } return { method, - params: expectRecord(object.params, [...path, "params"]), + params: expectRequiredRecord(object.params, [...path, "params"]), }; } function decodeInputRequests( @@ -345,17 +345,17 @@ function decodeInputResponse( path: DecodePath, ): InputResponseV2 { const object = expectRecord(value, path); - if (has(object, "action")) { + if (hasOwn(object, "action")) { expectEnum( object.action, ["accept", "decline", "cancel"], [...path, "action"], ); - } else if (has(object, "roots")) { + } else if (hasOwn(object, "roots")) { if (!Array.isArray(object.roots)) throw new ProtocolDecodeError("expected array", [...path, "roots"]); } else { - if (!has(object, "content")) + if (!hasOwn(object, "content")) throw new ProtocolDecodeError("required field", [...path, "content"]); expectString(object.model, [...path, "model"]); expectEnum(object.role, ["user", "assistant"], [...path, "role"]); @@ -395,7 +395,7 @@ function decodeDetailedTask( return { ...task, status: task.status, - result: expectRecord(object.result, [...path, "result"]), + result: expectRequiredRecord(object.result, [...path, "result"]), }; case "failed": return { @@ -412,18 +412,18 @@ function decodeDetailedTask( function decodeRpcRequest(value: JsonValue, path: DecodePath, method: string) { const object = expectRecord(value, path); - expectConst(object.jsonrpc, "2.0", [...path, "jsonrpc"]); - expectConst(object.method, method, [...path, "method"]); + expectLiteralProperty(object, "jsonrpc", "2.0", path); + expectLiteralProperty(object, "method", method, path); return { object, id: expectRequestId(object.id, [...path, "id"]), - params: expectRecord(object.params, [...path, "params"]), + params: expectRequiredRecord(object.params, [...path, "params"]), }; } function decodeCompleteResult(value: JsonValue, path: DecodePath) { const object = expectRecord(value, path); - expectConst(object.resultType, "complete", [...path, "resultType"]); - optionalRecord(object._meta, [...path, "_meta"]); + expectLiteralProperty(object, "resultType", "complete", path); + expectOptionalRecordProperty(object, "_meta", path); return object; } @@ -500,8 +500,8 @@ export const ElicitResultV2Codec: RuntimeCodec = export const CreateTaskResultV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const object = expectRecord(value, path); - expectConst(object.resultType, "task", [...path, "resultType"]); - optionalRecord(object._meta, [...path, "_meta"]); + expectLiteralProperty(object, "resultType", "task", path); + expectOptionalRecordProperty(object, "_meta", path); return { ...decodeTask(value, path), resultType: "task", @@ -651,8 +651,8 @@ export const TasksExtensionCapabilityV2Codec: RuntimeCodec = createRuntimeCodec((value, path) => { const object = expectRecord(value, path); - expectConst(object.jsonrpc, "2.0", [...path, "jsonrpc"]); - expectConst(object.method, "notifications/tasks", [...path, "method"]); + expectLiteralProperty(object, "jsonrpc", "2.0", path); + expectLiteralProperty(object, "method", "notifications/tasks", path); return { jsonrpc: "2.0", method: "notifications/tasks", From 2c7e9e6b675ccc317aa59bec456a89ea6d983aaf Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Sat, 5 Sep 2026 14:57:17 -0700 Subject: [PATCH 14/33] feat: adopt Zod-first task schemas - Replace handwritten V1/V2 codecs and wire types with inferred Zod schemas - Accept caller result schemas directly and remove custom codec compatibility APIs - Harden schema exports, protocol defaults, and packed dependency contracts --- package-lock.json | 12 +- package.json | 4 +- packages/ext-tasks/README.md | 18 +- packages/ext-tasks/package.json | 3 + packages/ext-tasks/scripts/check-exports.mjs | 228 ++++-- packages/ext-tasks/src/client/api.ts | 6 +- .../client/declarations-capabilities.test.ts | 2 +- packages/ext-tasks/src/client/execution.ts | 15 +- .../client/immediate-session-basics.test.ts | 26 +- packages/ext-tasks/src/client/port.ts | 11 +- packages/ext-tasks/src/client/session.ts | 70 +- .../src/client/task-lifecycle-races.test.ts | 2 +- .../ext-tasks/src/client/task-protocol-v1.ts | 26 +- .../ext-tasks/src/client/task-protocol-v2.ts | 51 +- .../src/client/task-resumption.test.ts | 3 +- .../ext-tasks/src/client/tool-declarations.ts | 36 +- .../src/client/v1-input-task.test.ts | 3 +- .../src/client/v2-input-task.test.ts | 13 +- packages/ext-tasks/src/core/index.test.ts | 95 +-- packages/ext-tasks/src/core/index.ts | 26 +- packages/ext-tasks/src/core/internal/codec.ts | 165 ----- packages/ext-tasks/src/core/v1/codecs.ts | 393 ----------- packages/ext-tasks/src/core/v1/index.test.ts | 326 +++++---- packages/ext-tasks/src/core/v1/index.ts | 44 +- packages/ext-tasks/src/core/v1/negotiation.ts | 2 +- packages/ext-tasks/src/core/v1/schemas.ts | 217 ++++++ packages/ext-tasks/src/core/v1/wire.ts | 157 ----- packages/ext-tasks/src/core/v2/codecs.ts | 664 ------------------ packages/ext-tasks/src/core/v2/index.test.ts | 294 +++++--- packages/ext-tasks/src/core/v2/index.ts | 76 +- packages/ext-tasks/src/core/v2/integration.ts | 36 +- packages/ext-tasks/src/core/v2/schemas.ts | 384 ++++++++++ packages/ext-tasks/src/core/v2/wire.ts | 200 ------ .../test-support/client/fake-port.ts | 6 + packages/ext-tasks/tsconfig.json | 1 + 35 files changed, 1381 insertions(+), 2234 deletions(-) delete mode 100644 packages/ext-tasks/src/core/internal/codec.ts delete mode 100644 packages/ext-tasks/src/core/v1/codecs.ts create mode 100644 packages/ext-tasks/src/core/v1/schemas.ts delete mode 100644 packages/ext-tasks/src/core/v1/wire.ts delete mode 100644 packages/ext-tasks/src/core/v2/codecs.ts create mode 100644 packages/ext-tasks/src/core/v2/schemas.ts delete mode 100644 packages/ext-tasks/src/core/v2/wire.ts diff --git a/package-lock.json b/package-lock.json index 8297605..ae877d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "vitepress": "^1.6.4", "vitepress-plugin-mermaid": "^2.0.17", "vitest": "^5.0.0", - "zod": "^4.4.3" + "zod": "^4.5.4" } }, "node_modules/@algolia/abtesting": { @@ -8206,10 +8206,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -8230,6 +8229,9 @@ "name": "@modelcontextprotocol/ext-tasks", "version": "0.1.0", "license": "Apache-2.0", + "dependencies": { + "zod": "^4.5.4" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@modelcontextprotocol/client": "^2.0.0", diff --git a/package.json b/package.json index ecbcff9..b59c053 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,9 @@ "ts-to-zod": "^5.1.0", "tsx": "^4.21.0", "typescript": "^5.0.0", - "vitest": "^5.0.0", "vitepress": "^1.6.4", "vitepress-plugin-mermaid": "^2.0.17", - "zod": "^4.4.3" + "vitest": "^5.0.0", + "zod": "^4.5.4" } } diff --git a/packages/ext-tasks/README.md b/packages/ext-tasks/README.md index e0d6c6f..cdf8532 100644 --- a/packages/ext-tasks/README.md +++ b/packages/ext-tasks/README.md @@ -1,6 +1,6 @@ # `@modelcontextprotocol/ext-tasks` -Protocol declarations and requester-side lifecycle support for MCP Tasks. +Protocol Zod schemas and requester-side lifecycle support for MCP Tasks. This package intentionally has no root entry point. Import the role or protocol generation needed by the application: @@ -9,6 +9,22 @@ generation needed by the application: import { withTasks } from "@modelcontextprotocol/ext-tasks/client"; ``` +```ts +import { TaskV2Schema } from "@modelcontextprotocol/ext-tasks/core/v2"; +import * as z from "zod/v4"; + +TaskV2Schema.parse(taskPayload); + +const execution = await withTasks(client).callTool( + "generate_report", + undefined, + { + resultSchema: z.object({ reportUrl: z.url() }), + }, +); +const { reportUrl } = await execution.result(); +``` + The public package subpaths are: - `@modelcontextprotocol/ext-tasks/core` diff --git a/packages/ext-tasks/package.json b/packages/ext-tasks/package.json index db4ef20..6219a1d 100644 --- a/packages/ext-tasks/package.json +++ b/packages/ext-tasks/package.json @@ -88,5 +88,8 @@ "globals": "^17.12.0", "prettier": "^3.9.6", "typescript-eslint": "^8.69.0" + }, + "dependencies": { + "zod": "^4.5.4" } } diff --git a/packages/ext-tasks/scripts/check-exports.mjs b/packages/ext-tasks/scripts/check-exports.mjs index a5dba04..8924054 100644 --- a/packages/ext-tasks/scripts/check-exports.mjs +++ b/packages/ext-tasks/scripts/check-exports.mjs @@ -19,35 +19,33 @@ const manifest = JSON.parse( const packageName = manifest.name; const publicSubpaths = ["core", "core/v1", "core/v2", "client"]; const expectedRuntimeExports = { - core: [ - "ProtocolDecodeError", - "createRuntimeCodec", - "expectEnum", - "expectNumber", - "expectRecord", - "expectString", - "isJsonArray", - "isJsonValue", - "taskId", - ], + core: ["JsonValueSchema", "isJsonValue", "taskId"], "core/v1": [ - "CallToolRequestV1Codec", - "CallToolResultV1Codec", - "CancelTaskRequestV1Codec", - "CancelTaskResultV1Codec", - "CreateTaskResultV1Codec", - "GetTaskRequestV1Codec", - "GetTaskResultRequestV1Codec", - "GetTaskResultV1Codec", - "ListTasksRequestV1Codec", - "ListTasksResultV1Codec", - "ServerTaskCapabilitiesV1Codec", - "TaskResultV1Codec", - "TaskStatusNotificationV1Codec", - "TaskStatusV1Codec", + "CallToolAsTaskRequestV1Schema", + "CallToolRequestV1Schema", + "CallToolResultV1Schema", + "CancelTaskRequestV1Schema", + "CancelTaskResultV1Schema", + "ContentBlockV1Schema", + "CreateTaskResultV1Schema", + "GetTaskRequestV1Schema", + "GetTaskResultRequestV1Schema", + "GetTaskResultV1Schema", + "JsonRpcRequestIdV1Schema", + "ListTasksRequestV1Schema", + "ListTasksResultV1Schema", + "ServerCapabilitiesV1Schema", + "ServerTaskCapabilitiesV1Schema", + "TaskEligibleMethodV1Schema", + "TaskMetadataV1Schema", + "TaskResultV1Schema", + "TaskStatusNotificationV1Schema", + "TaskStatusV1Schema", "TaskStatusesV1", - "TaskV1Codec", - "ToolV1Codec", + "TaskSupportV1Schema", + "TaskV1Schema", + "ToolExecutionV1Schema", + "ToolV1Schema", "callToolAsTaskV1", "hasTaskCancelCapabilityV1", "hasTaskListCapabilityV1", @@ -57,39 +55,45 @@ const expectedRuntimeExports = { ], "core/v2": [ "CLIENT_CAPABILITIES_META_KEY_V2", - "CallToolResultV2Codec", - "CancelTaskRequestV2Codec", - "CancelTaskResultV2Codec", - "CancelledTaskV2Codec", - "CompletedTaskV2Codec", - "CreateMessageRequestV2Codec", - "CreateMessageResultV2Codec", - "CreateTaskResultV2Codec", - "DetailedTaskV2Codec", - "ElicitRequestV2Codec", - "ElicitResultV2Codec", - "ErrorV2Codec", - "FailedTaskV2Codec", - "GetTaskRequestV2Codec", - "GetTaskResultV2Codec", - "InputRequestV2Codec", - "InputRequestsV2Codec", - "InputRequiredTaskV2Codec", - "InputResponseV2Codec", - "InputResponsesV2Codec", - "ListRootsRequestV2Codec", - "ListRootsResultV2Codec", + "CallToolResultV2Schema", + "CancelTaskRequestV2Schema", + "CancelTaskResultV2Schema", + "CancelledTaskV2Schema", + "ClientTaskCapabilityEnvelopeV2Schema", + "CompletedTaskV2Schema", + "ContentBlockV2Schema", + "CreateMessageRequestV2Schema", + "CreateMessageResultV2Schema", + "CreateTaskResultV2Schema", + "DetailedTaskV2Schema", + "ElicitRequestV2Schema", + "ElicitResultV2Schema", + "ErrorV2Schema", + "FailedTaskV2Schema", + "GetTaskRequestV2Schema", + "GetTaskResultV2Schema", + "InputRequestV2Schema", + "InputRequestsV2Schema", + "InputRequiredTaskV2Schema", + "InputResponseV2Schema", + "InputResponsesV2Schema", + "ListRootsRequestV2Schema", + "ListRootsResultV2Schema", + "RequestIdV2Schema", + "ServerTaskCapabilityEnvelopeV2Schema", "TASKS_EXTENSION_ID_V2", - "TaskStatusNotificationParamsV2Codec", - "TaskStatusNotificationV2Codec", - "TaskSubscriptionAcknowledgedNotificationsV2Codec", - "TaskSubscriptionNotificationsV2Codec", - "TaskV2Codec", - "TasksExtensionCapabilityV2Codec", - "ToolV2Codec", - "UpdateTaskRequestV2Codec", - "UpdateTaskResultV2Codec", - "WorkingTaskV2Codec", + "TaskEligibleMethodV2Schema", + "TaskStatusNotificationParamsV2Schema", + "TaskStatusNotificationV2Schema", + "TaskStatusV2Schema", + "TaskSubscriptionAcknowledgedNotificationsV2Schema", + "TaskSubscriptionNotificationsV2Schema", + "TaskV2Schema", + "TasksExtensionCapabilityV2Schema", + "ToolV2Schema", + "UpdateTaskRequestV2Schema", + "UpdateTaskResultV2Schema", + "WorkingTaskV2Schema", "contributeTaskFilterV2", "hasTaskClientCapabilityV2", "hasTaskServerCapabilityV2", @@ -115,6 +119,69 @@ const expectedRuntimeExports = { "withTasks", ], }; +const removedCoreNames = [ + "DecodePath", + "ProtocolDecodeError", + "RuntimeCodec", + "createRuntimeCodec", + "expectEnum", + "expectNumber", + "expectRecord", + "expectString", + "isJsonArray", +]; +const removedV1CodecNames = [ + "CallToolRequestV1Codec", + "CallToolResultV1Codec", + "CancelTaskRequestV1Codec", + "CancelTaskResultV1Codec", + "CreateTaskResultV1Codec", + "GetTaskRequestV1Codec", + "GetTaskResultRequestV1Codec", + "GetTaskResultV1Codec", + "ListTasksRequestV1Codec", + "ListTasksResultV1Codec", + "ServerTaskCapabilitiesV1Codec", + "TaskResultV1Codec", + "TaskStatusNotificationV1Codec", + "TaskStatusV1Codec", + "TaskV1Codec", + "ToolV1Codec", +]; +const removedV2CodecNames = [ + "CallToolResultV2Codec", + "CancelTaskRequestV2Codec", + "CancelTaskResultV2Codec", + "CancelledTaskV2Codec", + "CompletedTaskV2Codec", + "CreateMessageRequestV2Codec", + "CreateMessageResultV2Codec", + "CreateTaskResultV2Codec", + "DetailedTaskV2Codec", + "ElicitRequestV2Codec", + "ElicitResultV2Codec", + "ErrorV2Codec", + "FailedTaskV2Codec", + "GetTaskRequestV2Codec", + "GetTaskResultV2Codec", + "InputRequestV2Codec", + "InputRequestsV2Codec", + "InputRequiredTaskV2Codec", + "InputResponseV2Codec", + "InputResponsesV2Codec", + "ListRootsRequestV2Codec", + "ListRootsResultV2Codec", + "TaskStatusNotificationParamsV2Codec", + "TaskStatusNotificationV2Codec", + "TaskSubscriptionAcknowledgedNotificationsV2Codec", + "TaskSubscriptionNotificationsV2Codec", + "TaskV2Codec", + "TasksExtensionCapabilityV2Codec", + "ToolV2Codec", + "UpdateTaskRequestV2Codec", + "UpdateTaskResultV2Codec", + "WorkingTaskV2Codec", +]; const removedPublicAliasesV2 = [ "EligibleTaskResultV2", "TaskExtensionCapabilitiesV2", @@ -131,7 +198,6 @@ const removedRuntimeAliasesV2 = [ "supportsTasksExtensionV2", ]; const unbarreledInternalTypesV2 = [ - "ContentBlockV2", "IconV2", "JsonRpcRequestV2", "OpenObjectV2", @@ -172,6 +238,11 @@ async function checkBuiltContract() { sorted(expectedSubpaths), "exports must contain exactly the public subpaths and no root export", ); + assert.equal( + typeof manifest.dependencies?.zod, + "string", + "zod must be a runtime dependency", + ); const typeMappings = manifest.typesVersions?.["*"] ?? {}; assert.deepEqual( @@ -290,6 +361,9 @@ async function checkPackedContract() { ], { cwd: consumerDirectory }, ); + await access( + join(consumerDirectory, "node_modules", "zod", "package.json"), + ); const positiveImports = publicSubpaths .map( @@ -297,10 +371,18 @@ async function checkPackedContract() { `import * as ${subpath.replace(/\W/gu, "_")} from "${packageName}/${subpath}";`, ) .join("\n"); - await writeFile( - join(consumerDirectory, "positive.ts"), - `${positiveImports}\nvoid 0;\n`, - ); + const positiveSource = `${positiveImports} +import { withTasks, type ConnectedMcpSessionPort } from "${packageName}/client"; +import * as z from "zod/v4"; +declare const port: ConnectedMcpSessionPort; +const session = withTasks(port); +const execution = await session.callTool("example", undefined, { + resultSchema: z.object({ value: z.string() }).transform(({ value }) => value.length), +}); +const inferred: number = await execution.result(); +void inferred; +`; + await writeFile(join(consumerDirectory, "positive.ts"), positiveSource); const baseCompilerOptions = { target: "ES2022", module: "NodeNext", @@ -338,6 +420,24 @@ async function checkPackedContract() { "test-support", `import "${packageName}/test-support/client/fake-port";`, ], + [ + "removed-result-codec-option", + `import { withTasks, type ConnectedMcpSessionPort } from "${packageName}/client"; +declare const port: ConnectedMcpSessionPort; +void withTasks(port).callTool("example", undefined, { resultCodec: {} });`, + ], + ...removedCoreNames.map((name) => [ + `removed-core-${name}`, + `import { ${name} } from "${packageName}/core";`, + ]), + ...removedV1CodecNames.map((name) => [ + `removed-v1-${name}`, + `import { ${name} } from "${packageName}/core/v1";`, + ]), + ...removedV2CodecNames.map((name) => [ + `removed-v2-codec-${name}`, + `import { ${name} } from "${packageName}/core/v2";`, + ]), ...unavailableV2Names.map((alias) => [ `removed-v2-${alias}`, `import { ${alias} } from "${packageName}/core/v2";`, diff --git a/packages/ext-tasks/src/client/api.ts b/packages/ext-tasks/src/client/api.ts index d2c03ab..2798548 100644 --- a/packages/ext-tasks/src/client/api.ts +++ b/packages/ext-tasks/src/client/api.ts @@ -1,10 +1,10 @@ import type { JsonValue, - RuntimeCodec, TaskGeneration, TaskId, TaskSnapshot, } from "../core/index.js"; +import type { z } from "zod/v4"; import type { CallToolResultV1, TaskEligibleMethodV1, @@ -201,7 +201,7 @@ export interface TaskEnabledSession { name: string, params?: Readonly>, options?: { - readonly resultCodec?: RuntimeCodec; + readonly resultSchema?: z.ZodType; readonly applicationContext?: TApplicationContext; readonly signal?: AbortSignal; readonly preferTask?: boolean; @@ -210,7 +210,7 @@ export interface TaskEnabledSession { resumeTask( reference: SerializedTaskReference, options?: { - readonly resultCodec?: RuntimeCodec; + readonly resultSchema?: z.ZodType; readonly applicationContext?: TApplicationContext; readonly signal?: AbortSignal; }, diff --git a/packages/ext-tasks/src/client/declarations-capabilities.test.ts b/packages/ext-tasks/src/client/declarations-capabilities.test.ts index 91bc625..dd3e231 100644 --- a/packages/ext-tasks/src/client/declarations-capabilities.test.ts +++ b/packages/ext-tasks/src/client/declarations-capabilities.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { expectRecord } from "../core/index.js"; import { type ToolV1 } from "../core/v1/index.js"; import { DispatchError, withTasks, type JsonRpcResponse } from "./index.js"; import { FakePort, asJson, formatJson, + expectRecord, } from "../../test-support/client/fake-port.js"; describe("declarations and capabilities", () => { diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts index 2c51740..f716639 100644 --- a/packages/ext-tasks/src/client/execution.ts +++ b/packages/ext-tasks/src/client/execution.ts @@ -1,11 +1,12 @@ -import type { RuntimeCodec, TaskSnapshot } from "../core/index.js"; +import type { TaskSnapshot } from "../core/index.js"; +import type { z } from "zod/v4"; import { - CallToolResultV1Codec, + CallToolResultV1Schema, type CallToolResultV1, type TaskV1, } from "../core/v1/index.js"; import { - CallToolResultV2Codec, + CallToolResultV2Schema, type CallToolResultV2, } from "../core/v2/index.js"; import { @@ -19,11 +20,11 @@ import type { SessionTaskCapabilities } from "./port.js"; import { linkAbortSignals, withAbort } from "./port.js"; import { throwIfAborted } from "./input-routing.js"; -/** Selects the default tool-result codec for the negotiated task generation. */ -export function defaultResultCodec( +/** Selects the default tool-result schema for the negotiated task generation. */ +export function defaultResultSchema( generation: SessionTaskCapabilities["generation"], -): RuntimeCodec { - return generation === "v2" ? CallToolResultV2Codec : CallToolResultV1Codec; +): z.ZodType { + return generation === "v2" ? CallToolResultV2Schema : CallToolResultV1Schema; } /** Normalizes an invalidation or abort reason to an Error instance. */ diff --git a/packages/ext-tasks/src/client/immediate-session-basics.test.ts b/packages/ext-tasks/src/client/immediate-session-basics.test.ts index 99ff77a..ba5ecbd 100644 --- a/packages/ext-tasks/src/client/immediate-session-basics.test.ts +++ b/packages/ext-tasks/src/client/immediate-session-basics.test.ts @@ -1,10 +1,7 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { - createRuntimeCodec, - expectRecord, - type JsonValue, -} from "../core/index.js"; +import { type JsonValue } from "../core/index.js"; +import { z } from "zod/v4"; import { DispatchError, JsonRpcResponseError, withTasks } from "./index.js"; import { FakePort, asJson } from "../../test-support/client/fake-port.js"; @@ -47,23 +44,26 @@ describe("immediate and session basics", () => { ); }); - it("uses an application result codec at the dispatch boundary", async () => { + it("uses an application result schema at the dispatch boundary", async () => { const port = new FakePort(); port.response = { kind: "result", result: { answer: 42 } }; - const codec = createRuntimeCodec((value) => { - const record = expectRecord(value); - if (typeof record.answer !== "number") throw new Error("answer required"); - return record.answer; - }); + const resultSchema = z + .object({ answer: z.number() }) + .transform(({ answer }) => String(answer)); const session = withTasks(port, { tools: { currentTool: () => undefined }, }); const execution = await session.callTool("answer", undefined, { - resultCodec: codec, + resultSchema, applicationContext: "ctx", }); expect(execution.applicationContext).toBe("ctx"); - await expect(execution.result()).resolves.toBe(42); + await expect(execution.result()).resolves.toBe("42"); + + port.response = { kind: "result", result: { answer: "invalid" } }; + await expect( + session.callTool("answer", undefined, { resultSchema }), + ).rejects.toBeInstanceOf(z.ZodError); await session.close(); }); diff --git a/packages/ext-tasks/src/client/port.ts b/packages/ext-tasks/src/client/port.ts index a276f55..e9d8eb5 100644 --- a/packages/ext-tasks/src/client/port.ts +++ b/packages/ext-tasks/src/client/port.ts @@ -1,4 +1,5 @@ -import type { JsonValue, RuntimeCodec } from "../core/index.js"; +import type { JsonValue } from "../core/index.js"; +import type { z } from "zod/v4"; import type { ServerTaskCapabilitiesV1 } from "../core/v1/index.js"; import type { ErrorV2, TasksExtensionCapabilityV2 } from "../core/v2/index.js"; import { JsonRpcResponseError } from "./api.js"; @@ -123,11 +124,9 @@ export async function dispatchWithRetry( } } -/** Decodes a successful JSON value or throws its protocol decode error. */ -export function decodeResult(codec: RuntimeCodec, value: JsonValue): T { - const decoded = codec.parse(value); - if (!decoded.success) throw decoded.error; - return decoded.value; +/** Validates and parses a JSON-RPC result with the supplied Zod schema. */ +export function parseResult(schema: z.ZodType, value: JsonValue): T { + return schema.parse(value); } /** Unwraps a JSON-RPC result or throws the response error. */ diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index 5e25155..506ff07 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -2,23 +2,23 @@ import { Client } from "@modelcontextprotocol/client"; import { isJsonValue, type JsonValue, - type RuntimeCodec, type TaskId, type TaskSnapshot, } from "../core/index.js"; +import type { z } from "zod/v4"; import { - CreateTaskResultV1Codec, - GetTaskResultV1Codec, - TaskStatusNotificationV1Codec, + CreateTaskResultV1Schema, + GetTaskResultV1Schema, + TaskStatusNotificationV1Schema, shouldCallToolAsTaskV1, type CallToolResultV1, type TaskV1, type ToolV1, } from "../core/v1/index.js"; import { - CreateTaskResultV2Codec, - GetTaskResultV2Codec, - TaskStatusNotificationV2Codec, + CreateTaskResultV2Schema, + GetTaskResultV2Schema, + TaskStatusNotificationV2Schema, isCreateTaskResultV2, withTaskCapabilityV2, type CallToolResultV2, @@ -39,7 +39,7 @@ import { import { ImmediateExecution, TaskExecution, - defaultResultCodec, + defaultResultSchema, reasonAsError, } from "./execution.js"; import { @@ -51,7 +51,7 @@ import { type V1TaskInputCandidate, } from "./input-routing.js"; import { - decodeResult, + parseResult, dispatchWithRetry, linkAbortSignals, responseResult, @@ -149,7 +149,7 @@ class PortTaskEnabledSession< name: string, params?: Readonly>, options: { - readonly resultCodec?: RuntimeCodec; + readonly resultSchema?: z.ZodType; readonly applicationContext?: TApplicationContext; readonly signal?: AbortSignal; readonly preferTask?: boolean; @@ -239,12 +239,12 @@ class PortTaskEnabledSession< throw error; } const wireResult = responseResult(response); - const codec = - options.resultCodec ?? - (defaultResultCodec(generation) as RuntimeCodec); + const schema = + options.resultSchema ?? + (defaultResultSchema(generation) as z.ZodType); if (generation === "v1" && callAsTaskV1) { - const created = decodeResult(CreateTaskResultV1Codec, wireResult); + const created = parseResult(CreateTaskResultV1Schema, wireResult); const handle: TaskHandle & { readonly generation: "v1" } = { generation: "v1", taskId: created.task.taskId as TaskId, @@ -254,7 +254,7 @@ class PortTaskEnabledSession< applicationContext: options.applicationContext as TApplicationContext, handle, initialTask: created.task, - resultCodec: codec, + resultSchema: schema, port: this.port, lifecycleSignal: this.lifecycleController.signal, }); @@ -270,7 +270,7 @@ class PortTaskEnabledSession< } if (generation === "v2" && isCreateTaskResultV2(wireResult)) { - const created = decodeResult(CreateTaskResultV2Codec, wireResult); + const created = parseResult(CreateTaskResultV2Schema, wireResult); const handle: TaskHandle & { readonly generation: "v2" } = { generation: "v2", taskId: created.taskId as TaskId, @@ -281,7 +281,7 @@ class PortTaskEnabledSession< applicationContext: options.applicationContext as TApplicationContext, handle, initialTask: created, - resultCodec: codec, + resultSchema: schema, port: this.port, lifecycleSignal: this.lifecycleController.signal, onInputRequest: this.options.onInputRequest, @@ -290,7 +290,7 @@ class PortTaskEnabledSession< ); } - const resultPromise = Promise.resolve(decodeResult(codec, wireResult)); + const resultPromise = Promise.resolve(parseResult(schema, wireResult)); return new ImmediateExecution( options.applicationContext as TApplicationContext, resultPromise, @@ -300,7 +300,7 @@ class PortTaskEnabledSession< async resumeTask( reference: SerializedTaskReference, options: { - readonly resultCodec?: RuntimeCodec; + readonly resultSchema?: z.ZodType; readonly applicationContext?: TApplicationContext; readonly signal?: AbortSignal; } = {}, @@ -320,9 +320,9 @@ class PortTaskEnabledSession< ); const resumeSignal = resumeLifecycle.signal; const executionId = nextExecutionIdentifier(); - const codec = - options.resultCodec ?? - (defaultResultCodec(reference.generation) as RuntimeCodec); + const schema = + options.resultSchema ?? + (defaultResultSchema(reference.generation) as z.ZodType); try { throwIfAborted(resumeSignal); const response = await dispatchWithRetry( @@ -341,15 +341,15 @@ class PortTaskEnabledSession< throwIfAborted(resumeSignal); if (reference.generation === "v1") { - const task = decodeResult( - GetTaskResultV1Codec, + const task = parseResult( + GetTaskResultV1Schema, responseResult(response), ); const execution = createTaskExecutionV1({ applicationContext: options.applicationContext as TApplicationContext, handle: reference, initialTask: task, - resultCodec: codec, + resultSchema: schema, port: this.port, lifecycleSignal: this.lifecycleController.signal, }); @@ -364,14 +364,14 @@ class PortTaskEnabledSession< }); } - const task = decodeResult(GetTaskResultV2Codec, responseResult(response)); + const task = parseResult(GetTaskResultV2Schema, responseResult(response)); return this.trackTaskExecution( createTaskExecutionV2({ applicationContext: options.applicationContext as TApplicationContext, handle: reference, initialTask: task, initialDetailedTask: task, - resultCodec: codec, + resultSchema: schema, port: this.port, lifecycleSignal: this.lifecycleController.signal, onInputRequest: this.options.onInputRequest, @@ -392,15 +392,15 @@ class PortTaskEnabledSession< let taskId: TaskId | undefined; let params: JsonValue | undefined; if (generation === "v1" && callAsTaskV1) { - const parsed = CreateTaskResultV1Codec.parse(response.result); + const parsed = CreateTaskResultV1Schema.safeParse(response.result); if (parsed.success) { - taskId = parsed.value.task.taskId as TaskId; + taskId = parsed.data.task.taskId as TaskId; params = { taskId }; } } else if (generation === "v2" && isCreateTaskResultV2(response.result)) { - const parsed = CreateTaskResultV2Codec.parse(response.result); + const parsed = CreateTaskResultV2Schema.safeParse(response.result); if (parsed.success) { - taskId = parsed.value.taskId as TaskId; + taskId = parsed.data.taskId as TaskId; params = withTaskCapabilityV2({ taskId }); } } @@ -485,9 +485,9 @@ class PortTaskEnabledSession< const generation = this.port.taskCapabilities.generation; const parsed = generation === "v1" && method === "notifications/tasks/status" - ? TaskStatusNotificationV1Codec.parse(notification) + ? TaskStatusNotificationV1Schema.safeParse(notification) : generation === "v2" && method === "notifications/tasks" - ? TaskStatusNotificationV2Codec.parse(notification) + ? TaskStatusNotificationV2Schema.safeParse(notification) : undefined; if (parsed === undefined) return; if (!parsed.success) { @@ -496,8 +496,8 @@ class PortTaskEnabledSession< } const snapshot: TaskSnapshot = generation === "v1" - ? { generation: "v1", task: parsed.value.params as TaskV1 } - : { generation: "v2", task: parsed.value.params as DetailedTaskV2 }; + ? { generation: "v1", task: parsed.data.params as TaskV1 } + : { generation: "v2", task: parsed.data.params as DetailedTaskV2 }; for (const execution of this.activeTaskExecutions) { execution.onNotification(snapshot); } diff --git a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts index d51b348..bfd5fb3 100644 --- a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -1,6 +1,5 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { expectRecord } from "../core/index.js"; import { DispatchError, JsonRpcResponseError, @@ -14,6 +13,7 @@ import { asJson, formatJson, asError, + expectRecord, } from "../../test-support/client/fake-port.js"; describe("task lifecycle and races", () => { diff --git a/packages/ext-tasks/src/client/task-protocol-v1.ts b/packages/ext-tasks/src/client/task-protocol-v1.ts index 5e83419..6cc58b8 100644 --- a/packages/ext-tasks/src/client/task-protocol-v1.ts +++ b/packages/ext-tasks/src/client/task-protocol-v1.ts @@ -1,10 +1,10 @@ /** Generation-specific requester-side V1 task execution. */ -import type { RuntimeCodec } from "../core/index.js"; +import type { z } from "zod/v4"; import { - CancelTaskResultV1Codec, - GetTaskResultV1Codec, - TaskResultV1Codec, + CancelTaskResultV1Schema, + GetTaskResultV1Schema, + TaskResultV1Schema, type TaskV1, } from "../core/v1/index.js"; import { TaskCancellationUnsupportedError, type TaskHandle } from "./api.js"; @@ -14,7 +14,7 @@ import { terminalStatus, } from "./execution.js"; import { - decodeResult, + parseResult, dispatchWithRetry, responseResult, type ConnectedMcpSessionPort, @@ -25,11 +25,11 @@ export function createTaskExecutionV1(options: { readonly applicationContext: TApplicationContext; readonly handle: TaskHandle & { readonly generation: "v1" }; readonly initialTask: TaskV1; - readonly resultCodec: RuntimeCodec; + readonly resultSchema: z.ZodType; readonly port: ConnectedMcpSessionPort; readonly lifecycleSignal: AbortSignal; }): TaskExecution { - const { applicationContext, handle, initialTask, resultCodec, port } = + const { applicationContext, handle, initialTask, resultSchema, port } = options; return new TaskExecution( applicationContext, @@ -65,8 +65,8 @@ export function createTaskExecutionV1(options: { "observe", ).then((response) => ({ generation: "v1" as const, - task: decodeResult( - GetTaskResultV1Codec, + task: parseResult( + GetTaskResultV1Schema, responseResult(response), ), })), @@ -88,8 +88,8 @@ export function createTaskExecutionV1(options: { "observe", ), ); - decodeResult(TaskResultV1Codec, taskResult); - return decodeResult(resultCodec, taskResult); + parseResult(TaskResultV1Schema, taskResult); + return parseResult(resultSchema, taskResult); }, async (signal) => { const capabilities = port.taskCapabilities; @@ -98,8 +98,8 @@ export function createTaskExecutionV1(options: { capabilities.capabilities.cancel === undefined ) throw new TaskCancellationUnsupportedError(); - decodeResult( - CancelTaskResultV1Codec, + parseResult( + CancelTaskResultV1Schema, responseResult( await dispatchWithRetry( port, diff --git a/packages/ext-tasks/src/client/task-protocol-v2.ts b/packages/ext-tasks/src/client/task-protocol-v2.ts index 6cac35c..754cce9 100644 --- a/packages/ext-tasks/src/client/task-protocol-v2.ts +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -1,13 +1,14 @@ /** Generation-specific requester-side V2 task execution. */ -import type { JsonValue, RuntimeCodec } from "../core/index.js"; +import type { JsonValue } from "../core/index.js"; +import type { z } from "zod/v4"; import { - CancelTaskResultV2Codec, - CreateMessageResultV2Codec, - ElicitResultV2Codec, - GetTaskResultV2Codec, - ListRootsResultV2Codec, - UpdateTaskResultV2Codec, + CancelTaskResultV2Schema, + CreateMessageResultV2Schema, + ElicitResultV2Schema, + GetTaskResultV2Schema, + ListRootsResultV2Schema, + UpdateTaskResultV2Schema, withTaskCapabilityV2, type DetailedTaskV2, type InputRequestV2, @@ -27,7 +28,7 @@ import { terminalStatus, } from "./execution.js"; import { - decodeResult, + parseResult, dispatchWithRetry, responseResult, type ConnectedMcpSessionPort, @@ -39,7 +40,7 @@ export function createTaskExecutionV2(options: { readonly handle: TaskHandle & { readonly generation: "v2" }; readonly initialTask: TaskV2; readonly initialDetailedTask?: DetailedTaskV2; - readonly resultCodec: RuntimeCodec; + readonly resultSchema: z.ZodType; readonly port: ConnectedMcpSessionPort; readonly lifecycleSignal: AbortSignal; readonly onInputRequest?: ApplicationInputHandler["handle"]; @@ -50,7 +51,7 @@ export function createTaskExecutionV2(options: { handle, initialTask, initialDetailedTask, - resultCodec, + resultSchema, port, } = options; return new TaskExecution( @@ -101,8 +102,8 @@ export function createTaskExecutionV2(options: { "observe", ).then((response) => ({ generation: "v2" as const, - task: decodeResult( - GetTaskResultV2Codec, + task: parseResult( + GetTaskResultV2Schema, responseResult(response), ), })), @@ -122,8 +123,8 @@ export function createTaskExecutionV2(options: { } if (isClosed()) throw closedError; if (current === undefined) { - current = decodeResult( - GetTaskResultV2Codec, + current = parseResult( + GetTaskResultV2Schema, responseResult( await dispatchWithRetry( port, @@ -143,11 +144,11 @@ export function createTaskExecutionV2(options: { throw new JsonRpcResponseError(current.error); if (current.status !== "completed") throw new Error(`Unsupported terminal task status: ${current.status}`); - return decodeResult(resultCodec, current.result); + return parseResult(resultSchema, current.result); }, async (signal) => { - decodeResult( - CancelTaskResultV2Codec, + parseResult( + CancelTaskResultV2Schema, responseResult( await dispatchWithRetry( port, @@ -230,14 +231,14 @@ async function acquireInputs( } } try { - const responseCodec = + const responseSchema = request.method === "sampling/createMessage" - ? CreateMessageResultV2Codec + ? CreateMessageResultV2Schema : request.method === "roots/list" - ? ListRootsResultV2Codec - : ElicitResultV2Codec; - inputResponses[inputKey] = decodeResult( - responseCodec as RuntimeCodec, + ? ListRootsResultV2Schema + : ElicitResultV2Schema; + inputResponses[inputKey] = parseResult( + responseSchema as z.ZodType, result as JsonValue, ); } catch (error) { @@ -247,8 +248,8 @@ async function acquireInputs( } } if (inputSignal.aborted || Object.keys(inputResponses).length === 0) return; - decodeResult( - UpdateTaskResultV2Codec, + parseResult( + UpdateTaskResultV2Schema, responseResult( await dispatchWithRetry( options.port, diff --git a/packages/ext-tasks/src/client/task-resumption.test.ts b/packages/ext-tasks/src/client/task-resumption.test.ts index 2384a13..22040d8 100644 --- a/packages/ext-tasks/src/client/task-resumption.test.ts +++ b/packages/ext-tasks/src/client/task-resumption.test.ts @@ -1,6 +1,6 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { expectRecord, type TaskId } from "../core/index.js"; +import { type TaskId } from "../core/index.js"; import { DispatchError, InputCorrelationError, @@ -14,6 +14,7 @@ import { asJson, formatJson, asError, + expectRecord, } from "../../test-support/client/fake-port.js"; describe("task reference resumption", () => { diff --git a/packages/ext-tasks/src/client/tool-declarations.ts b/packages/ext-tasks/src/client/tool-declarations.ts index 26588c7..6899889 100644 --- a/packages/ext-tasks/src/client/tool-declarations.ts +++ b/packages/ext-tasks/src/client/tool-declarations.ts @@ -1,10 +1,14 @@ -import { isJsonArray, type JsonValue } from "../core/index.js"; -import { ToolV1Codec, type ToolV1 } from "../core/v1/index.js"; -import { ToolV2Codec, type ToolV2 } from "../core/v2/index.js"; +import type { JsonValue } from "../core/index.js"; +import { ToolV1Schema, type ToolV1 } from "../core/v1/index.js"; +import { ToolV2Schema, type ToolV2 } from "../core/v2/index.js"; +import type { z } from "zod/v4"; import { JsonRpcResponseError, type ToolDeclarationProvider } from "./api.js"; import type { ConnectedMcpSessionPort } from "./port.js"; import { throwIfAborted } from "./input-routing.js"; +const ToolV1Parser = ToolV1Schema as unknown as z.ZodType; +const ToolV2Parser = ToolV2Schema as unknown as z.ZodType; + export class ManagedToolDeclarations implements ToolDeclarationProvider { private tools = new Map(); private refreshSequence = 0; @@ -114,25 +118,31 @@ export class ManagedToolDeclarations implements ToolDeclarationProvider { } const result = response.result as Readonly>; const listed = result.tools; - if (!isJsonArray(listed)) + if (!Array.isArray(listed)) throw new Error("tools/list result must contain tools"); + const generation = ( + this.port.taskCapabilities as { + readonly generation: "none" | "v1" | "v2"; + } + ).generation; for (const value of listed) { const parsed = - this.port.taskCapabilities.generation === "v1" - ? ToolV1Codec.parse(value) - : this.port.taskCapabilities.generation === "v2" - ? ToolV2Codec.parse(value) + generation === "v1" + ? ToolV1Parser.safeParse(value) + : generation === "v2" + ? ToolV2Parser.safeParse(value) : (() => { - const v2 = ToolV2Codec.parse(value); - return v2.success ? v2 : ToolV1Codec.parse(value); + const v2 = ToolV2Parser.safeParse(value); + return v2.success ? v2 : ToolV1Parser.safeParse(value); })(); if (!parsed.success) throw parsed.error; - if (decoded.has(parsed.value.name)) { + const tool = parsed.data as ToolV1 | ToolV2; + if (decoded.has(tool.name)) { this.reportError( - new Error(`Duplicate tool declaration: ${parsed.value.name}`), + new Error(`Duplicate tool declaration: ${tool.name}`), ); } - decoded.set(parsed.value.name, parsed.value); + decoded.set(tool.name, tool); } cursor = typeof result.nextCursor === "string" ? result.nextCursor : undefined; diff --git a/packages/ext-tasks/src/client/v1-input-task.test.ts b/packages/ext-tasks/src/client/v1-input-task.test.ts index 80fa7e2..20a522d 100644 --- a/packages/ext-tasks/src/client/v1-input-task.test.ts +++ b/packages/ext-tasks/src/client/v1-input-task.test.ts @@ -1,6 +1,6 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { expectRecord, type JsonValue } from "../core/index.js"; +import { type JsonValue } from "../core/index.js"; import { type ServerTaskCapabilitiesV1, type ToolV1, @@ -17,6 +17,7 @@ import { asJson, formatJson, asError, + expectRecord, } from "../../test-support/client/fake-port.js"; describe("V1 input and task behavior", () => { diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts index 542b562..0c88c83 100644 --- a/packages/ext-tasks/src/client/v2-input-task.test.ts +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -1,12 +1,12 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { expectRecord } from "../core/index.js"; import { withTasks } from "./index.js"; import { FakePort, asJson, formatJson, asError, + expectRecord, } from "../../test-support/client/fake-port.js"; describe("V2 input and task behavior", () => { @@ -40,7 +40,6 @@ describe("V2 input and task behavior", () => { lastUpdatedAt: "b", ttlMs: null, result: { - resultType: "complete", content: [{ type: "text", text: "done" }], }, }), @@ -140,7 +139,7 @@ describe("V2 input and task behavior", () => { createdAt: "a", lastUpdatedAt: "c", ttlMs: null, - result: { resultType: "complete", content: [] }, + result: { content: [] }, }), }; } @@ -261,7 +260,7 @@ describe("V2 input and task behavior", () => { createdAt: "a", lastUpdatedAt: "z", ttlMs: null, - result: { resultType: "complete", content: [] }, + result: { content: [] }, }), }; } @@ -338,7 +337,7 @@ describe("V2 input and task behavior", () => { createdAt: "a", lastUpdatedAt: "c", ttlMs: null, - result: { resultType: "complete", content: [] }, + result: { content: [] }, }, ), }; @@ -439,7 +438,7 @@ describe("V2 input and task behavior", () => { createdAt: "a", lastUpdatedAt: "c", ttlMs: null, - result: { resultType: "complete", content: [] }, + result: { content: [] }, }, }), ); @@ -495,7 +494,7 @@ describe("V2 input and task behavior", () => { status === "completed" ? { ...terminal, - result: { resultType: "complete", content: [] }, + result: { content: [] }, } : status === "failed" ? { diff --git a/packages/ext-tasks/src/core/index.test.ts b/packages/ext-tasks/src/core/index.test.ts index 7bc6347..f9a8db4 100644 --- a/packages/ext-tasks/src/core/index.test.ts +++ b/packages/ext-tasks/src/core/index.test.ts @@ -1,9 +1,8 @@ import fc from "fast-check"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { - ProtocolDecodeError, - createRuntimeCodec, + JsonValueSchema, isJsonValue, taskId, type JsonValue, @@ -21,10 +20,11 @@ const jsonValue = fc.letrec((tie) => ({ })).value as fc.Arbitrary; describe("core runtime contracts", () => { - it("recognizes exactly JSON-compatible generated values", () => { + it("recognizes and parses exactly JSON-compatible generated values", () => { fc.assert( fc.property(jsonValue, (value) => { expect(isJsonValue(value)).toBe(true); + expect(JsonValueSchema.parse(value)).toEqual(value); }), ); fc.assert( @@ -32,23 +32,13 @@ describe("core runtime contracts", () => { fc.oneof(fc.constant(undefined), fc.bigInt(), fc.constant(Symbol("x"))), (value) => { expect(isJsonValue(value)).toBe(false); + expect(JsonValueSchema.safeParse(value).success).toBe(false); }, ), ); - expect(isJsonValue(new Date())).toBe(false); - expect(isJsonValue(new Map())).toBe(false); - expect(isJsonValue(Object.assign(Object.create(null), { ok: true }))).toBe( - true, - ); - const cyclic: Record = {}; - cyclic.self = cyclic; - expect(isJsonValue(cyclic)).toBe(false); - const sparse: unknown[] = []; - sparse.length = 1; - expect(isJsonValue(sparse)).toBe(false); }); - it("rejects non-JSON inputs before invoking the decoder", () => { + it("rejects exotic, cyclic, sparse, and non-finite values", () => { const sparse: unknown[] = []; sparse.length = 1; const cyclic: Record = {}; @@ -70,70 +60,17 @@ describe("core runtime contracts", () => { Object.create({ inherited: true }) as object, /not-json/, ]; - const decode = vi.fn(() => "decoded"); - const codec = createRuntimeCodec(decode); for (const value of nonJsonValues) { - const result = codec.parse(value); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toBeInstanceOf(ProtocolDecodeError); - expect(result.error.path).toEqual([]); - } + expect(JsonValueSchema.safeParse(value).success).toBe(false); + expect(() => JsonValueSchema.parse(value)).toThrow(); } - expect(decode).not.toHaveBeenCalled(); }); - it("invokes the decoder for JSON values and starts it at the root path", () => { - const decode = vi.fn( - (value: JsonValue, path: readonly (string | number)[]) => ({ - value, - path, - }), - ); - const codec = createRuntimeCodec(decode); - const input = { nested: [null, true, 1, "value"] }; - const result = codec.parse(input); - - expect(result).toEqual({ - success: true, - value: { value: input, path: [] }, - }); - expect(decode).toHaveBeenCalledTimes(1); - expect(decode).toHaveBeenCalledWith(input, []); + it("accepts plain objects with null prototypes", () => { + const value = Object.assign(Object.create(null) as object, { ok: true }); + expect(JsonValueSchema.parse(value)).toEqual(value); }); - - it("returns decoder ProtocolDecodeError instances unchanged with their paths", () => { - const error = new ProtocolDecodeError("expected string", [ - "params", - "name", - ]); - const codec = createRuntimeCodec(() => { - throw error; - }); - - const result = codec.parse({}); - expect(result).toEqual({ success: false, error }); - if (!result.success) { - expect(result.error).toBe(error); - expect(result.error.path).toEqual(["params", "name"]); - } - }); - - it("rethrows unexpected decoder errors unchanged", () => { - const error = new Error("programmer failure"); - const codec = createRuntimeCodec(() => { - throw error; - }); - - expect(() => codec.parse({})).toThrow(error); - try { - codec.parse({}); - } catch (caught) { - expect(caught).toBe(error); - } - }); - it("brands task identifiers without changing their wire value", () => { fc.assert( fc.property(fc.string(), (value) => { @@ -141,14 +78,4 @@ describe("core runtime contracts", () => { }), ); }); - - it("exposes decode failures as Error values with stable paths", () => { - const error = new ProtocolDecodeError("expected string", [ - "task", - "taskId", - ]); - expect(error).toBeInstanceOf(Error); - expect(error.path).toEqual(["task", "taskId"]); - expect(error.message).toContain("task.taskId"); - }); }); diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts index db8be6b..04205ec 100644 --- a/packages/ext-tasks/src/core/index.ts +++ b/packages/ext-tasks/src/core/index.ts @@ -1,16 +1,6 @@ +import * as z from "zod/v4"; import type { TaskV1 } from "./v1/index.js"; import type { DetailedTaskV2, TaskV2 } from "./v2/index.js"; -import { ProtocolDecodeError } from "./internal/codec.js"; -export { - ProtocolDecodeError, - createRuntimeCodec, - expectEnum, - expectNumber, - expectRecord, - expectString, - isJsonArray, - type DecodePath, -} from "./internal/codec.js"; export type TaskId = string & { readonly __taskId: unique symbol }; export type TaskGeneration = "v1" | "v2"; @@ -23,14 +13,6 @@ export type JsonValue = | readonly JsonValue[] | { readonly [key: string]: JsonValue }; -export interface RuntimeCodec { - parse( - value: unknown, - ): - | { readonly success: true; readonly value: T } - | { readonly success: false; readonly error: ProtocolDecodeError }; -} - export type TaskSnapshot = | { readonly generation: "v1"; readonly task: TaskV1 } | { readonly generation: "v2"; readonly task: TaskV2 | DetailedTaskV2 }; @@ -73,3 +55,9 @@ export function isJsonValue(value: unknown): value is JsonValue { }; return visit(value); } + +/** Validates the package's recursive JSON data model. */ +export const JsonValueSchema: z.ZodType = z.custom( + isJsonValue, + "Expected a JSON value", +); diff --git a/packages/ext-tasks/src/core/internal/codec.ts b/packages/ext-tasks/src/core/internal/codec.ts deleted file mode 100644 index daaacea..0000000 --- a/packages/ext-tasks/src/core/internal/codec.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** Internal runtime codec implementation helpers. */ -import { isJsonValue, type JsonValue, type RuntimeCodec } from "../index.js"; - -export type DecodePath = readonly (string | number)[]; - -export class ProtocolDecodeError extends Error { - readonly path: DecodePath; - - constructor(message: string, path: DecodePath = []) { - const location = path.length === 0 ? "$" : path.join("."); - super(`${location}: ${message}`); - this.name = "ProtocolDecodeError"; - this.path = path; - } -} - -/** Returns whether a JSON value is an array. */ -export function isJsonArray( - value: JsonValue | undefined, -): value is readonly JsonValue[] { - return Array.isArray(value); -} - -/** Creates a runtime codec from a path-aware decoder. */ -export function createRuntimeCodec( - decode: (value: JsonValue, path: DecodePath) => T, -): RuntimeCodec { - return { - parse(value) { - if (!isJsonValue(value)) { - return { - success: false, - error: new ProtocolDecodeError("expected JSON value"), - }; - } - try { - return { success: true, value: decode(value, []) }; - } catch (error) { - if (error instanceof ProtocolDecodeError) { - return { success: false, error }; - } - throw error; - } - }, - }; -} - -/** Requires a JSON object at the supplied decode path. */ -export function expectRecord( - value: JsonValue, - path: DecodePath = [], -): Record { - if (value === null || Array.isArray(value) || typeof value !== "object") { - throw new ProtocolDecodeError("expected object", path); - } - return value as Record; -} - -/** Requires a possibly absent value to be a JSON object. */ -export function expectRequiredRecord( - value: JsonValue | undefined, - path: DecodePath, -): Record { - if (value === undefined) { - throw new ProtocolDecodeError("expected object", path); - } - return expectRecord(value, path); -} - -/** Requires a string at the supplied decode path. */ -export function expectString( - value: JsonValue | undefined, - path: DecodePath, -): string { - if (typeof value !== "string") - throw new ProtocolDecodeError("expected string", path); - return value; -} - -/** Requires a finite number at the supplied decode path. */ -export function expectNumber( - value: JsonValue | undefined, - path: DecodePath, -): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new ProtocolDecodeError("expected finite number", path); - } - return value; -} - -/** Requires one of the allowed string values at the supplied decode path. */ -export function expectEnum( - value: JsonValue | undefined, - values: readonly T[], - path: DecodePath, -): T { - if (typeof value !== "string" || !values.includes(value as T)) { - throw new ProtocolDecodeError(`expected one of ${values.join(", ")}`, path); - } - return value as T; -} - -/** Appends a property or index to a decode path. */ -export function childPath(path: DecodePath, key: string | number): DecodePath { - return [...path, key]; -} - -/** Returns whether a decoded object defines an own property. */ -export function hasOwn( - record: Readonly>, - key: string, -): boolean { - return Object.prototype.hasOwnProperty.call(record, key); -} - -/** Requires an integer at the supplied decode path. */ -export function expectInteger( - value: JsonValue | undefined, - path: DecodePath, -): number { - const number = expectNumber(value, path); - if (!Number.isInteger(number)) { - throw new ProtocolDecodeError("expected integer", path); - } - return number; -} - -/** Requires a specific literal-valued property on a decoded object. */ -export function expectLiteralProperty( - record: Readonly>, - key: string, - expected: string | number | boolean | null, - path: DecodePath, -): void { - const propertyPath = childPath(path, key); - if (record[key] !== expected) { - throw new ProtocolDecodeError(`expected ${String(expected)}`, propertyPath); - } -} - -/** Decodes an optional object-valued property on a decoded object. */ -export function expectOptionalRecordProperty( - record: Readonly>, - key: string, - path: DecodePath, -): Record | undefined { - const value = record[key]; - return value === undefined - ? undefined - : expectRecord(value, childPath(path, key)); -} - -/** Decodes an optional boolean-valued property on a decoded object. */ -export function expectOptionalBooleanProperty( - record: Readonly>, - key: string, - path: DecodePath, -): boolean | undefined { - const value = record[key]; - if (value === undefined) return undefined; - if (typeof value !== "boolean") { - throw new ProtocolDecodeError("expected boolean", childPath(path, key)); - } - return value; -} diff --git a/packages/ext-tasks/src/core/v1/codecs.ts b/packages/ext-tasks/src/core/v1/codecs.ts deleted file mode 100644 index 8775c41..0000000 --- a/packages/ext-tasks/src/core/v1/codecs.ts +++ /dev/null @@ -1,393 +0,0 @@ -/** MCP Tasks V1 runtime codecs. */ -import { - ProtocolDecodeError, - childPath as at, - createRuntimeCodec, - expectEnum, - expectInteger, - expectLiteralProperty as literal, - expectOptionalBooleanProperty as optionalBoolean, - expectOptionalRecordProperty as optionalJsonRecord, - expectRecord, - expectRequiredRecord as jsonRecord, - expectString, - isJsonArray, - type DecodePath, -} from "../internal/codec.js"; -import { type JsonValue, type RuntimeCodec } from "../index.js"; -import { - TaskStatusesV1, - type CallToolRequestV1, - type CallToolResultV1, - type CancelTaskRequestV1, - type CancelTaskResultV1, - type ContentBlockV1, - type CreateTaskResultV1, - type GetTaskRequestV1, - type GetTaskResultRequestV1, - type GetTaskResultV1, - type JsonRpcRequestIdV1, - type ListTasksRequestV1, - type ListTasksResultV1, - type ServerTaskCapabilitiesV1, - type TaskResultV1, - type TaskStatusNotificationV1, - type TaskStatusV1, - type TaskV1, - type ToolExecutionV1, - type ToolV1, -} from "./wire.js"; -interface JsonRpcRequestV1 { - readonly jsonrpc: "2.0"; - readonly id: JsonRpcRequestIdV1; - readonly method: M; - readonly params: P; -} - -function decodeId( - value: JsonValue | undefined, - path: DecodePath, -): JsonRpcRequestIdV1 { - if (typeof value !== "string" && typeof value !== "number") - throw new ProtocolDecodeError("expected request id", path); - return value; -} -function decodeContentBlock( - value: JsonValue, - path: DecodePath, -): ContentBlockV1 { - const record = expectRecord(value, path); - const type = expectEnum( - record.type, - ["text", "image", "audio", "resource_link", "resource"] as const, - at(path, "type"), - ); - if (type === "text") { - expectString(record.text, at(path, "text")); - } else if (type === "image" || type === "audio") { - expectString(record.data, at(path, "data")); - expectString(record.mimeType, at(path, "mimeType")); - } else if (type === "resource_link") { - expectString(record.name, at(path, "name")); - expectString(record.uri, at(path, "uri")); - } else { - jsonRecord(record.resource, at(path, "resource")); - } - return record as ContentBlockV1; -} -function decodeTask(value: JsonValue, path: DecodePath): TaskV1 { - const record = expectRecord(value, path); - const ttlValue = record.ttl; - const ttl = - ttlValue === null ? null : expectInteger(ttlValue, at(path, "ttl")); - return { - taskId: expectString(record.taskId, at(path, "taskId")), - status: expectEnum(record.status, TaskStatusesV1, at(path, "status")), - ...(record.statusMessage === undefined - ? {} - : { - statusMessage: expectString( - record.statusMessage, - at(path, "statusMessage"), - ), - }), - createdAt: expectString(record.createdAt, at(path, "createdAt")), - lastUpdatedAt: expectString( - record.lastUpdatedAt, - at(path, "lastUpdatedAt"), - ), - ttl, - ...(record.pollInterval === undefined - ? {} - : { - pollInterval: expectInteger( - record.pollInterval, - at(path, "pollInterval"), - ), - }), - }; -} -function decodeTaskRequest< - M extends "tasks/get" | "tasks/result" | "tasks/cancel", ->( - value: JsonValue, - path: DecodePath, - method: M, -): JsonRpcRequestV1 { - const record = expectRecord(value, path); - literal(record, "jsonrpc", "2.0", path); - literal(record, "method", method, path); - const params = jsonRecord(record.params, at(path, "params")); - return { - jsonrpc: "2.0", - id: decodeId(record.id, at(path, "id")), - method, - params: { - taskId: expectString(params.taskId, at(at(path, "params"), "taskId")), - }, - }; -} - -export const ToolV1Codec: RuntimeCodec = createRuntimeCodec( - (value, path) => { - const record = expectRecord(value, path); - const inputSchema = jsonRecord(record.inputSchema, at(path, "inputSchema")); - literal(inputSchema, "type", "object", at(path, "inputSchema")); - let outputSchema: ToolV1["outputSchema"]; - if (record.outputSchema !== undefined) { - const decoded = jsonRecord(record.outputSchema, at(path, "outputSchema")); - literal(decoded, "type", "object", at(path, "outputSchema")); - outputSchema = decoded as ToolV1["outputSchema"]; - } - let execution: ToolExecutionV1 | undefined; - if (record.execution !== undefined) { - const decoded = jsonRecord(record.execution, at(path, "execution")); - execution = - decoded.taskSupport === undefined - ? {} - : { - taskSupport: expectEnum( - decoded.taskSupport, - ["forbidden", "optional", "required"] as const, - at(at(path, "execution"), "taskSupport"), - ), - }; - } - let icons: ToolV1["icons"]; - if (record.icons !== undefined) { - if (!isJsonArray(record.icons)) - throw new ProtocolDecodeError("expected array", at(path, "icons")); - icons = record.icons.map((icon, index) => - jsonRecord(icon, at(at(path, "icons"), index)), - ); - } - return { - name: expectString(record.name, at(path, "name")), - inputSchema: inputSchema as ToolV1["inputSchema"], - ...(record.title === undefined - ? {} - : { title: expectString(record.title, at(path, "title")) }), - ...(record.description === undefined - ? {} - : { - description: expectString( - record.description, - at(path, "description"), - ), - }), - ...(outputSchema === undefined ? {} : { outputSchema }), - ...(execution === undefined ? {} : { execution }), - ...(record.annotations === undefined - ? {} - : { - annotations: jsonRecord( - record.annotations, - at(path, "annotations"), - ), - }), - ...(icons === undefined ? {} : { icons }), - ...(record._meta === undefined - ? {} - : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), - }; - }, -); -export const ServerTaskCapabilitiesV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - const list = optionalJsonRecord(record, "list", path); - const cancel = optionalJsonRecord(record, "cancel", path); - let requests: ServerTaskCapabilitiesV1["requests"]; - if (record.requests !== undefined) { - const r = jsonRecord(record.requests, at(path, "requests")); - let tools: NonNullable["tools"]; - if (r.tools !== undefined) { - const t = jsonRecord(r.tools, at(at(path, "requests"), "tools")); - tools = - t.call === undefined - ? {} - : { - call: jsonRecord( - t.call, - at(at(at(path, "requests"), "tools"), "call"), - ), - }; - } - requests = tools === undefined ? {} : { tools }; - } - return { - ...(list === undefined ? {} : { list }), - ...(cancel === undefined ? {} : { cancel }), - ...(requests === undefined ? {} : { requests }), - }; - }); -export const CallToolRequestV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - literal(record, "jsonrpc", "2.0", path); - literal(record, "method", "tools/call", path); - const params = jsonRecord(record.params, at(path, "params")); - return { - jsonrpc: "2.0", - id: decodeId(record.id, at(path, "id")), - method: "tools/call", - params: { - name: expectString(params.name, at(at(path, "params"), "name")), - ...(params.arguments === undefined - ? {} - : { - arguments: jsonRecord( - params.arguments, - at(at(path, "params"), "arguments"), - ), - }), - ...(params.task === undefined - ? {} - : { - task: (() => { - const t = jsonRecord( - params.task, - at(at(path, "params"), "task"), - ); - return t.ttl === undefined - ? {} - : { - ttl: expectInteger( - t.ttl, - at(at(at(path, "params"), "task"), "ttl"), - ), - }; - })(), - }), - }, - }; - }); -export const TaskStatusV1Codec: RuntimeCodec = createRuntimeCodec( - (value, path) => expectEnum(value, TaskStatusesV1, path), -); -export const TaskV1Codec: RuntimeCodec = createRuntimeCodec(decodeTask); -export const CreateTaskResultV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - return { - task: decodeTask(record.task, at(path, "task")), - ...(record._meta === undefined - ? {} - : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), - }; - }); -export const CallToolResultV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - if (!isJsonArray(record.content)) - throw new ProtocolDecodeError("expected array", at(path, "content")); - record.content.forEach((item, index) => - decodeContentBlock(item, at(at(path, "content"), index)), - ); - if (record.structuredContent !== undefined) - jsonRecord(record.structuredContent, at(path, "structuredContent")); - optionalBoolean(record, "isError", path); - if (record._meta !== undefined) jsonRecord(record._meta, at(path, "_meta")); - return record as unknown as CallToolResultV1; - }); -export const GetTaskRequestV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => - decodeTaskRequest(v, p, "tasks/get"), - ); -export const GetTaskResultV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => { - const record = expectRecord(v, p); - return { - ...decodeTask(v, p), - ...(record._meta === undefined - ? {} - : { _meta: jsonRecord(record._meta, at(p, "_meta")) }), - }; - }); -export const GetTaskResultRequestV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => - decodeTaskRequest(v, p, "tasks/result"), - ); -export const TaskResultV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => expectRecord(v, p)); -export const CancelTaskRequestV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => - decodeTaskRequest(v, p, "tasks/cancel"), - ); -export const CancelTaskResultV1Codec: RuntimeCodec = - createRuntimeCodec((v, p) => { - const record = expectRecord(v, p); - return { - ...decodeTask(v, p), - ...(record._meta === undefined - ? {} - : { _meta: jsonRecord(record._meta, at(p, "_meta")) }), - }; - }); -export const ListTasksRequestV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - literal(record, "jsonrpc", "2.0", path); - literal(record, "method", "tasks/list", path); - const result: ListTasksRequestV1 = { - jsonrpc: "2.0", - id: decodeId(record.id, at(path, "id")), - method: "tasks/list", - }; - if (record.params === undefined) return result; - const params = jsonRecord(record.params, at(path, "params")); - return { - ...result, - params: { - ...(params.cursor === undefined - ? {} - : { - cursor: expectString( - params.cursor, - at(at(path, "params"), "cursor"), - ), - }), - }, - }; - }); -export const ListTasksResultV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - if (!isJsonArray(record.tasks)) - throw new ProtocolDecodeError("expected array", at(path, "tasks")); - return { - tasks: record.tasks.map((task, index) => - decodeTask(task, at(at(path, "tasks"), index)), - ), - ...(record.nextCursor === undefined - ? {} - : { - nextCursor: expectString(record.nextCursor, at(path, "nextCursor")), - }), - ...(record._meta === undefined - ? {} - : { _meta: jsonRecord(record._meta, at(path, "_meta")) }), - }; - }); -export const TaskStatusNotificationV1Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const record = expectRecord(value, path); - literal(record, "jsonrpc", "2.0", path); - literal(record, "method", "notifications/tasks/status", path); - const paramsRecord = jsonRecord(record.params, at(path, "params")); - return { - jsonrpc: "2.0", - method: "notifications/tasks/status", - params: { - ...decodeTask(record.params, at(path, "params")), - ...(paramsRecord._meta === undefined - ? {} - : { - _meta: jsonRecord( - paramsRecord._meta, - at(at(path, "params"), "_meta"), - ), - }), - }, - }; - }); diff --git a/packages/ext-tasks/src/core/v1/index.test.ts b/packages/ext-tasks/src/core/v1/index.test.ts index e2b763f..8059ff4 100644 --- a/packages/ext-tasks/src/core/v1/index.test.ts +++ b/packages/ext-tasks/src/core/v1/index.test.ts @@ -1,23 +1,24 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; +import type { z } from "zod/v4"; import { - CallToolRequestV1Codec, - CallToolResultV1Codec, - CancelTaskRequestV1Codec, - CancelTaskResultV1Codec, - CreateTaskResultV1Codec, - GetTaskRequestV1Codec, - GetTaskResultRequestV1Codec, - GetTaskResultV1Codec, - ListTasksRequestV1Codec, - ListTasksResultV1Codec, - ServerTaskCapabilitiesV1Codec, - TaskResultV1Codec, - TaskStatusNotificationV1Codec, - TaskStatusV1Codec, - TaskV1Codec, - ToolV1Codec, + CallToolRequestV1Schema, + CallToolResultV1Schema, + CancelTaskRequestV1Schema, + CancelTaskResultV1Schema, + CreateTaskResultV1Schema, + GetTaskRequestV1Schema, + GetTaskResultRequestV1Schema, + GetTaskResultV1Schema, + ListTasksRequestV1Schema, + ListTasksResultV1Schema, + ServerTaskCapabilitiesV1Schema, + TaskResultV1Schema, + TaskStatusNotificationV1Schema, + TaskStatusV1Schema, + TaskV1Schema, + ToolV1Schema, callToolAsTaskV1, hasTaskCancelCapabilityV1, hasTaskListCapabilityV1, @@ -46,6 +47,10 @@ const taskArb = fc.record({ pollInterval: fc.option(fc.integer(), { nil: undefined }), }); const idArb = fc.oneof(fc.string(), fc.integer()); +const jsonRecordArb = fc.dictionary( + fc.string().filter((key) => key !== "__proto__"), + fc.jsonValue(), +); const taskRequestArb = ( method: "tasks/get" | "tasks/result" | "tasks/cancel", ) => @@ -56,30 +61,17 @@ const taskRequestArb = ( params: fc.record({ taskId: fc.string() }), }); -function expectRoundTrip( - codec: { parse(value: unknown): { success: boolean; value?: unknown } }, - value: unknown, -): void { - const wireValue: unknown = JSON.parse(JSON.stringify(value)); - const decoded = codec.parse(wireValue); - expect(decoded.success).toBe(true); - if (decoded.success) expect(decoded.value).toEqual(wireValue); +type Schema = z.ZodType; +const asWire = (value: unknown): unknown => JSON.parse(JSON.stringify(value)); +function expectRoundTrip(schema: Schema, value: unknown): void { + const wire = asWire(value); + expect(schema.parse(wire)).toEqual(wire); } -describe("V1 runtime wire contracts", () => { - it("round-trips Tasks and rejects missing fields, fractions, and unknown statuses", () => { +describe("V1 Zod wire schemas", () => { + it("accepts every Task output and rejects missing fields, null exceptions, fractions, and statuses", () => { fc.assert( - fc.property(taskArb, (task) => { - expectRoundTrip(TaskV1Codec, task); - }), - ); - fc.assert( - fc.property( - fc.string().filter((v) => !statuses.includes(v as TaskStatusV1)), - (value) => { - expect(TaskStatusV1Codec.parse(value).success).toBe(false); - }, - ), + fc.property(taskArb, (task) => expectRoundTrip(TaskV1Schema, task)), ); fc.assert( fc.property( @@ -92,9 +84,9 @@ describe("V1 runtime wire contracts", () => { "ttl", ), (task, key) => { - const { [key]: ignored, ...incomplete } = task; - void ignored; - expect(TaskV1Codec.parse(incomplete as never).success).toBe(false); + const invalid = { ...task }; + delete invalid[key]; + expect(TaskV1Schema.safeParse(invalid).success).toBe(false); }, ), ); @@ -102,53 +94,108 @@ describe("V1 runtime wire contracts", () => { fc.property( taskArb, fc - .double({ noNaN: true, noDefaultInfinity: true }) - .filter((n) => !Number.isInteger(n)), - (task, fraction) => { - expect(TaskV1Codec.parse({ ...task, ttl: fraction }).success).toBe( + .string() + .filter((value) => !statuses.includes(value as TaskStatusV1)), + (task, status) => { + expect(TaskStatusV1Schema.safeParse(status).success).toBe(false); + expect(TaskV1Schema.safeParse({ ...task, status }).success).toBe( false, ); + }, + ), + ); + fc.assert( + fc.property( + taskArb, + fc + .double({ noNaN: true, noDefaultInfinity: true }) + .filter((value) => !Number.isInteger(value)), + (task, fraction) => { expect( - TaskV1Codec.parse({ ...task, pollInterval: fraction }).success, + TaskV1Schema.safeParse({ ...task, ttl: fraction }).success, + ).toBe(false); + expect( + TaskV1Schema.safeParse({ ...task, pollInterval: fraction }).success, + ).toBe(false); + expect( + CallToolRequestV1Schema.safeParse({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "x", task: { ttl: fraction } }, + }).success, ).toBe(false); }, ), ); + const [task] = fc.sample(taskArb, 1); + expect(TaskV1Schema.safeParse({ ...task, ttl: null }).success).toBe(true); + expect( + TaskV1Schema.safeParse({ ...task, pollInterval: null }).success, + ).toBe(false); + expect( + CallToolRequestV1Schema.safeParse({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "x", task: { ttl: null } }, + }).success, + ).toBe(false); }); - it("round-trips strict task operation requests and rejects discriminator changes", () => { + it("enforces exact JSON-RPC literals and required request fields", () => { const cases = [ - [GetTaskRequestV1Codec, taskRequestArb("tasks/get")], - [GetTaskResultRequestV1Codec, taskRequestArb("tasks/result")], - [CancelTaskRequestV1Codec, taskRequestArb("tasks/cancel")], + [GetTaskRequestV1Schema, taskRequestArb("tasks/get")], + [GetTaskResultRequestV1Schema, taskRequestArb("tasks/result")], + [CancelTaskRequestV1Schema, taskRequestArb("tasks/cancel")], ] as const; - for (const [codec, arbitrary] of cases) + for (const [schema, arbitrary] of cases) { fc.assert( fc.property(arbitrary, (request) => { - expectRoundTrip(codec, request); + expectRoundTrip(schema, request); + expect(schema.safeParse({ ...request, jsonrpc: "1.0" }).success).toBe( + false, + ); expect( - codec.parse({ ...request, method: "tasks/nope" }).success, + schema.safeParse({ ...request, method: "tasks/nope" }).success, ).toBe(false); - expect(codec.parse({ ...request, jsonrpc: "1.0" }).success).toBe( + for (const key of ["jsonrpc", "id", "method", "params"] as const) { + const invalid = { ...request }; + delete invalid[key]; + expect(schema.safeParse(invalid).success).toBe(false); + } + expect(schema.safeParse({ ...request, params: {} }).success).toBe( false, ); - const { params: ignored, ...withoutParams } = request; - void ignored; - expect(codec.parse(withoutParams as never).success).toBe(false); }), ); + } + expect( + ListTasksRequestV1Schema.safeParse({ + jsonrpc: "2.0", + id: 1, + method: "tasks/nope", + }).success, + ).toBe(false); + expect( + TaskStatusNotificationV1Schema.safeParse({ + jsonrpc: "2.0", + method: "notifications/tasks/nope", + params: {}, + }).success, + ).toBe(false); }); - it("round-trips get/cancel/list/create results and notifications", () => { + it("parses all result and notification schema outputs", () => { fc.assert( - fc.property(taskArb, (task) => { - expectRoundTrip(GetTaskResultV1Codec, task); - expectRoundTrip(CancelTaskResultV1Codec, task); - expectRoundTrip(CreateTaskResultV1Codec, { task }); - expectRoundTrip(TaskStatusNotificationV1Codec, { + fc.property(taskArb, jsonRecordArb, (task, metadata) => { + expectRoundTrip(GetTaskResultV1Schema, { ...task, _meta: metadata }); + expectRoundTrip(CancelTaskResultV1Schema, { ...task, _meta: metadata }); + expectRoundTrip(CreateTaskResultV1Schema, { task, _meta: metadata }); + expectRoundTrip(TaskStatusNotificationV1Schema, { jsonrpc: "2.0", method: "notifications/tasks/status", - params: task, + params: { ...task, _meta: metadata }, }); }), ); @@ -156,51 +203,42 @@ describe("V1 runtime wire contracts", () => { fc.property( fc.array(taskArb), fc.option(fc.string(), { nil: undefined }), - (tasks, nextCursor) => { - expectRoundTrip(ListTasksResultV1Codec, { + (tasks, nextCursor) => + expectRoundTrip(ListTasksResultV1Schema, { tasks, ...(nextCursor === undefined ? {} : { nextCursor }), - }); - }, + }), ), ); fc.assert( fc.property( idArb, fc.option(fc.string(), { nil: undefined }), - (id, cursor) => { - expectRoundTrip(ListTasksRequestV1Codec, { + (id, cursor) => + expectRoundTrip(ListTasksRequestV1Schema, { jsonrpc: "2.0", id, method: "tasks/list", ...(cursor === undefined ? {} : { params: { cursor } }), - }); - }, + }), ), ); - expect( - TaskStatusNotificationV1Codec.parse({ - jsonrpc: "2.0", - method: "notifications/tasks/nope", - params: {}, - }).success, - ).toBe(false); - }); - - it("decodes arbitrary task results and strict tool call content discriminators", () => { fc.assert( - fc.property(fc.dictionary(fc.string(), fc.jsonValue()), (result) => { - expectRoundTrip(TaskResultV1Codec, result); - }), + fc.property(jsonRecordArb, (result) => + expectRoundTrip(TaskResultV1Schema, result), + ), ); + }); + + it("validates tool content discriminators and required fields", () => { const content = [ - { type: "text", text: "hello" }, + { type: "text", text: "hello", extension: true }, { type: "image", data: "x", mimeType: "image/png" }, { type: "audio", data: "x", mimeType: "audio/wav" }, { type: "resource_link", name: "n", uri: "https://x" }, { type: "resource", resource: { uri: "https://x", text: "body" } }, ]; - expectRoundTrip(CallToolResultV1Codec, { + expectRoundTrip(CallToolResultV1Schema, { content, structuredContent: { ok: true }, isError: false, @@ -217,27 +255,68 @@ describe("V1 runtime wire contracts", () => { ), (type) => { expect( - CallToolResultV1Codec.parse({ content: [{ type }] }).success, + CallToolResultV1Schema.safeParse({ content: [{ type }] }).success, ).toBe(false); }, ), ); expect( - CallToolResultV1Codec.parse({ content: [{ type: "text" }] }).success, + CallToolResultV1Schema.safeParse({ content: [{ type: "text" }] }).success, ).toBe(false); + expect(CallToolResultV1Schema.safeParse({}).success).toBe(false); }); - it("decodes tools, task-augmented calls, and nested capabilities strictly", () => { + it("preserves the pinned unknown-key projection and open-record policy", () => { + const [task] = fc.sample(taskArb, 1); + expect(TaskV1Schema.parse({ ...task, extension: true })).toEqual(task); + expect( + GetTaskRequestV1Schema.parse({ + jsonrpc: "2.0", + id: 1, + method: "tasks/get", + params: { taskId: "t", extension: true }, + extension: true, + }), + ).toEqual({ + jsonrpc: "2.0", + id: 1, + method: "tasks/get", + params: { taskId: "t" }, + }); + expect( + CallToolResultV1Schema.parse({ + content: [{ type: "text", text: "x", extension: true }], + extension: { ok: true }, + }), + ).toEqual({ + content: [{ type: "text", text: "x", extension: true }], + extension: { ok: true }, + }); + expect( + ToolV1Schema.parse({ + name: "x", + inputSchema: { type: "object", extension: true }, + execution: { taskSupport: "optional", extension: true }, + extension: true, + }), + ).toEqual({ + name: "x", + inputSchema: { type: "object", extension: true }, + execution: { taskSupport: "optional" }, + }); + }); + + it("parses tools, task calls, and nested capabilities", () => { fc.assert( fc.property( fc.string(), fc.option(fc.constantFrom("forbidden", "optional", "required"), { nil: undefined, }), - fc.dictionary(fc.string(), fc.jsonValue()), - fc.array(fc.dictionary(fc.string(), fc.jsonValue())), - (name, taskSupport, metadata, icons) => { - expectRoundTrip(ToolV1Codec, { + jsonRecordArb, + fc.array(jsonRecordArb), + (name, taskSupport, metadata, icons) => + expectRoundTrip(ToolV1Schema, { name, title: "title", description: "description", @@ -249,77 +328,42 @@ describe("V1 runtime wire contracts", () => { annotations: metadata, icons, _meta: metadata, - }); - }, + }), ), ); - expect(ToolV1Codec.parse({ name: "x", inputSchema: {} }).success).toBe( + expect(ToolV1Schema.safeParse({ name: "x", inputSchema: {} }).success).toBe( false, ); expect( - ToolV1Codec.parse({ + ToolV1Schema.safeParse({ name: "x", inputSchema: { type: "object" }, execution: { taskSupport: "sometimes" }, }).success, ).toBe(false); - for (const [field, invalid] of [ - ["outputSchema", true], - ["annotations", true], - ["icons", true], - ["_meta", true], - ] as const) { - expect( - ToolV1Codec.parse({ - name: "x", - inputSchema: { type: "object" }, - [field]: invalid, - }).success, - ).toBe(false); - } - expect( - ToolV1Codec.parse({ - name: "x", - inputSchema: { type: "object" }, - icons: [true], - }).success, - ).toBe(false); fc.assert( - fc.property( - idArb, - fc.string(), - fc.dictionary(fc.string(), fc.jsonValue()), - (id, name, args) => { - expectRoundTrip(CallToolRequestV1Codec, { - jsonrpc: "2.0", - id, - method: "tools/call", - params: { name, arguments: args, task: {} }, - }); - }, + fc.property(idArb, fc.string(), jsonRecordArb, (id, name, args) => + expectRoundTrip(CallToolRequestV1Schema, { + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name, arguments: args, task: {} }, + }), ), ); - expect( - CallToolRequestV1Codec.parse({ - jsonrpc: "2.0", - id: 1, - method: "tools/nope", - params: { name: "x" }, - }).success, - ).toBe(false); - expectRoundTrip(ServerTaskCapabilitiesV1Codec, { + expectRoundTrip(ServerTaskCapabilitiesV1Schema, { list: {}, cancel: {}, requests: { tools: { call: {} } }, }); expect( - ServerTaskCapabilitiesV1Codec.parse({ + ServerTaskCapabilitiesV1Schema.safeParse({ requests: { tools: { call: true } }, }).success, ).toBe(false); }); - it("follows every capability-first negotiation row and narrow guard", () => { + it("follows every capability-first negotiation row", () => { const support = fc.option( fc.constantFrom("forbidden", "optional", "required"), { nil: undefined }, diff --git a/packages/ext-tasks/src/core/v1/index.ts b/packages/ext-tasks/src/core/v1/index.ts index cfb73cc..113db08 100644 --- a/packages/ext-tasks/src/core/v1/index.ts +++ b/packages/ext-tasks/src/core/v1/index.ts @@ -1,6 +1,30 @@ /** MCP Tasks V1 public API. */ export { + CallToolAsTaskRequestV1Schema, + CallToolRequestV1Schema, + CallToolResultV1Schema, + CancelTaskRequestV1Schema, + CancelTaskResultV1Schema, + ContentBlockV1Schema, + CreateTaskResultV1Schema, + GetTaskRequestV1Schema, + GetTaskResultRequestV1Schema, + GetTaskResultV1Schema, + JsonRpcRequestIdV1Schema, + ListTasksRequestV1Schema, + ListTasksResultV1Schema, + ServerCapabilitiesV1Schema, + ServerTaskCapabilitiesV1Schema, + TaskEligibleMethodV1Schema, + TaskMetadataV1Schema, + TaskResultV1Schema, TaskStatusesV1, + TaskStatusNotificationV1Schema, + TaskStatusV1Schema, + TaskSupportV1Schema, + TaskV1Schema, + ToolExecutionV1Schema, + ToolV1Schema, type CallToolAsTaskRequestV1, type CallToolRequestV1, type CallToolResultV1, @@ -25,25 +49,7 @@ export { type TaskV1, type ToolExecutionV1, type ToolV1, -} from "./wire.js"; -export { - CallToolRequestV1Codec, - CallToolResultV1Codec, - CancelTaskRequestV1Codec, - CancelTaskResultV1Codec, - CreateTaskResultV1Codec, - GetTaskRequestV1Codec, - GetTaskResultRequestV1Codec, - GetTaskResultV1Codec, - ListTasksRequestV1Codec, - ListTasksResultV1Codec, - ServerTaskCapabilitiesV1Codec, - TaskResultV1Codec, - TaskStatusNotificationV1Codec, - TaskStatusV1Codec, - TaskV1Codec, - ToolV1Codec, -} from "./codecs.js"; +} from "./schemas.js"; export { callToolAsTaskV1, hasTaskCancelCapabilityV1, diff --git a/packages/ext-tasks/src/core/v1/negotiation.ts b/packages/ext-tasks/src/core/v1/negotiation.ts index 22fb283..4f1c264 100644 --- a/packages/ext-tasks/src/core/v1/negotiation.ts +++ b/packages/ext-tasks/src/core/v1/negotiation.ts @@ -5,7 +5,7 @@ import { type ServerTaskCapabilitiesV1, type TaskEligibleMethodV1, type ToolV1, -} from "./wire.js"; +} from "./schemas.js"; /** Checks whether the server advertises task listing by defining its list capability. */ export function hasTaskListCapabilityV1( capabilities: ServerTaskCapabilitiesV1, diff --git a/packages/ext-tasks/src/core/v1/schemas.ts b/packages/ext-tasks/src/core/v1/schemas.ts new file mode 100644 index 0000000..f639df4 --- /dev/null +++ b/packages/ext-tasks/src/core/v1/schemas.ts @@ -0,0 +1,217 @@ +/** MCP Tasks V1 runtime schemas and schema-derived wire types. */ +import * as z from "zod/v4"; + +import { JsonValueSchema } from "../index.js"; + +const JsonRecordSchema = z.record(z.string(), JsonValueSchema); +const ObjectJsonSchema = z + .object({ type: z.literal("object") }) + .catchall(JsonValueSchema); + +export const TaskStatusesV1 = [ + "working", + "input_required", + "completed", + "failed", + "cancelled", +] as const; +export const TaskStatusV1Schema = z.enum(TaskStatusesV1); +export type TaskStatusV1 = z.output; + +export const TaskSupportV1Schema = z.enum([ + "forbidden", + "optional", + "required", +]); +export type TaskSupportV1 = z.output; + +export const TaskEligibleMethodV1Schema = z.literal("tools/call"); +export type TaskEligibleMethodV1 = z.output; + +export const JsonRpcRequestIdV1Schema = z.union([z.string(), z.number()]); +export type JsonRpcRequestIdV1 = z.output; + +export const TaskMetadataV1Schema = z.object({ + ttl: z.number().int().optional(), +}); +export type TaskMetadataV1 = z.output; + +export const TaskV1Schema = z.object({ + taskId: z.string(), + status: TaskStatusV1Schema, + statusMessage: z.string().optional(), + createdAt: z.string(), + lastUpdatedAt: z.string(), + /** Normative V1 permits null for unlimited retention; the pinned JSON Schema omitted it. */ + ttl: z.number().int().nullable(), + pollInterval: z.number().int().optional(), +}); +export type TaskV1 = z.output; + +export const CreateTaskResultV1Schema = z.object({ + task: TaskV1Schema, + _meta: JsonRecordSchema.optional(), +}); +export type CreateTaskResultV1 = z.output; + +export const ToolExecutionV1Schema = z.object({ + taskSupport: TaskSupportV1Schema.optional(), +}); +export type ToolExecutionV1 = z.output; + +export const ToolV1Schema = z.object({ + name: z.string(), + title: z.string().optional(), + description: z.string().optional(), + inputSchema: ObjectJsonSchema, + outputSchema: ObjectJsonSchema.optional(), + execution: ToolExecutionV1Schema.optional(), + annotations: JsonRecordSchema.optional(), + icons: z.array(JsonRecordSchema).optional(), + _meta: JsonRecordSchema.optional(), +}); +export type ToolV1 = z.output; + +const TextContentBlockV1Schema = z + .object({ type: z.literal("text"), text: z.string() }) + .catchall(JsonValueSchema); +const MediaContentBlockV1Schema = z + .object({ + type: z.enum(["image", "audio"]), + data: z.string(), + mimeType: z.string(), + }) + .catchall(JsonValueSchema); +const ResourceLinkContentBlockV1Schema = z + .object({ + type: z.literal("resource_link"), + name: z.string(), + uri: z.string(), + }) + .catchall(JsonValueSchema); +const EmbeddedResourceContentBlockV1Schema = z + .object({ type: z.literal("resource"), resource: JsonRecordSchema }) + .catchall(JsonValueSchema); + +export const ContentBlockV1Schema = z.union([ + TextContentBlockV1Schema, + MediaContentBlockV1Schema, + ResourceLinkContentBlockV1Schema, + EmbeddedResourceContentBlockV1Schema, +]); +export type ContentBlockV1 = z.output; + +export const CallToolRequestV1Schema = z.object({ + jsonrpc: z.literal("2.0"), + id: JsonRpcRequestIdV1Schema, + method: z.literal("tools/call"), + params: z.object({ + name: z.string(), + arguments: JsonRecordSchema.optional(), + task: TaskMetadataV1Schema.optional(), + }), +}); +export type CallToolRequestV1 = z.output; + +export const CallToolResultV1Schema = z + .object({ + content: z.array(ContentBlockV1Schema), + structuredContent: JsonRecordSchema.optional(), + isError: z.boolean().optional(), + _meta: JsonRecordSchema.optional(), + }) + .catchall(JsonValueSchema); +export type CallToolResultV1 = z.output; + +export const ServerTaskCapabilitiesV1Schema = z.object({ + list: JsonRecordSchema.optional(), + cancel: JsonRecordSchema.optional(), + requests: z + .object({ + tools: z + .object({ + call: JsonRecordSchema.optional(), + }) + .optional(), + }) + .optional(), +}); +export type ServerTaskCapabilitiesV1 = z.output< + typeof ServerTaskCapabilitiesV1Schema +>; + +export const ServerCapabilitiesV1Schema = z.object({ + tasks: ServerTaskCapabilitiesV1Schema.optional(), +}); +export type ServerCapabilitiesV1 = z.output; + +function taskRequestSchema< + M extends "tasks/get" | "tasks/result" | "tasks/cancel", +>(method: M) { + return z.object({ + jsonrpc: z.literal("2.0"), + id: JsonRpcRequestIdV1Schema, + method: z.literal(method), + params: z.object({ taskId: z.string() }), + }); +} + +export const GetTaskRequestV1Schema = taskRequestSchema("tasks/get"); +export type GetTaskRequestV1 = z.output; + +export const GetTaskResultV1Schema = TaskV1Schema.extend({ + _meta: JsonRecordSchema.optional(), +}); +export type GetTaskResultV1 = z.output; + +export const GetTaskResultRequestV1Schema = taskRequestSchema("tasks/result"); +export type GetTaskResultRequestV1 = z.output< + typeof GetTaskResultRequestV1Schema +>; + +export const TaskResultV1Schema = JsonRecordSchema; +export type TaskResultV1 = z.output; + +export const ListTasksRequestV1Schema = z.object({ + jsonrpc: z.literal("2.0"), + id: JsonRpcRequestIdV1Schema, + method: z.literal("tasks/list"), + params: z.object({ cursor: z.string().optional() }).optional(), +}); +export type ListTasksRequestV1 = z.output; + +export const ListTasksResultV1Schema = z.object({ + tasks: z.array(TaskV1Schema), + nextCursor: z.string().optional(), + _meta: JsonRecordSchema.optional(), +}); +export type ListTasksResultV1 = z.output; + +export const CancelTaskRequestV1Schema = taskRequestSchema("tasks/cancel"); +export type CancelTaskRequestV1 = z.output; + +export const CancelTaskResultV1Schema = TaskV1Schema.extend({ + _meta: JsonRecordSchema.optional(), +}); +export type CancelTaskResultV1 = z.output; + +export const TaskStatusNotificationV1Schema = z.object({ + jsonrpc: z.literal("2.0"), + method: z.literal("notifications/tasks/status"), + params: TaskV1Schema.extend({ _meta: JsonRecordSchema.optional() }), +}); +export type TaskStatusNotificationV1 = z.output< + typeof TaskStatusNotificationV1Schema +>; + +export const CallToolAsTaskRequestV1Schema = z.object({ + method: z.literal("tools/call"), + params: z.object({ + name: z.string(), + arguments: JsonRecordSchema.optional(), + task: z.object({}), + }), +}); +export type CallToolAsTaskRequestV1 = z.output< + typeof CallToolAsTaskRequestV1Schema +>; diff --git a/packages/ext-tasks/src/core/v1/wire.ts b/packages/ext-tasks/src/core/v1/wire.ts deleted file mode 100644 index 2e5817c..0000000 --- a/packages/ext-tasks/src/core/v1/wire.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** MCP Tasks V1 wire declarations. */ -import { type JsonValue } from "../index.js"; - -export const TaskStatusesV1 = [ - "working", - "input_required", - "completed", - "failed", - "cancelled", -] as const; -export type TaskStatusV1 = (typeof TaskStatusesV1)[number]; -export type TaskSupportV1 = "forbidden" | "optional" | "required"; -export type TaskEligibleMethodV1 = "tools/call"; -export type JsonRpcRequestIdV1 = string | number; - -export interface TaskMetadataV1 { - readonly ttl?: number; -} -export interface TaskV1 { - readonly taskId: string; - readonly status: TaskStatusV1; - readonly statusMessage?: string; - readonly createdAt: string; - readonly lastUpdatedAt: string; - /** Normative V1 source permits null for unlimited retention; the pinned JSON Schema omitted this union. */ - readonly ttl: number | null; - readonly pollInterval?: number; -} - -export interface CreateTaskResultV1 { - readonly task: TaskV1; - readonly _meta?: Readonly>; -} - -export interface ToolExecutionV1 { - readonly taskSupport?: TaskSupportV1; -} -export interface ToolV1 { - readonly name: string; - readonly title?: string; - readonly description?: string; - readonly inputSchema: Readonly> & { - readonly type: "object"; - }; - readonly outputSchema?: Readonly> & { - readonly type: "object"; - }; - readonly execution?: ToolExecutionV1; - readonly annotations?: Readonly>; - readonly icons?: readonly Readonly>[]; - readonly _meta?: Readonly>; -} - -export type ContentBlockV1 = - | { - readonly type: "text"; - readonly text: string; - readonly [key: string]: JsonValue; - } - | { - readonly type: "image" | "audio"; - readonly data: string; - readonly mimeType: string; - readonly [key: string]: JsonValue; - } - | { - readonly type: "resource_link"; - readonly name: string; - readonly uri: string; - readonly [key: string]: JsonValue; - } - | { - readonly type: "resource"; - readonly resource: Readonly>; - readonly [key: string]: JsonValue; - }; -export interface CallToolRequestV1 { - readonly jsonrpc: "2.0"; - readonly id: JsonRpcRequestIdV1; - readonly method: "tools/call"; - readonly params: { - readonly name: string; - readonly arguments?: Readonly>; - readonly task?: TaskMetadataV1; - }; -} - -export interface CallToolResultV1 { - readonly content: readonly ContentBlockV1[]; - readonly structuredContent?: Readonly>; - readonly isError?: boolean; - readonly _meta?: Readonly>; -} - -export interface ServerTaskCapabilitiesV1 { - readonly list?: Readonly>; - readonly cancel?: Readonly>; - readonly requests?: { - readonly tools?: { readonly call?: Readonly> }; - }; -} -export interface ServerCapabilitiesV1 { - readonly tasks?: ServerTaskCapabilitiesV1; -} - -interface JsonRpcRequestV1 { - readonly jsonrpc: "2.0"; - readonly id: JsonRpcRequestIdV1; - readonly method: M; - readonly params: P; -} -export type GetTaskRequestV1 = JsonRpcRequestV1< - "tasks/get", - { readonly taskId: string } ->; -export type GetTaskResultV1 = TaskV1 & { - readonly _meta?: Readonly>; -}; -export type GetTaskResultRequestV1 = JsonRpcRequestV1< - "tasks/result", - { readonly taskId: string } ->; -export type TaskResultV1 = Readonly>; -export interface ListTasksRequestV1 { - readonly jsonrpc: "2.0"; - readonly id: JsonRpcRequestIdV1; - readonly method: "tasks/list"; - readonly params?: { readonly cursor?: string }; -} -export interface ListTasksResultV1 { - readonly tasks: readonly TaskV1[]; - readonly nextCursor?: string; - readonly _meta?: Readonly>; -} -export type CancelTaskRequestV1 = JsonRpcRequestV1< - "tasks/cancel", - { readonly taskId: string } ->; -export type CancelTaskResultV1 = TaskV1 & { - readonly _meta?: Readonly>; -}; -export interface TaskStatusNotificationV1 { - readonly jsonrpc: "2.0"; - readonly method: "notifications/tasks/status"; - readonly params: TaskV1 & { - readonly _meta?: Readonly>; - }; -} - -export interface CallToolAsTaskRequestV1 { - readonly method: "tools/call"; - readonly params: { - readonly name: string; - readonly arguments?: Readonly>; - readonly task: Record; - }; -} diff --git a/packages/ext-tasks/src/core/v2/codecs.ts b/packages/ext-tasks/src/core/v2/codecs.ts deleted file mode 100644 index cbecfdf..0000000 --- a/packages/ext-tasks/src/core/v2/codecs.ts +++ /dev/null @@ -1,664 +0,0 @@ -/** MCP Tasks V2 runtime codecs. */ -import { - ProtocolDecodeError, - createRuntimeCodec, - expectEnum, - expectInteger, - expectLiteralProperty, - expectNumber, - expectOptionalBooleanProperty, - expectOptionalRecordProperty, - expectRecord, - expectRequiredRecord, - expectString, - hasOwn, - isJsonArray, - type DecodePath, -} from "../internal/codec.js"; -import { type JsonValue, type RuntimeCodec } from "../index.js"; -import { - type CallToolResultV2, - type ContentBlockV2, - type CancelTaskRequestV2, - type CancelTaskResultV2, - type CancelledTaskV2, - type CompletedTaskV2, - type CreateMessageRequestV2, - type CreateMessageResultV2, - type CreateTaskResultV2, - type DetailedTaskV2, - type ElicitRequestV2, - type ElicitResultV2, - type ErrorV2, - type FailedTaskV2, - type GetTaskRequestV2, - type GetTaskResultV2, - type InputRequestV2, - type InputRequestsV2, - type InputRequiredTaskV2, - type InputResponseV2, - type InputResponsesV2, - type ListRootsRequestV2, - type ListRootsResultV2, - type RequestIdV2, - type TaskStatusNotificationParamsV2, - type TaskStatusNotificationV2, - type TaskSubscriptionAcknowledgedNotificationsV2, - type TaskSubscriptionNotificationsV2, - type TasksExtensionCapabilityV2, - type TaskV2, - type ToolV2, - type UpdateTaskRequestV2, - type UpdateTaskResultV2, - type WorkingTaskV2, -} from "./wire.js"; -const statuses = [ - "working", - "input_required", - "completed", - "failed", - "cancelled", -] as const; -const inputMethods = [ - "sampling/createMessage", - "roots/list", - "elicitation/create", -] as const; - -function expectRequestId( - value: JsonValue | undefined, - path: DecodePath, -): RequestIdV2 { - if (typeof value === "string") return value; - return expectInteger(value, path); -} -function optionalString( - object: Record, - key: string, - path: DecodePath, -): void { - if (object[key] !== undefined) expectString(object[key], [...path, key]); -} -function optionalStringArray( - object: Record, - key: string, - path: DecodePath, -): void { - const value = object[key]; - if ( - value !== undefined && - (!Array.isArray(value) || !value.every((item) => typeof item === "string")) - ) { - throw new ProtocolDecodeError("expected string array", [...path, key]); - } -} - -function decodeAnnotations(value: JsonValue, path: DecodePath): void { - const object = expectRecord(value, path); - if (object.audience !== undefined) { - if ( - !Array.isArray(object.audience) || - !object.audience.every((role) => role === "user" || role === "assistant") - ) { - throw new ProtocolDecodeError("expected role array", [ - ...path, - "audience", - ]); - } - } - if (object.priority !== undefined) { - const priority = expectNumber(object.priority, [...path, "priority"]); - if (priority < 0 || priority > 1) - throw new ProtocolDecodeError("expected number from 0 to 1", [ - ...path, - "priority", - ]); - } - optionalString(object, "lastModified", path); -} - -function decodeIcon(value: JsonValue, path: DecodePath): void { - const object = expectRecord(value, path); - expectString(object.src, [...path, "src"]); - optionalString(object, "mimeType", path); - optionalStringArray(object, "sizes", path); - if (object.theme !== undefined) - expectEnum(object.theme, ["light", "dark"], [...path, "theme"]); -} - -function decodeImplementation(value: JsonValue, path: DecodePath): void { - const object = expectRecord(value, path); - expectString(object.name, [...path, "name"]); - expectString(object.version, [...path, "version"]); - optionalString(object, "title", path); - optionalString(object, "description", path); - optionalString(object, "websiteUrl", path); - if (object.icons !== undefined) { - if (!isJsonArray(object.icons)) - throw new ProtocolDecodeError("expected array", [...path, "icons"]); - object.icons.forEach((icon, index) => - decodeIcon(icon, [...path, "icons", index]), - ); - } -} - -function decodeContentBlock( - value: JsonValue, - path: DecodePath, -): ContentBlockV2 { - const object = expectRecord(value, path); - const type = expectEnum( - object.type, - ["text", "image", "audio", "resource_link", "resource"], - [...path, "type"], - ); - if (type === "text") expectString(object.text, [...path, "text"]); - else if (type === "image" || type === "audio") { - expectString(object.data, [...path, "data"]); - expectString(object.mimeType, [...path, "mimeType"]); - } else if (type === "resource_link") { - expectString(object.name, [...path, "name"]); - expectString(object.uri, [...path, "uri"]); - optionalString(object, "title", path); - optionalString(object, "description", path); - optionalString(object, "mimeType", path); - if (object.size !== undefined) - expectInteger(object.size, [...path, "size"]); - if (object.icons !== undefined) { - if (!isJsonArray(object.icons)) - throw new ProtocolDecodeError("expected array", [...path, "icons"]); - object.icons.forEach((icon, index) => - decodeIcon(icon, [...path, "icons", index]), - ); - } - } else { - const resource = expectRequiredRecord(object.resource, [ - ...path, - "resource", - ]); - expectString(resource.uri, [...path, "resource", "uri"]); - optionalString(resource, "mimeType", [...path, "resource"]); - expectOptionalRecordProperty(resource, "_meta", [...path, "resource"]); - const hasText = resource.text !== undefined; - const hasBlob = resource.blob !== undefined; - if (!hasText && !hasBlob) - throw new ProtocolDecodeError("expected text or blob", [ - ...path, - "resource", - ]); - if (hasText) expectString(resource.text, [...path, "resource", "text"]); - if (hasBlob) expectString(resource.blob, [...path, "resource", "blob"]); - } - if (object.annotations !== undefined) - decodeAnnotations(object.annotations, [...path, "annotations"]); - expectOptionalRecordProperty(object, "_meta", path); - return object as ContentBlockV2; -} - -function decodeTool(value: JsonValue, path: DecodePath): ToolV2 { - const object = expectRecord(value, path); - expectString(object.name, [...path, "name"]); - optionalString(object, "title", path); - optionalString(object, "description", path); - const inputSchema = expectRecord(object.inputSchema, [ - ...path, - "inputSchema", - ]); - expectLiteralProperty(inputSchema, "type", "object", [ - ...path, - "inputSchema", - ]); - optionalString(inputSchema, "$schema", [...path, "inputSchema"]); - if (object.outputSchema !== undefined) { - const outputSchema = expectRecord(object.outputSchema, [ - ...path, - "outputSchema", - ]); - optionalString(outputSchema, "$schema", [...path, "outputSchema"]); - } - if (object.annotations !== undefined) { - const annotations = expectRecord(object.annotations, [ - ...path, - "annotations", - ]); - optionalString(annotations, "title", [...path, "annotations"]); - for (const key of [ - "readOnlyHint", - "destructiveHint", - "idempotentHint", - "openWorldHint", - ]) - expectOptionalBooleanProperty(annotations, key, [...path, "annotations"]); - } - if (object.icons !== undefined) { - if (!isJsonArray(object.icons)) - throw new ProtocolDecodeError("expected array", [...path, "icons"]); - object.icons.forEach((icon, index) => - decodeIcon(icon, [...path, "icons", index]), - ); - } - expectOptionalRecordProperty(object, "_meta", path); - return object as ToolV2; -} - -function decodeCallToolResult( - value: JsonValue, - path: DecodePath, -): CallToolResultV2 { - const object = expectRecord(value, path); - expectString(object.resultType, [...path, "resultType"]); - if (!isJsonArray(object.content)) - throw new ProtocolDecodeError("expected array", [...path, "content"]); - object.content.forEach((block, index) => - decodeContentBlock(block, [...path, "content", index]), - ); - expectOptionalBooleanProperty(object, "isError", path); - const meta = expectOptionalRecordProperty(object, "_meta", path); - if (meta?.["io.modelcontextprotocol/serverInfo"] !== undefined) { - decodeImplementation(meta["io.modelcontextprotocol/serverInfo"], [ - ...path, - "_meta", - "io.modelcontextprotocol/serverInfo", - ]); - } - return object as CallToolResultV2; -} - -function decodeTask(value: JsonValue, path: DecodePath): TaskV2 { - const object = expectRecord(value, path); - const ttl = object.ttlMs; - if (!hasOwn(object, "ttlMs")) - throw new ProtocolDecodeError("required field", [...path, "ttlMs"]); - // Task is a closed wire shape; wrappers preserve extension data through `_meta`. - const task: TaskV2 = { - taskId: expectString(object.taskId, [...path, "taskId"]), - status: expectEnum(object.status, statuses, [...path, "status"]), - createdAt: expectString(object.createdAt, [...path, "createdAt"]), - lastUpdatedAt: expectString(object.lastUpdatedAt, [ - ...path, - "lastUpdatedAt", - ]), - ttlMs: ttl === null ? null : expectInteger(ttl, [...path, "ttlMs"]), - ...(object.statusMessage === undefined - ? {} - : { - statusMessage: expectString(object.statusMessage, [ - ...path, - "statusMessage", - ]), - }), - ...(object.pollIntervalMs === undefined - ? {} - : { - pollIntervalMs: expectInteger(object.pollIntervalMs, [ - ...path, - "pollIntervalMs", - ]), - }), - }; - return task; -} - -function decodeError(value: JsonValue, path: DecodePath): ErrorV2 { - const object = expectRecord(value, path); - return { - code: expectInteger(object.code, [...path, "code"]), - message: expectString(object.message, [...path, "message"]), - ...(hasOwn(object, "data") ? { data: object.data } : {}), - }; -} - -function decodeInputRequest( - value: JsonValue, - path: DecodePath, -): InputRequestV2 { - const object = expectRecord(value, path); - const method = expectEnum(object.method, inputMethods, [...path, "method"]); - if (method === "roots/list") { - return { - method, - ...(object.params === undefined - ? {} - : { params: expectRecord(object.params, [...path, "params"]) }), - }; - } - return { - method, - params: expectRequiredRecord(object.params, [...path, "params"]), - }; -} -function decodeInputRequests( - value: JsonValue, - path: DecodePath, -): InputRequestsV2 { - const object = expectRecord(value, path); - return Object.fromEntries( - Object.entries(object).map(([key, request]) => [ - key, - decodeInputRequest(request, [...path, key]), - ]), - ); -} - -function decodeInputResponse( - value: JsonValue, - path: DecodePath, -): InputResponseV2 { - const object = expectRecord(value, path); - if (hasOwn(object, "action")) { - expectEnum( - object.action, - ["accept", "decline", "cancel"], - [...path, "action"], - ); - } else if (hasOwn(object, "roots")) { - if (!Array.isArray(object.roots)) - throw new ProtocolDecodeError("expected array", [...path, "roots"]); - } else { - if (!hasOwn(object, "content")) - throw new ProtocolDecodeError("required field", [...path, "content"]); - expectString(object.model, [...path, "model"]); - expectEnum(object.role, ["user", "assistant"], [...path, "role"]); - } - return object as InputResponseV2; -} -function decodeInputResponses( - value: JsonValue, - path: DecodePath, -): InputResponsesV2 { - const object = expectRecord(value, path); - return Object.fromEntries( - Object.entries(object).map(([key, response]) => [ - key, - decodeInputResponse(response, [...path, key]), - ]), - ); -} - -function decodeDetailedTask( - value: JsonValue, - path: DecodePath, -): DetailedTaskV2 { - const object = expectRecord(value, path); - const task = decodeTask(value, path); - switch (task.status) { - case "input_required": - return { - ...task, - status: task.status, - inputRequests: decodeInputRequests(object.inputRequests, [ - ...path, - "inputRequests", - ]), - }; - case "completed": - return { - ...task, - status: task.status, - result: expectRequiredRecord(object.result, [...path, "result"]), - }; - case "failed": - return { - ...task, - status: task.status, - error: decodeError(object.error, [...path, "error"]), - }; - case "working": - return { ...task, status: task.status }; - case "cancelled": - return { ...task, status: task.status }; - } -} - -function decodeRpcRequest(value: JsonValue, path: DecodePath, method: string) { - const object = expectRecord(value, path); - expectLiteralProperty(object, "jsonrpc", "2.0", path); - expectLiteralProperty(object, "method", method, path); - return { - object, - id: expectRequestId(object.id, [...path, "id"]), - params: expectRequiredRecord(object.params, [...path, "params"]), - }; -} -function decodeCompleteResult(value: JsonValue, path: DecodePath) { - const object = expectRecord(value, path); - expectLiteralProperty(object, "resultType", "complete", path); - expectOptionalRecordProperty(object, "_meta", path); - return object; -} - -export const ToolV2Codec: RuntimeCodec = - createRuntimeCodec(decodeTool); -export const CallToolResultV2Codec: RuntimeCodec = - createRuntimeCodec(decodeCallToolResult); -export const TaskV2Codec: RuntimeCodec = - createRuntimeCodec(decodeTask); -export const DetailedTaskV2Codec: RuntimeCodec = - createRuntimeCodec(decodeDetailedTask); -export const ErrorV2Codec: RuntimeCodec = - createRuntimeCodec(decodeError); -export const InputRequestV2Codec: RuntimeCodec = - createRuntimeCodec(decodeInputRequest); -export const InputRequestsV2Codec: RuntimeCodec = - createRuntimeCodec(decodeInputRequests); -export const InputResponseV2Codec: RuntimeCodec = - createRuntimeCodec(decodeInputResponse); -export const InputResponsesV2Codec: RuntimeCodec = - createRuntimeCodec(decodeInputResponses); -export const CreateMessageRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const request = decodeInputRequest(value, path); - if (request.method !== "sampling/createMessage") - throw new ProtocolDecodeError("expected sampling/createMessage", [ - ...path, - "method", - ]); - return request; - }); -export const ListRootsRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const request = decodeInputRequest(value, path); - if (request.method !== "roots/list") - throw new ProtocolDecodeError("expected roots/list", [...path, "method"]); - return request; - }); -export const ElicitRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const request = decodeInputRequest(value, path); - if (request.method !== "elicitation/create") - throw new ProtocolDecodeError("expected elicitation/create", [ - ...path, - "method", - ]); - return request; - }); -export const CreateMessageResultV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const response = decodeInputResponse(value, path); - if ( - !("content" in response) || - !("model" in response) || - !("role" in response) - ) - throw new ProtocolDecodeError("expected sampling result", path); - return response as CreateMessageResultV2; - }); -export const ListRootsResultV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const response = decodeInputResponse(value, path); - if (!("roots" in response)) - throw new ProtocolDecodeError("expected roots result", path); - return response as ListRootsResultV2; - }); -export const ElicitResultV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const response = decodeInputResponse(value, path); - if (!("action" in response)) - throw new ProtocolDecodeError("expected elicitation result", path); - return response as ElicitResultV2; - }); -export const CreateTaskResultV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - expectLiteralProperty(object, "resultType", "task", path); - expectOptionalRecordProperty(object, "_meta", path); - return { - ...decodeTask(value, path), - resultType: "task", - ...(object._meta === undefined - ? {} - : { _meta: expectRecord(object._meta, [...path, "_meta"]) }), - }; - }); -export const GetTaskRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const { id, params } = decodeRpcRequest(value, path, "tasks/get"); - return { - jsonrpc: "2.0", - id, - method: "tasks/get", - params: { - taskId: expectString(params.taskId, [...path, "params", "taskId"]), - }, - }; - }); -export const UpdateTaskRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const { id, params } = decodeRpcRequest(value, path, "tasks/update"); - return { - jsonrpc: "2.0", - id, - method: "tasks/update", - params: { - taskId: expectString(params.taskId, [...path, "params", "taskId"]), - inputResponses: decodeInputResponses(params.inputResponses, [ - ...path, - "params", - "inputResponses", - ]), - }, - }; - }); -export const CancelTaskRequestV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const { id, params } = decodeRpcRequest(value, path, "tasks/cancel"); - return { - jsonrpc: "2.0", - id, - method: "tasks/cancel", - params: { - taskId: expectString(params.taskId, [...path, "params", "taskId"]), - }, - }; - }); -export const GetTaskResultV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const object = decodeCompleteResult(value, path); - return { - ...decodeDetailedTask(value, path), - resultType: "complete", - ...(object._meta === undefined - ? {} - : { _meta: expectRecord(object._meta, [...path, "_meta"]) }), - }; - }); -export const UpdateTaskResultV2Codec: RuntimeCodec = - createRuntimeCodec( - (value, path) => decodeCompleteResult(value, path) as UpdateTaskResultV2, - ); -export const CancelTaskResultV2Codec: RuntimeCodec = - createRuntimeCodec( - (value, path) => decodeCompleteResult(value, path) as CancelTaskResultV2, - ); -export const WorkingTaskV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const task = decodeDetailedTask(value, path); - if (task.status !== "working") - throw new ProtocolDecodeError("expected working", [...path, "status"]); - return task; - }); -export const InputRequiredTaskV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const task = decodeDetailedTask(value, path); - if (task.status !== "input_required") - throw new ProtocolDecodeError("expected input_required", [ - ...path, - "status", - ]); - return task; - }); -export const CompletedTaskV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const task = decodeDetailedTask(value, path); - if (task.status !== "completed") - throw new ProtocolDecodeError("expected completed", [...path, "status"]); - return task; - }); -export const FailedTaskV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const task = decodeDetailedTask(value, path); - if (task.status !== "failed") - throw new ProtocolDecodeError("expected failed", [...path, "status"]); - return task; - }); -export const CancelledTaskV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const task = decodeDetailedTask(value, path); - if (task.status !== "cancelled") - throw new ProtocolDecodeError("expected cancelled", [...path, "status"]); - return task; - }); -function decodeTaskStatusNotificationParams( - value: JsonValue, - path: DecodePath, -): TaskStatusNotificationParamsV2 { - const object = expectRecord(value, path); - return { - ...decodeDetailedTask(value, path), - ...(object._meta === undefined - ? {} - : { _meta: expectRecord(object._meta, [...path, "_meta"]) }), - }; -} - -export const TaskStatusNotificationParamsV2Codec: RuntimeCodec = - createRuntimeCodec( - decodeTaskStatusNotificationParams, - ); -export const TaskSubscriptionNotificationsV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - if (object.taskIds === undefined) return {}; - if ( - !Array.isArray(object.taskIds) || - !object.taskIds.every((id) => typeof id === "string") - ) - throw new ProtocolDecodeError("expected string array", [ - ...path, - "taskIds", - ]); - return { taskIds: object.taskIds }; - }); -export const TaskSubscriptionAcknowledgedNotificationsV2Codec: RuntimeCodec = - TaskSubscriptionNotificationsV2Codec; -export const TasksExtensionCapabilityV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - if (Object.keys(object).length !== 0) - throw new ProtocolDecodeError("expected empty object", path); - return {}; - }); -export const TaskStatusNotificationV2Codec: RuntimeCodec = - createRuntimeCodec((value, path) => { - const object = expectRecord(value, path); - expectLiteralProperty(object, "jsonrpc", "2.0", path); - expectLiteralProperty(object, "method", "notifications/tasks", path); - return { - jsonrpc: "2.0", - method: "notifications/tasks", - params: decodeTaskStatusNotificationParams(object.params, [ - ...path, - "params", - ]), - }; - }); diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts index 5a5e0bd..e09d113 100644 --- a/packages/ext-tasks/src/core/v2/index.test.ts +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -1,28 +1,28 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { ProtocolDecodeError, type JsonValue } from "../index.js"; +import { type JsonValue } from "../index.js"; import * as coreV2 from "./index.js"; import { - CallToolResultV2Codec, - CancelTaskRequestV2Codec, - CancelTaskResultV2Codec, - CreateTaskResultV2Codec, - DetailedTaskV2Codec, - ErrorV2Codec, - GetTaskRequestV2Codec, - GetTaskResultV2Codec, - InputRequestsV2Codec, - InputResponsesV2Codec, - TaskStatusNotificationParamsV2Codec, - TaskStatusNotificationV2Codec, - TasksExtensionCapabilityV2Codec, - TaskV2Codec, - ToolV2Codec, - UpdateTaskRequestV2Codec, - UpdateTaskResultV2Codec, + CallToolResultV2Schema, + CancelTaskRequestV2Schema, + CancelTaskResultV2Schema, + CreateTaskResultV2Schema, + DetailedTaskV2Schema, + ErrorV2Schema, + GetTaskRequestV2Schema, + GetTaskResultV2Schema, + InputRequestsV2Schema, + InputResponsesV2Schema, + TaskStatusNotificationParamsV2Schema, + TaskStatusNotificationV2Schema, + TasksExtensionCapabilityV2Schema, + TaskV2Schema, + ToolV2Schema, + UpdateTaskRequestV2Schema, + UpdateTaskResultV2Schema, contributeTaskFilterV2, hasTaskClientCapabilityV2, hasTaskServerCapabilityV2, @@ -59,7 +59,7 @@ describe("V2 runtime wire contracts", () => { it("accepts every valid base Task and rejects missing required fields, invalid integers, and statuses", () => { fc.assert( fc.property(baseTask, (task) => { - expect(TaskV2Codec.parse(asJson(task)).success).toBe(true); + expect(TaskV2Schema.safeParse(asJson(task)).success).toBe(true); }), ); fc.assert( @@ -75,7 +75,7 @@ describe("V2 runtime wire contracts", () => { (task, key) => { const invalid = { ...task }; delete invalid[key]; - expect(TaskV2Codec.parse(asJson(invalid)).success).toBe(false); + expect(TaskV2Schema.safeParse(asJson(invalid)).success).toBe(false); }, ), ); @@ -86,9 +86,9 @@ describe("V2 runtime wire contracts", () => { .string() .filter((status) => !statuses.includes(status as TaskStatusV2)), (task, status) => { - expect(TaskV2Codec.parse(asJson({ ...task, status })).success).toBe( - false, - ); + expect( + TaskV2Schema.safeParse(asJson({ ...task, status })).success, + ).toBe(false); }, ), ); @@ -99,16 +99,16 @@ describe("V2 runtime wire contracts", () => { .double({ noNaN: true, noDefaultInfinity: true }) .filter((n) => !Number.isInteger(n)), (task, ttlMs) => { - expect(TaskV2Codec.parse(asJson({ ...task, ttlMs })).success).toBe( - false, - ); + expect( + TaskV2Schema.safeParse(asJson({ ...task, ttlMs })).success, + ).toBe(false); }, ), ); }); it("keeps Task closed while preserving wrapper metadata", () => { - const decoded = TaskV2Codec.parse({ + const decoded = TaskV2Schema.safeParse({ taskId: "task", status: "working", createdAt: "created", @@ -117,9 +117,9 @@ describe("V2 runtime wire contracts", () => { vendorHint: 1, }); expect(decoded.success).toBe(true); - if (decoded.success) expect("vendorHint" in decoded.value).toBe(false); + if (decoded.success) expect("vendorHint" in decoded.data).toBe(false); - const notification = TaskStatusNotificationParamsV2Codec.parse({ + const notification = TaskStatusNotificationParamsV2Schema.safeParse({ taskId: "task", status: "working", createdAt: "created", @@ -129,18 +129,18 @@ describe("V2 runtime wire contracts", () => { }); expect(notification.success).toBe(true); if (notification.success) - expect(notification.value._meta).toEqual({ vendorHint: 1 }); + expect(notification.data._meta).toEqual({ vendorHint: 1 }); }); it("enforces status-owned DetailedTask payloads", () => { fc.assert( fc.property(taskFor("working"), (task) => { - expect(DetailedTaskV2Codec.parse(asJson(task)).success).toBe(true); + expect(DetailedTaskV2Schema.safeParse(asJson(task)).success).toBe(true); }), ); fc.assert( fc.property(taskFor("cancelled"), (task) => { - expect(DetailedTaskV2Codec.parse(asJson(task)).success).toBe(true); + expect(DetailedTaskV2Schema.safeParse(asJson(task)).success).toBe(true); }), ); fc.assert( @@ -152,7 +152,7 @@ describe("V2 runtime wire contracts", () => { ), (task, inputRequests) => { expect( - DetailedTaskV2Codec.parse(asJson({ ...task, inputRequests })) + DetailedTaskV2Schema.safeParse(asJson({ ...task, inputRequests })) .success, ).toBe(true); }, @@ -164,7 +164,7 @@ describe("V2 runtime wire contracts", () => { fc.dictionary(fc.string(), fc.jsonValue()), (task, result) => { expect( - DetailedTaskV2Codec.parse(asJson({ ...task, result })).success, + DetailedTaskV2Schema.safeParse(asJson({ ...task, result })).success, ).toBe(true); }, ), @@ -176,7 +176,7 @@ describe("V2 runtime wire contracts", () => { fc.string(), (task, code, message) => { expect( - DetailedTaskV2Codec.parse( + DetailedTaskV2Schema.safeParse( asJson({ ...task, error: { code, message } }), ).success, ).toBe(true); @@ -188,7 +188,7 @@ describe("V2 runtime wire contracts", () => { fc.constantFrom("input_required", "completed", "failed"), (status) => { expect( - DetailedTaskV2Codec.parse({ + DetailedTaskV2Schema.safeParse({ taskId: "id", status, createdAt: "a", @@ -219,15 +219,16 @@ describe("V2 runtime wire contracts", () => { ), ), (requests) => { - expect(InputRequestsV2Codec.parse(asJson(requests)).success).toBe( - true, - ); + expect( + InputRequestsV2Schema.safeParse(asJson(requests)).success, + ).toBe(true); }, ), ); expect( - InputRequestsV2Codec.parse({ key: { method: "unknown", params: {} } }) - .success, + InputRequestsV2Schema.safeParse({ + key: { method: "unknown", params: {} }, + }).success, ).toBe(false); fc.assert( fc.property( @@ -250,13 +251,13 @@ describe("V2 runtime wire contracts", () => { ), ), (responses) => { - expect(InputResponsesV2Codec.parse(asJson(responses)).success).toBe( - true, - ); + expect( + InputResponsesV2Schema.safeParse(asJson(responses)).success, + ).toBe(true); }, ), ); - expect(InputResponsesV2Codec.parse({ key: {} }).success).toBe(false); + expect(InputResponsesV2Schema.safeParse({ key: {} }).success).toBe(false); }); it("decodes complete JSON-RPC errors", () => { @@ -267,7 +268,7 @@ describe("V2 runtime wire contracts", () => { fc.option(fc.jsonValue(), { nil: undefined }), (code, message, data) => { expect( - ErrorV2Codec.parse( + ErrorV2Schema.safeParse( asJson({ code, message, @@ -278,8 +279,8 @@ describe("V2 runtime wire contracts", () => { }, ), ); - expect(ErrorV2Codec.parse({ code: 1 }).success).toBe(false); - expect(ErrorV2Codec.parse({ code: 1.5, message: "bad" }).success).toBe( + expect(ErrorV2Schema.safeParse({ code: 1 }).success).toBe(false); + expect(ErrorV2Schema.safeParse({ code: 1.5, message: "bad" }).success).toBe( false, ); }); @@ -325,17 +326,18 @@ describe("V2 runtime wire contracts", () => { ], _meta: { trace: "test" }, }); - const parsed = ToolV2Codec.parse(tool); + const parsed = ToolV2Schema.safeParse(tool); expect(parsed.success).toBe(true); - if (parsed.success) expect(parsed.value).toEqual(tool); + if (parsed.success) expect(parsed.data).toEqual(tool); }, ), ); - expect(ToolV2Codec.parse({ name: "x", inputSchema: {} }).success).toBe( + expect(ToolV2Schema.safeParse({ name: "x", inputSchema: {} }).success).toBe( false, ); expect( - ToolV2Codec.parse({ name: "x", inputSchema: { type: "array" } }).success, + ToolV2Schema.safeParse({ name: "x", inputSchema: { type: "array" } }) + .success, ).toBe(false); for (const [field, invalid] of [ ["outputSchema", true], @@ -344,7 +346,7 @@ describe("V2 runtime wire contracts", () => { ["_meta", true], ] as const) { expect( - ToolV2Codec.parse({ + ToolV2Schema.safeParse({ name: "x", inputSchema: { type: "object" }, [field]: invalid, @@ -352,14 +354,14 @@ describe("V2 runtime wire contracts", () => { ).toBe(false); } expect( - ToolV2Codec.parse({ + ToolV2Schema.safeParse({ name: "x", inputSchema: { type: "object" }, annotations: { readOnlyHint: "yes" }, }).success, ).toBe(false); expect( - ToolV2Codec.parse({ + ToolV2Schema.safeParse({ name: "x", inputSchema: { type: "object" }, icons: [{}], @@ -403,7 +405,7 @@ describe("V2 runtime wire contracts", () => { ]; fc.assert( fc.property( - fc.string(), + fc.constant("complete" as const), fc.jsonValue(), fc.dictionary(fc.string(), fc.jsonValue()), (resultType, structuredContent, extra) => { @@ -415,20 +417,28 @@ describe("V2 runtime wire contracts", () => { isError: false, _meta: { trace: "test" }, }); - const parsed = CallToolResultV2Codec.parse(result); + const parsed = CallToolResultV2Schema.safeParse(result); expect(parsed.success).toBe(true); - if (parsed.success) expect(parsed.value).toEqual(result); + if (parsed.success) expect(parsed.data).toEqual(result); }, ), ); + expect(CallToolResultV2Schema.parse({ content: [] })).toEqual({ + resultType: "complete", + content: [], + }); expect( - CallToolResultV2Codec.parse({ resultType: "complete" }).success, + CallToolResultV2Schema.safeParse({ resultType: "complete" }).success, ).toBe(false); expect( - CallToolResultV2Codec.parse({ resultType: 1, content: [] }).success, + CallToolResultV2Schema.safeParse({ resultType: 1, content: [] }).success, ).toBe(false); expect( - CallToolResultV2Codec.parse({ + CallToolResultV2Schema.safeParse({ resultType: "task", content: [] }) + .success, + ).toBe(false); + expect( + CallToolResultV2Schema.safeParse({ resultType: "complete", content: [{ type: "text" }], }).success, @@ -445,7 +455,7 @@ describe("V2 runtime wire contracts", () => { ), (type) => { expect( - CallToolResultV2Codec.parse({ + CallToolResultV2Schema.safeParse({ resultType: "complete", content: [{ type }], }).success, @@ -454,7 +464,7 @@ describe("V2 runtime wire contracts", () => { ), ); expect( - CallToolResultV2Codec.parse({ + CallToolResultV2Schema.safeParse({ resultType: "complete", content: [], isError: "no", @@ -469,7 +479,7 @@ describe("V2 runtime wire contracts", () => { fc.string(), (id, taskId) => { expect( - GetTaskRequestV2Codec.parse({ + GetTaskRequestV2Schema.safeParse({ jsonrpc: "2.0", id, method: "tasks/get", @@ -477,7 +487,7 @@ describe("V2 runtime wire contracts", () => { }).success, ).toBe(true); expect( - CancelTaskRequestV2Codec.parse({ + CancelTaskRequestV2Schema.safeParse({ jsonrpc: "2.0", id, method: "tasks/cancel", @@ -485,7 +495,7 @@ describe("V2 runtime wire contracts", () => { }).success, ).toBe(true); expect( - UpdateTaskRequestV2Codec.parse({ + UpdateTaskRequestV2Schema.safeParse({ jsonrpc: "2.0", id, method: "tasks/update", @@ -495,33 +505,34 @@ describe("V2 runtime wire contracts", () => { }, ), ); - for (const codec of [ - GetTaskRequestV2Codec, - UpdateTaskRequestV2Codec, - CancelTaskRequestV2Codec, + for (const schema of [ + GetTaskRequestV2Schema, + UpdateTaskRequestV2Schema, + CancelTaskRequestV2Schema, ]) { expect( - codec.parse({ jsonrpc: "2.0", id: 1, method: "wrong", params: {} }) + schema.safeParse({ jsonrpc: "2.0", id: 1, method: "wrong", params: {} }) .success, ).toBe(false); } + expect(UpdateTaskResultV2Schema.parse({})).toEqual({ + resultType: "complete", + }); + expect(CancelTaskResultV2Schema.parse({})).toEqual({ + resultType: "complete", + }); expect( - UpdateTaskResultV2Codec.parse({ resultType: "complete" }).success, - ).toBe(true); - expect( - CancelTaskResultV2Codec.parse({ resultType: "complete" }).success, - ).toBe(true); - expect(UpdateTaskResultV2Codec.parse({}).success).toBe(false); + UpdateTaskResultV2Schema.safeParse({ resultType: "task" }).success, + ).toBe(false); fc.assert( fc.property( taskFor("completed"), fc.dictionary(fc.string(), fc.jsonValue()), (task, result) => { - expect( - GetTaskResultV2Codec.parse( - asJson({ ...task, result, resultType: "complete" }), - ).success, - ).toBe(true); + const parsed = GetTaskResultV2Schema.parse( + asJson({ ...task, result }), + ); + expect(parsed.resultType).toBe("complete"); }, ), ); @@ -531,13 +542,13 @@ describe("V2 runtime wire contracts", () => { fc.assert( fc.property(baseTask, (task) => { const result = asJson({ ...task, resultType: "task" }); - expect(CreateTaskResultV2Codec.parse(result).success).toBe(true); + expect(CreateTaskResultV2Schema.safeParse(result).success).toBe(true); expect(isToolCallTaskResultV2("tools/call", result)).toBe(true); expect(isToolCallTaskResultV2("prompts/get", result)).toBe(false); }), ); expect( - CreateTaskResultV2Codec.parse({ resultType: "complete" }).success, + CreateTaskResultV2Schema.safeParse({ resultType: "complete" }).success, ).toBe(false); }); @@ -545,7 +556,7 @@ describe("V2 runtime wire contracts", () => { fc.assert( fc.property(taskFor("working"), (task) => { expect( - TaskStatusNotificationV2Codec.parse( + TaskStatusNotificationV2Schema.safeParse( asJson({ jsonrpc: "2.0", method: "notifications/tasks", @@ -556,7 +567,7 @@ describe("V2 runtime wire contracts", () => { }), ); expect( - TaskStatusNotificationV2Codec.parse({ + TaskStatusNotificationV2Schema.safeParse({ jsonrpc: "2.0", method: "notifications/wrong", params: {}, @@ -572,19 +583,19 @@ describe("V2 runtime wire contracts", () => { (task, meta) => { const params = asJson({ ...task, _meta: meta }); const paramsResult = - TaskStatusNotificationParamsV2Codec.parse(params); + TaskStatusNotificationParamsV2Schema.safeParse(params); expect(paramsResult.success).toBe(true); if (paramsResult.success) - expect(paramsResult.value._meta).toEqual(asJson(meta)); + expect(paramsResult.data._meta).toEqual(asJson(meta)); - const notificationResult = TaskStatusNotificationV2Codec.parse({ + const notificationResult = TaskStatusNotificationV2Schema.safeParse({ jsonrpc: "2.0", method: "notifications/tasks", params, }); expect(notificationResult.success).toBe(true); if (notificationResult.success) - expect(notificationResult.value.params._meta).toEqual(asJson(meta)); + expect(notificationResult.data.params._meta).toEqual(asJson(meta)); }, ), ); @@ -598,33 +609,115 @@ describe("V2 runtime wire contracts", () => { ttlMs: null, _meta: meta, }; - const paramsResult = TaskStatusNotificationParamsV2Codec.parse(params); + const paramsResult = + TaskStatusNotificationParamsV2Schema.safeParse(params); expect(paramsResult.success).toBe(false); if (!paramsResult.success) { - expect(paramsResult.error).toBeInstanceOf(ProtocolDecodeError); - expect(paramsResult.error.path).toEqual(["_meta"]); + expect(paramsResult.error.issues[0]?.path).toEqual(["_meta"]); } - const notificationResult = TaskStatusNotificationV2Codec.parse({ + const notificationResult = TaskStatusNotificationV2Schema.safeParse({ jsonrpc: "2.0", method: "notifications/tasks", params, }); expect(notificationResult.success).toBe(false); if (!notificationResult.success) - expect(notificationResult.error.path).toEqual(["params", "_meta"]); + expect(notificationResult.error.issues[0]?.path).toEqual([ + "params", + "_meta", + ]); } }); + it("accepts valid input responses despite colliding extension keys", () => { + expect( + InputResponsesV2Schema.safeParse({ + response: { + action: "invalid", + roots: [], + content: {}, + model: "model", + role: "assistant", + }, + }).success, + ).toBe(true); + expect( + InputResponsesV2Schema.safeParse({ + response: { + roots: "invalid", + content: {}, + model: "model", + role: "assistant", + }, + }).success, + ).toBe(true); + expect( + InputResponsesV2Schema.safeParse({ + response: { + action: "accept", + roots: "ignored extension value", + content: {}, + model: 1, + role: "invalid", + }, + }).success, + ).toBe(true); + expect( + InputResponsesV2Schema.safeParse({ + response: { action: "invalid", roots: "invalid" }, + }).success, + ).toBe(false); + }); + + it("preserves open result output and rejects wrong literals, fractions, and capability extras", () => { + const openResult = { + resultType: "complete", + content: [], + structuredContent: { answer: 42 }, + vendorOutput: { trace: true }, + }; + expect(CallToolResultV2Schema.parse(openResult)).toEqual(openResult); + expect(UpdateTaskResultV2Schema.parse({ ...openResult })).toEqual( + openResult, + ); + expect(CancelTaskResultV2Schema.parse({ ...openResult })).toEqual( + openResult, + ); + expect( + CreateTaskResultV2Schema.safeParse({ + taskId: "task", + resultType: "complete", + status: "working", + createdAt: "created", + lastUpdatedAt: "updated", + ttlMs: null, + }).success, + ).toBe(false); + expect( + TaskV2Schema.safeParse({ + taskId: "task", + status: "working", + createdAt: "created", + lastUpdatedAt: "updated", + ttlMs: null, + pollIntervalMs: 1.5, + }).success, + ).toBe(false); + expect( + TasksExtensionCapabilityV2Schema.safeParse({ extra: true }).success, + ).toBe(false); + }); + it("exports only the canonical V2 task result and capability names", () => { const capability: TasksExtensionCapabilityV2 = {}; const result: CallToolResultV2 = { resultType: "complete", content: [] }; - expect(TasksExtensionCapabilityV2Codec.parse(capability)).toEqual({ + expect(TasksExtensionCapabilityV2Schema.safeParse(capability)).toEqual({ success: true, - value: {}, + data: {}, }); - expect(CallToolResultV2Codec.parse(result).success).toBe(true); + expect(CallToolResultV2Schema.safeParse(result).success).toBe(true); expect( isToolCallTaskResultV2("tools/call", { taskId: "task", @@ -636,10 +729,11 @@ describe("V2 runtime wire contracts", () => { }), ).toBe(true); for (const removed of [ - "ToolCallResultV2Codec", + "ToolCallResultV2Schema", "isEligibleTaskResultV2", - "TaskExtensionCapabilitiesV2Codec", + "TaskExtensionCapabilitiesV2Schema", "supportsTasksExtensionV2", + ...Object.keys(coreV2).filter((name) => name.endsWith("Codec")), ]) { expect(removed in coreV2).toBe(false); } diff --git a/packages/ext-tasks/src/core/v2/index.ts b/packages/ext-tasks/src/core/v2/index.ts index 8ccd6f0..9c48a27 100644 --- a/packages/ext-tasks/src/core/v2/index.ts +++ b/packages/ext-tasks/src/core/v2/index.ts @@ -1,79 +1,5 @@ /** MCP Tasks V2 public API. */ -export { - CLIENT_CAPABILITIES_META_KEY_V2, - TASKS_EXTENSION_ID_V2, - type CallToolResultV2, - type CancelTaskRequestV2, - type CancelTaskResultV2, - type CancelledTaskV2, - type ClientTaskCapabilityEnvelopeV2, - type CompletedTaskV2, - type CreateMessageRequestV2, - type CreateMessageResultV2, - type CreateTaskResultV2, - type DetailedTaskV2, - type ElicitRequestV2, - type ElicitResultV2, - type ErrorV2, - type FailedTaskV2, - type GetTaskRequestV2, - type GetTaskResultV2, - type InputRequestV2, - type InputRequestsV2, - type InputRequiredTaskV2, - type InputResponseV2, - type InputResponsesV2, - type ListRootsRequestV2, - type ListRootsResultV2, - type RequestIdV2, - type ServerTaskCapabilityEnvelopeV2, - type TaskEligibleMethodV2, - type TaskStatusNotificationParamsV2, - type TaskStatusNotificationV2, - type TaskStatusV2, - type TaskSubscriptionAcknowledgedNotificationsV2, - type TaskSubscriptionNotificationsV2, - type TasksExtensionCapabilityV2, - type TaskV2, - type ToolV2, - type UpdateTaskRequestV2, - type UpdateTaskResultV2, - type WorkingTaskV2, -} from "./wire.js"; -export { - CallToolResultV2Codec, - CancelTaskRequestV2Codec, - CancelTaskResultV2Codec, - CancelledTaskV2Codec, - CompletedTaskV2Codec, - CreateMessageRequestV2Codec, - CreateMessageResultV2Codec, - CreateTaskResultV2Codec, - DetailedTaskV2Codec, - ElicitRequestV2Codec, - ElicitResultV2Codec, - ErrorV2Codec, - FailedTaskV2Codec, - GetTaskRequestV2Codec, - GetTaskResultV2Codec, - InputRequestV2Codec, - InputRequestsV2Codec, - InputRequiredTaskV2Codec, - InputResponseV2Codec, - InputResponsesV2Codec, - ListRootsRequestV2Codec, - ListRootsResultV2Codec, - TaskStatusNotificationParamsV2Codec, - TaskStatusNotificationV2Codec, - TaskSubscriptionAcknowledgedNotificationsV2Codec, - TaskSubscriptionNotificationsV2Codec, - TasksExtensionCapabilityV2Codec, - TaskV2Codec, - ToolV2Codec, - UpdateTaskRequestV2Codec, - UpdateTaskResultV2Codec, - WorkingTaskV2Codec, -} from "./codecs.js"; +export * from "./schemas.js"; export { contributeTaskFilterV2, hasTaskClientCapabilityV2, diff --git a/packages/ext-tasks/src/core/v2/integration.ts b/packages/ext-tasks/src/core/v2/integration.ts index ead0d99..a1471a1 100644 --- a/packages/ext-tasks/src/core/v2/integration.ts +++ b/packages/ext-tasks/src/core/v2/integration.ts @@ -1,14 +1,14 @@ /** MCP Tasks V2 guards, capability integration, and subscription helpers. */ import { type JsonValue } from "../index.js"; import { - CreateTaskResultV2Codec, - DetailedTaskV2Codec, - GetTaskRequestV2Codec, - TaskStatusNotificationV2Codec, - TaskV2Codec, - UpdateTaskRequestV2Codec, - CancelTaskRequestV2Codec, -} from "./codecs.js"; + CreateTaskResultV2Schema, + DetailedTaskV2Schema, + GetTaskRequestV2Schema, + TaskStatusNotificationV2Schema, + TaskV2Schema, + UpdateTaskRequestV2Schema, + CancelTaskRequestV2Schema, +} from "./schemas.js"; import { CLIENT_CAPABILITIES_META_KEY_V2, TASKS_EXTENSION_ID_V2, @@ -20,48 +20,48 @@ import { type TaskV2, type UpdateTaskRequestV2, type CancelTaskRequestV2, -} from "./wire.js"; +} from "./schemas.js"; function parsed( - codec: { parse(value: unknown): { success: boolean } }, + schema: { safeParse(value: unknown): { success: boolean } }, value: unknown, ): value is T { - return codec.parse(value).success; + return schema.safeParse(value).success; } export const isTaskV2: (value: unknown) => value is TaskV2 = ( value: unknown, -): value is TaskV2 => parsed(TaskV2Codec, value); +): value is TaskV2 => parsed(TaskV2Schema, value); export const isDetailedTaskV2: (value: unknown) => value is DetailedTaskV2 = ( value: unknown, ): value is DetailedTaskV2 => - parsed(DetailedTaskV2Codec, value); + parsed(DetailedTaskV2Schema, value); export const isCreateTaskResultV2: ( value: unknown, ) => value is CreateTaskResultV2 = ( value: unknown, ): value is CreateTaskResultV2 => - parsed(CreateTaskResultV2Codec, value); + parsed(CreateTaskResultV2Schema, value); export const isGetTaskRequestV2: ( value: unknown, ) => value is GetTaskRequestV2 = (value: unknown): value is GetTaskRequestV2 => - parsed(GetTaskRequestV2Codec, value); + parsed(GetTaskRequestV2Schema, value); export const isUpdateTaskRequestV2: ( value: unknown, ) => value is UpdateTaskRequestV2 = ( value: unknown, ): value is UpdateTaskRequestV2 => - parsed(UpdateTaskRequestV2Codec, value); + parsed(UpdateTaskRequestV2Schema, value); export const isCancelTaskRequestV2: ( value: unknown, ) => value is CancelTaskRequestV2 = ( value: unknown, ): value is CancelTaskRequestV2 => - parsed(CancelTaskRequestV2Codec, value); + parsed(CancelTaskRequestV2Schema, value); export const isTaskStatusNotificationV2: ( value: unknown, ) => value is TaskStatusNotificationV2 = ( value: unknown, ): value is TaskStatusNotificationV2 => - parsed(TaskStatusNotificationV2Codec, value); + parsed(TaskStatusNotificationV2Schema, value); /** * Recognizes a decoded task-creation result only when it belongs to `tools/call`. diff --git a/packages/ext-tasks/src/core/v2/schemas.ts b/packages/ext-tasks/src/core/v2/schemas.ts new file mode 100644 index 0000000..363b4ea --- /dev/null +++ b/packages/ext-tasks/src/core/v2/schemas.ts @@ -0,0 +1,384 @@ +/** MCP Tasks V2 Zod schemas and schema-inferred wire declarations. */ +import { z } from "zod/v4"; + +import { JsonValueSchema, isJsonValue, type JsonValue } from "../index.js"; + +export const TASKS_EXTENSION_ID_V2 = "io.modelcontextprotocol/tasks" as const; +export const CLIENT_CAPABILITIES_META_KEY_V2 = + "io.modelcontextprotocol/clientCapabilities" as const; + +const JsonObjectSchema = z.custom>>( + (value) => + value !== null && + typeof value === "object" && + !Array.isArray(value) && + isJsonValue(value), + "Expected a JSON object", +); +const MetaSchema = JsonObjectSchema; +const openObject = (shape: T) => { + const validator = z.object(shape).catchall(JsonValueSchema); + return z.unknown().transform((value, context) => { + const parsed = validator.safeParse(value); + if (parsed.success) return parsed.data; + for (const issue of parsed.error.issues) + context.addIssue({ + code: "custom", + path: issue.path, + message: issue.message, + }); + return z.NEVER; + }); +}; + +const IconV2Schema = openObject({ + src: z.string(), + mimeType: z.string().optional(), + sizes: z.array(z.string()).optional(), + theme: z.enum(["light", "dark"]).optional(), +}); +const AnnotationsV2Schema = openObject({ + audience: z.array(z.enum(["user", "assistant"])).optional(), + priority: z.number().min(0).max(1).optional(), + lastModified: z.string().optional(), +}); +const ResourceContentsV2Schema = openObject({ + uri: z.string(), + mimeType: z.string().optional(), + text: z.string().optional(), + blob: z.string().optional(), + _meta: MetaSchema.optional(), +}).refine( + (resource) => resource.text !== undefined || resource.blob !== undefined, + { + message: "expected text or blob", + }, +); + +const ContentBaseShape = { + annotations: AnnotationsV2Schema.optional(), + _meta: MetaSchema.optional(), +}; +const ContentBlockV2Schema = z.union([ + openObject({ + ...ContentBaseShape, + type: z.literal("text"), + text: z.string(), + }), + openObject({ + ...ContentBaseShape, + type: z.literal("image"), + data: z.string(), + mimeType: z.string(), + }), + openObject({ + ...ContentBaseShape, + type: z.literal("audio"), + data: z.string(), + mimeType: z.string(), + }), + openObject({ + ...ContentBaseShape, + type: z.literal("resource_link"), + name: z.string(), + uri: z.string(), + title: z.string().optional(), + description: z.string().optional(), + mimeType: z.string().optional(), + size: z.int().optional(), + icons: z.array(IconV2Schema).optional(), + }), + openObject({ + ...ContentBaseShape, + type: z.literal("resource"), + resource: ResourceContentsV2Schema, + }), +]); + +const ToolV2Schema = openObject({ + name: z.string(), + title: z.string().optional(), + description: z.string().optional(), + inputSchema: openObject({ + type: z.literal("object"), + $schema: z.string().optional(), + }), + outputSchema: openObject({ $schema: z.string().optional() }).optional(), + annotations: openObject({ + title: z.string().optional(), + readOnlyHint: z.boolean().optional(), + destructiveHint: z.boolean().optional(), + idempotentHint: z.boolean().optional(), + openWorldHint: z.boolean().optional(), + }).optional(), + icons: z.array(IconV2Schema).optional(), + _meta: MetaSchema.optional(), +}); + +const CompleteResultTypeSchema = z.literal("complete").default("complete"); +const CallToolResultV2Schema = openObject({ + resultType: CompleteResultTypeSchema, + content: z.array(ContentBlockV2Schema), + structuredContent: JsonValueSchema.optional(), + isError: z.boolean().optional(), + _meta: MetaSchema.optional(), +}); + +const RequestIdV2Schema = z.union([z.string(), z.int()]); +const TaskStatusV2Schema = z.enum([ + "working", + "input_required", + "completed", + "failed", + "cancelled", +]); +const TaskEligibleMethodV2Schema = z.literal("tools/call"); +const TaskBaseShape = { + taskId: z.string(), + statusMessage: z.string().optional(), + createdAt: z.string(), + lastUpdatedAt: z.string(), + ttlMs: z.int().nullable(), + pollIntervalMs: z.int().optional(), +}; +const TaskV2Schema = z.object({ + ...TaskBaseShape, + status: TaskStatusV2Schema, +}); + +const ErrorV2Schema = z.object({ + code: z.int(), + message: z.string(), + data: JsonValueSchema.optional(), +}); + +const CreateMessageRequestV2Schema = z.object({ + method: z.literal("sampling/createMessage"), + params: JsonObjectSchema, +}); +const ListRootsRequestV2Schema = z.object({ + method: z.literal("roots/list"), + params: JsonObjectSchema.optional(), +}); +const ElicitRequestV2Schema = z.object({ + method: z.literal("elicitation/create"), + params: JsonObjectSchema, +}); +const InputRequestV2Schema = z.discriminatedUnion("method", [ + CreateMessageRequestV2Schema, + ListRootsRequestV2Schema, + ElicitRequestV2Schema, +]); +const InputRequestsV2Schema = z.record(z.string(), InputRequestV2Schema); + +const CreateMessageResultV2Schema = openObject({ + content: JsonValueSchema, + model: z.string(), + role: z.enum(["user", "assistant"]), +}); +const ListRootsResultV2Schema = openObject({ + roots: z.array(JsonValueSchema), +}); +const ElicitResultV2Schema = openObject({ + action: z.enum(["accept", "decline", "cancel"]), +}); +const InputResponseUnionV2Schema = z.union([ + ElicitResultV2Schema, + ListRootsResultV2Schema, + CreateMessageResultV2Schema, +]); +const InputResponseV2Schema = InputResponseUnionV2Schema; +const InputResponsesV2Schema = z.record(z.string(), InputResponseV2Schema); + +const WorkingTaskV2Schema = z.object({ + ...TaskBaseShape, + status: z.literal("working"), +}); +const InputRequiredTaskV2Schema = z.object({ + ...TaskBaseShape, + status: z.literal("input_required"), + inputRequests: InputRequestsV2Schema, +}); +const CompletedTaskV2Schema = z.object({ + ...TaskBaseShape, + status: z.literal("completed"), + result: JsonObjectSchema, +}); +const FailedTaskV2Schema = z.object({ + ...TaskBaseShape, + status: z.literal("failed"), + error: ErrorV2Schema, +}); +const CancelledTaskV2Schema = z.object({ + ...TaskBaseShape, + status: z.literal("cancelled"), +}); +const DetailedTaskV2Schema = z.discriminatedUnion("status", [ + WorkingTaskV2Schema, + InputRequiredTaskV2Schema, + CompletedTaskV2Schema, + FailedTaskV2Schema, + CancelledTaskV2Schema, +]); + +const CreateTaskResultV2Schema = z.object({ + ...TaskBaseShape, + status: TaskStatusV2Schema, + resultType: z.literal("task"), + _meta: MetaSchema.optional(), +}); +const RpcBaseShape = { jsonrpc: z.literal("2.0"), id: RequestIdV2Schema }; +const GetTaskRequestV2Schema = z.object({ + ...RpcBaseShape, + method: z.literal("tasks/get"), + params: z.object({ taskId: z.string() }), +}); +const UpdateTaskRequestV2Schema = z.object({ + ...RpcBaseShape, + method: z.literal("tasks/update"), + params: z.object({ + taskId: z.string(), + inputResponses: InputResponsesV2Schema, + }), +}); +const CancelTaskRequestV2Schema = z.object({ + ...RpcBaseShape, + method: z.literal("tasks/cancel"), + params: z.object({ taskId: z.string() }), +}); +const GetTaskResultV2Schema = z.intersection( + DetailedTaskV2Schema, + z.object({ + resultType: CompleteResultTypeSchema, + _meta: MetaSchema.optional(), + }), +); +const UpdateTaskResultV2Schema = openObject({ + resultType: CompleteResultTypeSchema, + _meta: MetaSchema.optional(), +}); +const CancelTaskResultV2Schema = openObject({ + resultType: CompleteResultTypeSchema, + _meta: MetaSchema.optional(), +}); + +const TaskStatusNotificationParamsV2Schema = z.intersection( + DetailedTaskV2Schema, + z.object({ _meta: MetaSchema.optional() }), +); +const TaskStatusNotificationV2Schema = z.object({ + jsonrpc: z.literal("2.0"), + method: z.literal("notifications/tasks"), + params: TaskStatusNotificationParamsV2Schema, +}); +const TaskSubscriptionNotificationsV2Schema = z.object({ + taskIds: z.array(z.string()).optional(), +}); +const TaskSubscriptionAcknowledgedNotificationsV2Schema = + TaskSubscriptionNotificationsV2Schema; +const TasksExtensionCapabilityV2Schema = z.strictObject({}); + +const ClientTaskCapabilityEnvelopeV2Schema = z.object({ + extensions: z.object({ + [TASKS_EXTENSION_ID_V2]: TasksExtensionCapabilityV2Schema, + }), +}); +const ServerTaskCapabilityEnvelopeV2Schema = z.object({ + extensions: z.record(z.string(), JsonValueSchema).optional(), +}); + +export { + ContentBlockV2Schema, + ToolV2Schema, + CallToolResultV2Schema, + RequestIdV2Schema, + TaskStatusV2Schema, + TaskEligibleMethodV2Schema, + TaskV2Schema, + ErrorV2Schema, + CreateMessageRequestV2Schema, + ListRootsRequestV2Schema, + ElicitRequestV2Schema, + InputRequestV2Schema, + InputRequestsV2Schema, + CreateMessageResultV2Schema, + ListRootsResultV2Schema, + ElicitResultV2Schema, + InputResponseV2Schema, + InputResponsesV2Schema, + WorkingTaskV2Schema, + InputRequiredTaskV2Schema, + CompletedTaskV2Schema, + FailedTaskV2Schema, + CancelledTaskV2Schema, + DetailedTaskV2Schema, + CreateTaskResultV2Schema, + GetTaskRequestV2Schema, + UpdateTaskRequestV2Schema, + CancelTaskRequestV2Schema, + GetTaskResultV2Schema, + UpdateTaskResultV2Schema, + CancelTaskResultV2Schema, + TaskStatusNotificationParamsV2Schema, + TaskStatusNotificationV2Schema, + TaskSubscriptionNotificationsV2Schema, + TaskSubscriptionAcknowledgedNotificationsV2Schema, + TasksExtensionCapabilityV2Schema, + ClientTaskCapabilityEnvelopeV2Schema, + ServerTaskCapabilityEnvelopeV2Schema, +}; + +export type ContentBlockV2 = z.infer; +export type ToolV2 = z.infer; +export type CallToolResultV2 = z.infer; +export type RequestIdV2 = z.infer; +export type TaskStatusV2 = z.infer; +export type TaskEligibleMethodV2 = z.infer; +export type TaskV2 = z.infer; +export type ErrorV2 = z.infer; +export type CreateMessageRequestV2 = z.infer< + typeof CreateMessageRequestV2Schema +>; +export type ListRootsRequestV2 = z.infer; +export type ElicitRequestV2 = z.infer; +export type InputRequestV2 = z.infer; +export type InputRequestsV2 = z.infer; +export type CreateMessageResultV2 = z.infer; +export type ListRootsResultV2 = z.infer; +export type ElicitResultV2 = z.infer; +export type InputResponseV2 = z.infer; +export type InputResponsesV2 = z.infer; +export type WorkingTaskV2 = z.infer; +export type InputRequiredTaskV2 = z.infer; +export type CompletedTaskV2 = z.infer; +export type FailedTaskV2 = z.infer; +export type CancelledTaskV2 = z.infer; +export type DetailedTaskV2 = z.infer; +export type CreateTaskResultV2 = z.infer; +export type GetTaskRequestV2 = z.infer; +export type UpdateTaskRequestV2 = z.infer; +export type CancelTaskRequestV2 = z.infer; +export type GetTaskResultV2 = z.infer; +export type UpdateTaskResultV2 = z.infer; +export type CancelTaskResultV2 = z.infer; +export type TaskStatusNotificationParamsV2 = z.infer< + typeof TaskStatusNotificationParamsV2Schema +>; +export type TaskStatusNotificationV2 = z.infer< + typeof TaskStatusNotificationV2Schema +>; +export type TaskSubscriptionNotificationsV2 = z.infer< + typeof TaskSubscriptionNotificationsV2Schema +>; +export type TaskSubscriptionAcknowledgedNotificationsV2 = z.infer< + typeof TaskSubscriptionAcknowledgedNotificationsV2Schema +>; +export type TasksExtensionCapabilityV2 = z.infer< + typeof TasksExtensionCapabilityV2Schema +>; +export type ClientTaskCapabilityEnvelopeV2 = z.infer< + typeof ClientTaskCapabilityEnvelopeV2Schema +>; +export type ServerTaskCapabilityEnvelopeV2 = z.infer< + typeof ServerTaskCapabilityEnvelopeV2Schema +>; diff --git a/packages/ext-tasks/src/core/v2/wire.ts b/packages/ext-tasks/src/core/v2/wire.ts deleted file mode 100644 index 5f20fb8..0000000 --- a/packages/ext-tasks/src/core/v2/wire.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** MCP Tasks V2 wire declarations. */ -import { type JsonValue } from "../index.js"; - -export const TASKS_EXTENSION_ID_V2 = "io.modelcontextprotocol/tasks" as const; -export const CLIENT_CAPABILITIES_META_KEY_V2 = - "io.modelcontextprotocol/clientCapabilities" as const; - -type OpenObjectV2 = Readonly>; -type ToolAnnotationsV2 = OpenObjectV2 & { - readonly title?: string; - readonly readOnlyHint?: boolean; - readonly destructiveHint?: boolean; - readonly idempotentHint?: boolean; - readonly openWorldHint?: boolean; -}; -type IconV2 = OpenObjectV2 & { - readonly src: string; - readonly mimeType?: string; - readonly sizes?: readonly string[]; - readonly theme?: "light" | "dark"; -}; -export type ContentBlockV2 = OpenObjectV2 & - ( - | { readonly type: "text"; readonly text: string } - | { - readonly type: "image" | "audio"; - readonly data: string; - readonly mimeType: string; - } - | { - readonly type: "resource_link"; - readonly name: string; - readonly uri: string; - } - | { readonly type: "resource"; readonly resource: OpenObjectV2 } - ); - -export type ToolV2 = OpenObjectV2 & { - readonly name: string; - readonly title?: string; - readonly description?: string; - readonly inputSchema: OpenObjectV2 & { - readonly type: "object"; - readonly $schema?: string; - }; - readonly outputSchema?: OpenObjectV2 & { readonly $schema?: string }; - readonly annotations?: ToolAnnotationsV2; - readonly icons?: readonly IconV2[]; - readonly _meta?: OpenObjectV2; -}; -export type RequestIdV2 = string | number; -export type TaskStatusV2 = - "working" | "input_required" | "completed" | "failed" | "cancelled"; - -export type TaskEligibleMethodV2 = "tools/call"; - -export interface TaskV2 { - readonly taskId: string; - readonly status: TaskStatusV2; - readonly statusMessage?: string; - readonly createdAt: string; - readonly lastUpdatedAt: string; - readonly ttlMs: number | null; - readonly pollIntervalMs?: number; -} - -export interface WorkingTaskV2 extends TaskV2 { - readonly status: "working"; -} -export interface InputRequiredTaskV2 extends TaskV2 { - readonly status: "input_required"; - readonly inputRequests: InputRequestsV2; -} -export interface CompletedTaskV2 extends TaskV2 { - readonly status: "completed"; - readonly result: Readonly>; -} -export interface FailedTaskV2 extends TaskV2 { - readonly status: "failed"; - readonly error: ErrorV2; -} -export interface CancelledTaskV2 extends TaskV2 { - readonly status: "cancelled"; -} -export type DetailedTaskV2 = - | WorkingTaskV2 - | InputRequiredTaskV2 - | CompletedTaskV2 - | FailedTaskV2 - | CancelledTaskV2; - -export interface ErrorV2 { - readonly code: number; - readonly message: string; - readonly data?: JsonValue; -} - -export interface CreateMessageRequestV2 { - readonly method: "sampling/createMessage"; - readonly params: Readonly>; -} -export interface ListRootsRequestV2 { - readonly method: "roots/list"; - readonly params?: Readonly>; -} -export interface ElicitRequestV2 { - readonly method: "elicitation/create"; - readonly params: Readonly>; -} -export type InputRequestV2 = - CreateMessageRequestV2 | ListRootsRequestV2 | ElicitRequestV2; -export type InputRequestsV2 = Readonly>; - -export interface CreateMessageResultV2 extends Readonly< - Record -> { - readonly content: JsonValue; - readonly model: string; - readonly role: "user" | "assistant"; -} -export interface ListRootsResultV2 extends Readonly> { - readonly roots: readonly JsonValue[]; -} -export interface ElicitResultV2 extends Readonly> { - readonly action: "accept" | "decline" | "cancel"; -} -export type InputResponseV2 = - CreateMessageResultV2 | ListRootsResultV2 | ElicitResultV2; -export type InputResponsesV2 = Readonly>; - -export interface CreateTaskResultV2 extends TaskV2 { - readonly resultType: "task"; - readonly _meta?: Readonly>; -} -export type CallToolResultV2 = OpenObjectV2 & { - readonly resultType: string; - readonly content: readonly ContentBlockV2[]; - readonly structuredContent?: JsonValue; - readonly isError?: boolean; - readonly _meta?: OpenObjectV2; -}; - -interface JsonRpcRequestV2 { - readonly jsonrpc: "2.0"; - readonly id: RequestIdV2; -} -export interface GetTaskRequestV2 extends JsonRpcRequestV2 { - readonly method: "tasks/get"; - readonly params: { readonly taskId: string }; -} -export interface UpdateTaskRequestV2 extends JsonRpcRequestV2 { - readonly method: "tasks/update"; - readonly params: { - readonly taskId: string; - readonly inputResponses: InputResponsesV2; - }; -} -export interface CancelTaskRequestV2 extends JsonRpcRequestV2 { - readonly method: "tasks/cancel"; - readonly params: { readonly taskId: string }; -} -export type GetTaskResultV2 = DetailedTaskV2 & { - readonly resultType: "complete"; - readonly _meta?: Readonly>; -}; -export interface UpdateTaskResultV2 extends Readonly< - Record -> { - readonly resultType: "complete"; -} -export interface CancelTaskResultV2 extends Readonly< - Record -> { - readonly resultType: "complete"; -} - -export type TaskStatusNotificationParamsV2 = DetailedTaskV2 & { - readonly _meta?: Readonly>; -}; -export interface TaskStatusNotificationV2 { - readonly jsonrpc: "2.0"; - readonly method: "notifications/tasks"; - readonly params: TaskStatusNotificationParamsV2; -} -export interface TaskSubscriptionNotificationsV2 { - readonly taskIds?: readonly string[]; -} -export interface TaskSubscriptionAcknowledgedNotificationsV2 { - readonly taskIds?: readonly string[]; -} -export type TasksExtensionCapabilityV2 = Readonly>; - -export interface ClientTaskCapabilityEnvelopeV2 { - readonly extensions: { - readonly [TASKS_EXTENSION_ID_V2]: TasksExtensionCapabilityV2; - }; -} -export interface ServerTaskCapabilityEnvelopeV2 { - readonly extensions?: Readonly>; -} diff --git a/packages/ext-tasks/test-support/client/fake-port.ts b/packages/ext-tasks/test-support/client/fake-port.ts index 420650d..639a3cb 100644 --- a/packages/ext-tasks/test-support/client/fake-port.ts +++ b/packages/ext-tasks/test-support/client/fake-port.ts @@ -1,4 +1,5 @@ import { type JsonValue } from "../../src/core/index.js"; +import { z } from "zod/v4"; import { type ConnectedMcpSessionPort, type IncomingServerRequest, @@ -9,6 +10,11 @@ import { export const asJson = (value: unknown): JsonValue => JSON.parse(JSON.stringify(value)) as JsonValue; +const JsonRecordSchema = z.record(z.string(), z.unknown()); + +export const expectRecord = (value: unknown): Record => + JsonRecordSchema.parse(value); + export const formatJson = (value: unknown): string => JSON.stringify(value) ?? "undefined"; diff --git a/packages/ext-tasks/tsconfig.json b/packages/ext-tasks/tsconfig.json index 910154d..06215f4 100644 --- a/packages/ext-tasks/tsconfig.json +++ b/packages/ext-tasks/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.package.json", "compilerOptions": { + "isolatedDeclarations": false, "outDir": "dist", "rootDir": "src", "tsBuildInfoFile": "node_modules/.cache/ext-tasks.tsbuildinfo" From 8392dfb2c83c04040a3a92486a3e831fdf9748b3 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Sat, 5 Sep 2026 17:01:22 -0700 Subject: [PATCH 15/33] chore: enable strict TypeScript linting - Enable the strict type-checked typescript-eslint preset - Resolve all unsafe, redundant, and confusing typed lint findings - Strengthen runtime boundary checks and race/property tests --- packages/ext-tasks/eslint.config.mjs | 2 +- .../src/client/client-adapter.test.ts | 10 ++-- .../client/declarations-capabilities.test.ts | 8 ++- packages/ext-tasks/src/client/execution.ts | 45 +++++++++------ .../client/immediate-session-basics.test.ts | 4 +- .../ext-tasks/src/client/input-routing.ts | 4 +- packages/ext-tasks/src/client/port.ts | 15 +++-- .../src/client/sdk-client-adapter.ts | 5 +- packages/ext-tasks/src/client/session.ts | 57 ++++++++++++------- .../src/client/task-lifecycle-races.test.ts | 43 +++++++++----- .../ext-tasks/src/client/task-protocol-v2.ts | 12 +--- .../src/client/task-resumption.test.ts | 4 +- .../ext-tasks/src/client/tool-declarations.ts | 7 ++- .../src/client/v1-input-task.test.ts | 14 +++-- .../src/client/v2-input-task.test.ts | 4 +- packages/ext-tasks/src/core/index.test.ts | 4 +- packages/ext-tasks/src/core/index.ts | 2 +- packages/ext-tasks/src/core/v1/index.test.ts | 43 ++++++++------ packages/ext-tasks/src/core/v2/index.test.ts | 5 +- packages/ext-tasks/src/core/v2/integration.ts | 10 ++-- packages/ext-tasks/src/core/v2/schemas.ts | 16 +++++- .../test-support/client/fake-port.ts | 6 +- 22 files changed, 202 insertions(+), 118 deletions(-) diff --git a/packages/ext-tasks/eslint.config.mjs b/packages/ext-tasks/eslint.config.mjs index 9b83e1b..09b3402 100644 --- a/packages/ext-tasks/eslint.config.mjs +++ b/packages/ext-tasks/eslint.config.mjs @@ -8,7 +8,7 @@ export default tseslint.config( ignores: ["dist/**", "coverage/**", "schema/**"], }, eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, + ...tseslint.configs.strictTypeChecked, { files: ["src/**/*.ts", "test-support/**/*.ts", "vitest.config.ts"], plugins: { jsdoc }, diff --git a/packages/ext-tasks/src/client/client-adapter.test.ts b/packages/ext-tasks/src/client/client-adapter.test.ts index 4cbe59b..c6c1f9f 100644 --- a/packages/ext-tasks/src/client/client-adapter.test.ts +++ b/packages/ext-tasks/src/client/client-adapter.test.ts @@ -170,9 +170,9 @@ describe("Client adapter", () => { invalidations.push(reason), ); await expect( - installedRequest?.({ jsonrpc: "2.0", id: 1, method: "other" }, context), + installedRequest({ jsonrpc: "2.0", id: 1, method: "other" }, context), ).resolves.toEqual({ prior: true }); - await installedNotification?.({ + await installedNotification({ method: "custom/notification", params: { value: 1 }, }); @@ -181,12 +181,12 @@ describe("Client adapter", () => { { method: "custom/notification", params: { value: 1 } }, ]); removeNotification(); - await installedNotification?.({ + await installedNotification({ method: "custom/notification", params: { value: 2 }, }); expect(notifications).toHaveLength(1); - installedClose?.(); + installedClose(); expect(priorClose).toHaveBeenCalledOnce(); expect(port.invalidated).toBe(true); expect(invalidations).toHaveLength(1); @@ -258,7 +258,7 @@ describe("Client adapter", () => { expect.any(Object), ); await expect( - sdk.fallbackRequestHandler?.( + sdk.fallbackRequestHandler( { jsonrpc: "2.0", id: 9, method: "custom/unrelated" }, context, ), diff --git a/packages/ext-tasks/src/client/declarations-capabilities.test.ts b/packages/ext-tasks/src/client/declarations-capabilities.test.ts index dd3e231..521543d 100644 --- a/packages/ext-tasks/src/client/declarations-capabilities.test.ts +++ b/packages/ext-tasks/src/client/declarations-capabilities.test.ts @@ -223,7 +223,9 @@ describe("declarations and capabilities", () => { new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(new DOMException("discovery aborted", "AbortError")), + () => { + reject(new DOMException("discovery aborted", "AbortError")); + }, { once: true }, ); }); @@ -244,7 +246,9 @@ describe("declarations and capabilities", () => { refreshSignal = options?.signal; options?.signal?.addEventListener( "abort", - () => reject(new DOMException("closed", "AbortError")), + () => { + reject(new DOMException("closed", "AbortError")); + }, { once: true }, ); }); diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts index f716639..61141fb 100644 --- a/packages/ext-tasks/src/client/execution.ts +++ b/packages/ext-tasks/src/client/execution.ts @@ -96,12 +96,16 @@ export class TaskExecution< this.inputController.abort(); this.lastAcceptedBytes = deterministicJson(initialSnapshot); if (lifecycleSignal !== undefined) { - const abort = (): void => this.controller.abort(lifecycleSignal.reason); + const abort = (): void => { + this.controller.abort(lifecycleSignal.reason); + }; if (lifecycleSignal.aborted) abort(); else lifecycleSignal.addEventListener("abort", abort, { once: true }); } this.resultPromise = driver( - (snapshot) => this.accept(snapshot), + (snapshot) => { + this.accept(snapshot); + }, (afterSequence, delayMs) => this.waitForTurn(afterSequence, delayMs), (afterSequence, observation) => this.observeOrNotification(afterSequence, observation), @@ -147,7 +151,7 @@ export class TaskExecution< private async *iterateUpdates( signal?: AbortSignal, ): AsyncIterable { - while (true) { + for (;;) { throwIfAborted(signal); if (this.initialSnapshot !== undefined) { const snapshot = this.initialSnapshot; @@ -168,13 +172,7 @@ export class TaskExecution< continue; } const settled = await this.waitForUpdateOrResult(signal); - if ( - !settled && - this.initialSnapshot === undefined && - this.pendingSnapshot === undefined && - this.terminalSnapshot === undefined - ) - return; + if (!settled) return; } } @@ -204,7 +202,9 @@ export class TaskExecution< return true; let wake: (() => void) | undefined; const updated = new Promise((resolve) => { - wake = () => resolve(true); + wake = () => { + resolve(true); + }; this.updateWaiters.add(wake); }); try { @@ -255,8 +255,12 @@ export class TaskExecution< if (error === undefined) resolve(); else reject(reasonAsError(error)); }; - const onNotification = (): void => finish(); - const onAbort = (): void => finish(this.controller.signal.reason); + const onNotification = (): void => { + finish(); + }; + const onAbort = (): void => { + finish(this.controller.signal.reason); + }; const timeout = setTimeout(onNotification, Math.max(0, delayMs)); this.notificationWaiters.add(onNotification); this.controller.signal.addEventListener("abort", onAbort, { once: true }); @@ -275,7 +279,9 @@ export class TaskExecution< void observationPromise.catch(() => {}); let wake: (() => void) | undefined; const notified = new Promise((resolve) => { - wake = () => resolve(this.currentTurn(afterSequence)); + wake = () => { + resolve(this.currentTurn(afterSequence)); + }; this.notificationWaiters.add(wake); }); try { @@ -335,10 +341,15 @@ export class TaskExecution< /** Produces stable JSON-like text by sorting object keys recursively. */ export function deterministicJson(value: unknown): string { - if (value === null || typeof value !== "object") { - const encoded = JSON.stringify(value); - return encoded ?? `[${typeof value}]`; + if (value === null) return "null"; + if ( + typeof value === "string" || + typeof value === "boolean" || + typeof value === "number" + ) { + return JSON.stringify(value); } + if (typeof value !== "object") return `[${typeof value}]`; if (Array.isArray(value)) return `[${value.map(deterministicJson).join(",")}]`; const record = value as Readonly>; diff --git a/packages/ext-tasks/src/client/immediate-session-basics.test.ts b/packages/ext-tasks/src/client/immediate-session-basics.test.ts index ba5ecbd..474b25d 100644 --- a/packages/ext-tasks/src/client/immediate-session-basics.test.ts +++ b/packages/ext-tasks/src/client/immediate-session-basics.test.ts @@ -120,7 +120,9 @@ describe("immediate and session basics", () => { new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(new DOMException("invalidated", "AbortError")), + () => { + reject(new DOMException("invalidated", "AbortError")); + }, { once: true }, ); }); diff --git a/packages/ext-tasks/src/client/input-routing.ts b/packages/ext-tasks/src/client/input-routing.ts index ba40fd3..deeeedc 100644 --- a/packages/ext-tasks/src/client/input-routing.ts +++ b/packages/ext-tasks/src/client/input-routing.ts @@ -5,7 +5,7 @@ import type { IncomingServerRequest, JsonRpcResponse } from "./port.js"; export function requestParams( request: Readonly>, ): Readonly> { - if (request.params === undefined) return {}; + if (!Object.hasOwn(request, "params")) return {}; if ( request.params === null || Array.isArray(request.params) || @@ -39,7 +39,7 @@ let nextExecutionId = 0; /** Allocates a process-local identifier for an ordinary tool execution. */ export function nextExecutionIdentifier(): string { - return `execution-${++nextExecutionId}`; + return `execution-${String(++nextExecutionId)}`; } /** Returns the conservative fallback response for an unhandled server request. */ diff --git a/packages/ext-tasks/src/client/port.ts b/packages/ext-tasks/src/client/port.ts index e9d8eb5..2f8bb7e 100644 --- a/packages/ext-tasks/src/client/port.ts +++ b/packages/ext-tasks/src/client/port.ts @@ -58,12 +58,13 @@ export async function withAbort( throwIfAborted(signal); let onAbort: (() => void) | undefined; const aborted = new Promise((_, reject) => { - onAbort = () => + onAbort = () => { reject( signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"), ); + }; signal.addEventListener("abort", onAbort, { once: true }); }); try { @@ -85,17 +86,23 @@ export function linkAbortSignals( const listeners: (() => void)[] = []; for (const signal of signals) { if (signal === undefined) continue; - const abort = (): void => controller.abort(signal.reason); + const abort = (): void => { + controller.abort(signal.reason); + }; if (signal.aborted) { abort(); break; } signal.addEventListener("abort", abort, { once: true }); - listeners.push(() => signal.removeEventListener("abort", abort)); + listeners.push(() => { + signal.removeEventListener("abort", abort); + }); } return { signal: controller.signal, - abort: (reason) => controller.abort(reason), + abort: (reason) => { + controller.abort(reason); + }, dispose: () => { for (const remove of listeners) remove(); }, diff --git a/packages/ext-tasks/src/client/sdk-client-adapter.ts b/packages/ext-tasks/src/client/sdk-client-adapter.ts index d58b2b0..109e190 100644 --- a/packages/ext-tasks/src/client/sdk-client-adapter.ts +++ b/packages/ext-tasks/src/client/sdk-client-adapter.ts @@ -43,7 +43,6 @@ function clientTaskCapabilities( const extension = capabilities?.extensions?.["io.modelcontextprotocol/tasks"]; if ( - extension !== null && typeof extension === "object" && !Array.isArray(extension) && Object.keys(extension).length === 0 @@ -67,7 +66,7 @@ function asClientRequest(request: JsonValue): { if (typeof method !== "string") throw new DispatchError("MCP request method must be a string"); const params = request.params; - if (params === undefined) return { method }; + if (!Object.hasOwn(request, "params")) return { method }; if (!isJsonRecord(params)) throw new DispatchError("MCP request params must be a JSON object"); return { method, params }; @@ -91,7 +90,7 @@ type ClientPublicSurface = Pick< | "onclose" >; -const adaptedClients = new WeakSet(); +const adaptedClients = new WeakSet(); /** Returns whether a value implements the connected MCP session port contract. */ export function isConnectedMcpSessionPort( diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index 506ff07..8399eef 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -70,6 +70,12 @@ import { ManagedToolDeclarations } from "./tool-declarations.js"; import { createTaskExecutionV1 } from "./task-protocol-v1.js"; import { createTaskExecutionV2 } from "./task-protocol-v2.js"; +function isSupportedTaskReferenceOperation(reference: { + readonly originalOperation: unknown; +}): boolean { + return reference.originalOperation === "tools/call"; +} + class PortTaskEnabledSession< TApplicationContext, > implements TaskEnabledSession { @@ -104,11 +110,13 @@ class PortTaskEnabledSession< console.error(sinkError); } }; - this.managedDeclarations = - options.tools === undefined - ? new ManagedToolDeclarations(port, reportError) - : undefined; - this.declarations = options.tools ?? this.managedDeclarations!; + if (options.tools === undefined) { + this.managedDeclarations = new ManagedToolDeclarations(port, reportError); + this.declarations = this.managedDeclarations; + } else { + this.managedDeclarations = undefined; + this.declarations = options.tools; + } const onSessionAbort = (): void => { const error = options.signal?.reason instanceof Error @@ -123,9 +131,9 @@ class PortTaskEnabledSession< port.onServerRequest(async (incoming) => this.handleServerRequest(incoming), ), - port.onNotification((notification) => - this.handleNotification(notification), - ), + port.onNotification((notification) => { + this.handleNotification(notification); + }), port.onInvalidated((reason) => { const error = reasonAsError(reason); this.invalidationError ??= error; @@ -222,8 +230,9 @@ class PortTaskEnabledSession< response = await withAbort(dispatchPromise, callSignal); } catch (error) { void dispatchPromise.then( - (lateResponse) => - this.cleanupLateTaskCreation(lateResponse, generation, callAsTaskV1), + (lateResponse) => { + this.cleanupLateTaskCreation(lateResponse, generation, callAsTaskV1); + }, () => {}, ); throw error; @@ -285,7 +294,9 @@ class PortTaskEnabledSession< port: this.port, lifecycleSignal: this.lifecycleController.signal, onInputRequest: this.options.onInputRequest, - reportError: (error) => this.reportBackgroundError(error), + reportError: (error) => { + this.reportBackgroundError(error); + }, }), ); } @@ -311,7 +322,7 @@ class PortTaskEnabledSession< throw new Error("Task reference belongs to a different endpoint"); if (reference.generation !== capabilities.generation) throw new Error("Task reference generation does not match this session"); - if (reference.originalOperation !== "tools/call") + if (!isSupportedTaskReferenceOperation(reference)) throw new Error("Task reference operation is not supported"); const resumeLifecycle = linkAbortSignals( @@ -375,7 +386,9 @@ class PortTaskEnabledSession< port: this.port, lifecycleSignal: this.lifecycleController.signal, onInputRequest: this.options.onInputRequest, - reportError: (error) => this.reportBackgroundError(error), + reportError: (error) => { + this.reportBackgroundError(error); + }, }), ); } finally { @@ -457,7 +470,9 @@ class PortTaskEnabledSession< ); v1InputCandidate.signal?.addEventListener( "abort", - () => this.v1TaskInputCandidates.delete(v1InputCandidate.executionId), + () => { + this.v1TaskInputCandidates.delete(v1InputCandidate.executionId); + }, { once: true }, ); } @@ -523,9 +538,9 @@ class PortTaskEnabledSession< : method === "roots/list" ? { kind: "roots", - ...(wire.params === undefined - ? {} - : { params: requestParams(wire) }), + ...(Object.hasOwn(wire, "params") + ? { params: requestParams(wire) } + : {}), } : undefined; if (request === undefined) return defaultServerRequestResponse(incoming); @@ -542,7 +557,7 @@ class PortTaskEnabledSession< const relatedTask = (meta as Readonly>)[ relatedTaskKey ]; - if (relatedTask === undefined) evidence = "absent"; + if (!Object.hasOwn(meta, relatedTaskKey)) evidence = "absent"; else if ( relatedTask === null || Array.isArray(relatedTask) || @@ -667,9 +682,9 @@ export function withTasks( throw new TypeError("withTasks(Client) requires options.endpointId"); const port = new ClientSessionPort(session, endpointId); try { - return new PortTaskEnabledSession(port, options, () => - port[Symbol.dispose](), - ); + return new PortTaskEnabledSession(port, options, () => { + port[Symbol.dispose](); + }); } catch (error) { port[Symbol.dispose](); throw error; diff --git a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts index bfd5fb3..66e5282 100644 --- a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -42,7 +42,9 @@ describe("task lifecycle and races", () => { return new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(asError(options.signal?.reason)), + () => { + reject(asError(options.signal?.reason)); + }, { once: true }, ); }); @@ -105,7 +107,9 @@ describe("task lifecycle and races", () => { return new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(asError(options.signal?.reason)), + () => { + reject(asError(options.signal?.reason)); + }, { once: true }, ); }); @@ -207,7 +211,9 @@ describe("task lifecycle and races", () => { return new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(asError(options.signal?.reason)), + () => { + reject(asError(options.signal?.reason)); + }, { once: true }, ); }); @@ -478,7 +484,9 @@ describe("task lifecycle and races", () => { return new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(asError(options.signal?.reason)), + () => { + reject(asError(options.signal?.reason)); + }, { once: true }, ); }); @@ -518,7 +526,9 @@ describe("task lifecycle and races", () => { return new Promise((_resolve, reject) => { options?.signal?.addEventListener( "abort", - () => reject(asError(options.signal?.reason)), + () => { + reject(asError(options.signal?.reason)); + }, { once: true }, ); }); @@ -539,8 +549,11 @@ describe("task lifecycle and races", () => { it("a terminal notification preempts an in-flight observation", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); - let getStarted = false; let observationSignal: AbortSignal | undefined; + let markGetStarted = (): void => {}; + const getStarted = new Promise((resolve) => { + markGetStarted = resolve; + }); port.dispatchHandler = async (request, options) => { const record = expectRecord(request); if (record.method === "tools/call") @@ -557,20 +570,22 @@ describe("task lifecycle and races", () => { }), }; if (record.method === "tasks/get") { - getStarted = true; const signal = options?.signal; if (signal === undefined) throw new Error("observation signal is required"); observationSignal = signal; - return new Promise((_resolve, reject) => + return new Promise((_resolve, reject) => { signal.addEventListener( "abort", - () => reject(asError(signal.reason)), + () => { + reject(asError(signal.reason)); + }, { once: true, }, - ), - ); + ); + markGetStarted(); + }); } if (record.method === "tasks/cancel") return { kind: "result", result: { resultType: "complete" } }; @@ -582,7 +597,7 @@ describe("task lifecycle and races", () => { }, }); const execution = await session.callTool("x"); - while (!getStarted) await new Promise((resolve) => setTimeout(resolve, 1)); + await getStarted; port.notify( asJson({ jsonrpc: "2.0", @@ -628,7 +643,9 @@ describe("task lifecycle and races", () => { return new Promise((_resolve, reject) => options?.signal?.addEventListener( "abort", - () => reject(asError(options.signal?.reason)), + () => { + reject(asError(options.signal?.reason)); + }, { once: true }, ), ); diff --git a/packages/ext-tasks/src/client/task-protocol-v2.ts b/packages/ext-tasks/src/client/task-protocol-v2.ts index 754cce9..daaa507 100644 --- a/packages/ext-tasks/src/client/task-protocol-v2.ts +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -192,7 +192,7 @@ async function acquireInputs( } acquiredInputs.set(inputKey, signature); const request: InputRequestV2 = inputRequest; - const projected: ApplicationInputRequest | undefined = + const projected: ApplicationInputRequest = request.method === "sampling/createMessage" ? { kind: "sampling", params: request.params } : request.method === "roots/list" @@ -202,15 +202,7 @@ async function acquireInputs( ? {} : { params: request.params }), } - : request.method === "elicitation/create" - ? { kind: "elicitation", params: request.params } - : undefined; - if (projected === undefined) { - options.reportError( - new Error(`Unknown V2 task input method for key ${inputKey}`), - ); - continue; - } + : { kind: "elicitation", params: request.params }; let result: unknown; if (options.onInputRequest === undefined) { if (request.method !== "elicitation/create") continue; diff --git a/packages/ext-tasks/src/client/task-resumption.test.ts b/packages/ext-tasks/src/client/task-resumption.test.ts index 22040d8..803fb03 100644 --- a/packages/ext-tasks/src/client/task-resumption.test.ts +++ b/packages/ext-tasks/src/client/task-resumption.test.ts @@ -87,7 +87,9 @@ describe("task reference resumption", () => { return new Promise((_resolve, reject) => options?.signal?.addEventListener( "abort", - () => reject(asError(options.signal?.reason)), + () => { + reject(asError(options.signal?.reason)); + }, { once: true }, ), ); diff --git a/packages/ext-tasks/src/client/tool-declarations.ts b/packages/ext-tasks/src/client/tool-declarations.ts index 6899889..2c8988e 100644 --- a/packages/ext-tasks/src/client/tool-declarations.ts +++ b/packages/ext-tasks/src/client/tool-declarations.ts @@ -6,8 +6,8 @@ import { JsonRpcResponseError, type ToolDeclarationProvider } from "./api.js"; import type { ConnectedMcpSessionPort } from "./port.js"; import { throwIfAborted } from "./input-routing.js"; -const ToolV1Parser = ToolV1Schema as unknown as z.ZodType; -const ToolV2Parser = ToolV2Schema as unknown as z.ZodType; +const ToolV1Parser = ToolV1Schema as unknown as z.ZodType; +const ToolV2Parser = ToolV2Schema as unknown as z.ZodType; export class ManagedToolDeclarations implements ToolDeclarationProvider { private tools = new Map(); @@ -48,12 +48,13 @@ export class ManagedToolDeclarations implements ToolDeclarationProvider { if (signal === undefined) return waiting; let onAbort: (() => void) | undefined; const aborted = new Promise((_, reject) => { - onAbort = () => + onAbort = () => { reject( signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"), ); + }; signal.addEventListener("abort", onAbort, { once: true }); }); try { diff --git a/packages/ext-tasks/src/client/v1-input-task.test.ts b/packages/ext-tasks/src/client/v1-input-task.test.ts index 20a522d..b27325c 100644 --- a/packages/ext-tasks/src/client/v1-input-task.test.ts +++ b/packages/ext-tasks/src/client/v1-input-task.test.ts @@ -210,7 +210,9 @@ describe("V1 input and task behavior", () => { return new Promise((_resolve, reject) => options?.signal?.addEventListener( "abort", - () => reject(asError(options.signal?.reason)), + () => { + reject(asError(options.signal?.reason)); + }, { once: true }, ), ); @@ -257,7 +259,7 @@ describe("V1 input and task behavior", () => { const executions = await Promise.all( Array.from({ length: candidateCount }, (_, index) => session.callTool(String(index), undefined, { - applicationContext: `context-${index}`, + applicationContext: `context-${String(index)}`, }), ), ); @@ -407,7 +409,9 @@ describe("V1 input and task behavior", () => { return new Promise((_resolve, reject) => options?.signal?.addEventListener( "abort", - () => reject(asError(options.signal?.reason)), + () => { + reject(asError(options.signal?.reason)); + }, { once: true }, ), ); @@ -666,7 +670,9 @@ describe("V1 input and task behavior", () => { return new Promise((_resolve, reject) => options?.signal?.addEventListener( "abort", - () => reject(asError(options.signal?.reason)), + () => { + reject(asError(options.signal?.reason)); + }, { once: true }, ), ); diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts index 0c88c83..e03a9cc 100644 --- a/packages/ext-tasks/src/client/v2-input-task.test.ts +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -418,7 +418,9 @@ describe("V2 input and task behavior", () => { return new Promise((_resolve, reject) => context.signal?.addEventListener( "abort", - () => reject(asError(context.signal?.reason)), + () => { + reject(asError(context.signal?.reason)); + }, { once: true }, ), ); diff --git a/packages/ext-tasks/src/core/index.test.ts b/packages/ext-tasks/src/core/index.test.ts index f9a8db4..9cf0ead 100644 --- a/packages/ext-tasks/src/core/index.test.ts +++ b/packages/ext-tasks/src/core/index.test.ts @@ -43,7 +43,9 @@ describe("core runtime contracts", () => { sparse.length = 1; const cyclic: Record = {}; cyclic.self = cyclic; - class Exotic {} + class Exotic { + readonly marker = "non-plain"; + } const nonJsonValues: readonly unknown[] = [ undefined, 1n, diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts index 04205ec..ffc75aa 100644 --- a/packages/ext-tasks/src/core/index.ts +++ b/packages/ext-tasks/src/core/index.ts @@ -27,7 +27,7 @@ export function taskId(value: string): TaskId { * sparse arrays, non-plain objects, and cyclic references. */ export function isJsonValue(value: unknown): value is JsonValue { - const visiting = new WeakSet(); + const visiting = new WeakSet(); const visit = (candidate: unknown): boolean => { if ( candidate === null || diff --git a/packages/ext-tasks/src/core/v1/index.test.ts b/packages/ext-tasks/src/core/v1/index.test.ts index 8059ff4..f13d717 100644 --- a/packages/ext-tasks/src/core/v1/index.test.ts +++ b/packages/ext-tasks/src/core/v1/index.test.ts @@ -71,7 +71,9 @@ function expectRoundTrip(schema: Schema, value: unknown): void { describe("V1 Zod wire schemas", () => { it("accepts every Task output and rejects missing fields, null exceptions, fractions, and statuses", () => { fc.assert( - fc.property(taskArb, (task) => expectRoundTrip(TaskV1Schema, task)), + fc.property(taskArb, (task) => { + expectRoundTrip(TaskV1Schema, task); + }), ); fc.assert( fc.property( @@ -84,8 +86,9 @@ describe("V1 Zod wire schemas", () => { "ttl", ), (task, key) => { - const invalid = { ...task }; - delete invalid[key]; + const invalid = Object.fromEntries( + Object.entries(task).filter(([candidate]) => candidate !== key), + ); expect(TaskV1Schema.safeParse(invalid).success).toBe(false); }, ), @@ -160,8 +163,11 @@ describe("V1 Zod wire schemas", () => { schema.safeParse({ ...request, method: "tasks/nope" }).success, ).toBe(false); for (const key of ["jsonrpc", "id", "method", "params"] as const) { - const invalid = { ...request }; - delete invalid[key]; + const invalid = Object.fromEntries( + Object.entries(request).filter( + ([candidate]) => candidate !== key, + ), + ); expect(schema.safeParse(invalid).success).toBe(false); } expect(schema.safeParse({ ...request, params: {} }).success).toBe( @@ -203,30 +209,32 @@ describe("V1 Zod wire schemas", () => { fc.property( fc.array(taskArb), fc.option(fc.string(), { nil: undefined }), - (tasks, nextCursor) => + (tasks, nextCursor) => { expectRoundTrip(ListTasksResultV1Schema, { tasks, ...(nextCursor === undefined ? {} : { nextCursor }), - }), + }); + }, ), ); fc.assert( fc.property( idArb, fc.option(fc.string(), { nil: undefined }), - (id, cursor) => + (id, cursor) => { expectRoundTrip(ListTasksRequestV1Schema, { jsonrpc: "2.0", id, method: "tasks/list", ...(cursor === undefined ? {} : { params: { cursor } }), - }), + }); + }, ), ); fc.assert( - fc.property(jsonRecordArb, (result) => - expectRoundTrip(TaskResultV1Schema, result), - ), + fc.property(jsonRecordArb, (result) => { + expectRoundTrip(TaskResultV1Schema, result); + }), ); }); @@ -315,7 +323,7 @@ describe("V1 Zod wire schemas", () => { }), jsonRecordArb, fc.array(jsonRecordArb), - (name, taskSupport, metadata, icons) => + (name, taskSupport, metadata, icons) => { expectRoundTrip(ToolV1Schema, { name, title: "title", @@ -328,7 +336,8 @@ describe("V1 Zod wire schemas", () => { annotations: metadata, icons, _meta: metadata, - }), + }); + }, ), ); expect(ToolV1Schema.safeParse({ name: "x", inputSchema: {} }).success).toBe( @@ -342,14 +351,14 @@ describe("V1 Zod wire schemas", () => { }).success, ).toBe(false); fc.assert( - fc.property(idArb, fc.string(), jsonRecordArb, (id, name, args) => + fc.property(idArb, fc.string(), jsonRecordArb, (id, name, args) => { expectRoundTrip(CallToolRequestV1Schema, { jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args, task: {} }, - }), - ), + }); + }), ); expectRoundTrip(ServerTaskCapabilitiesV1Schema, { list: {}, diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts index e09d113..5b3438c 100644 --- a/packages/ext-tasks/src/core/v2/index.test.ts +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -73,8 +73,9 @@ describe("V2 runtime wire contracts", () => { "ttlMs", ), (task, key) => { - const invalid = { ...task }; - delete invalid[key]; + const invalid = Object.fromEntries( + Object.entries(task).filter(([candidate]) => candidate !== key), + ); expect(TaskV2Schema.safeParse(asJson(invalid)).success).toBe(false); }, ), diff --git a/packages/ext-tasks/src/core/v2/integration.ts b/packages/ext-tasks/src/core/v2/integration.ts index a1471a1..a2506ae 100644 --- a/packages/ext-tasks/src/core/v2/integration.ts +++ b/packages/ext-tasks/src/core/v2/integration.ts @@ -1,4 +1,5 @@ /** MCP Tasks V2 guards, capability integration, and subscription helpers. */ +import type { z } from "zod/v4"; import { type JsonValue } from "../index.js"; import { CreateTaskResultV2Schema, @@ -21,10 +22,7 @@ import { type UpdateTaskRequestV2, type CancelTaskRequestV2, } from "./schemas.js"; -function parsed( - schema: { safeParse(value: unknown): { success: boolean } }, - value: unknown, -): value is T { +function parsed(schema: z.ZodType, value: unknown): value is T { return schema.safeParse(value).success; } export const isTaskV2: (value: unknown) => value is TaskV2 = ( @@ -125,11 +123,11 @@ export function withTaskCapabilityV2< T extends Readonly>, >(params: T): T & Readonly> { const wireMeta = params._meta; - const base = + const base: Readonly> = wireMeta !== null && typeof wireMeta === "object" && !Array.isArray(wireMeta) - ? wireMeta + ? (wireMeta as Readonly>) : {}; const capability = { extensions: { [TASKS_EXTENSION_ID_V2]: {} } }; return { diff --git a/packages/ext-tasks/src/core/v2/schemas.ts b/packages/ext-tasks/src/core/v2/schemas.ts index 363b4ea..8660006 100644 --- a/packages/ext-tasks/src/core/v2/schemas.ts +++ b/packages/ext-tasks/src/core/v2/schemas.ts @@ -18,9 +18,23 @@ const JsonObjectSchema = z.custom>>( const MetaSchema = JsonObjectSchema; const openObject = (shape: T) => { const validator = z.object(shape).catchall(JsonValueSchema); + const declaredKeys = new Set(Object.keys(shape)); return z.unknown().transform((value, context) => { const parsed = validator.safeParse(value); - if (parsed.success) return parsed.data; + if (parsed.success) { + const data = parsed.data; + const source = value as Readonly>; + for (const key of Object.keys(source)) { + if (declaredKeys.has(key)) continue; + Object.defineProperty(data, key, { + configurable: true, + enumerable: true, + value: source[key], + writable: true, + }); + } + return data; + } for (const issue of parsed.error.issues) context.addIssue({ code: "custom", diff --git a/packages/ext-tasks/test-support/client/fake-port.ts b/packages/ext-tasks/test-support/client/fake-port.ts index 639a3cb..f74fc99 100644 --- a/packages/ext-tasks/test-support/client/fake-port.ts +++ b/packages/ext-tasks/test-support/client/fake-port.ts @@ -15,8 +15,10 @@ const JsonRecordSchema = z.record(z.string(), z.unknown()); export const expectRecord = (value: unknown): Record => JsonRecordSchema.parse(value); -export const formatJson = (value: unknown): string => - JSON.stringify(value) ?? "undefined"; +export const formatJson = (value: unknown): string => { + const encoded: unknown = JSON.stringify(value); + return typeof encoded === "string" ? encoded : "undefined"; +}; export const asError = (reason: unknown): Error => reason instanceof Error ? reason : new Error(formatJson(reason)); From 130318c354a8f552b0011ddcab224f5f60d40637 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Sat, 5 Sep 2026 17:41:47 -0700 Subject: [PATCH 16/33] chore: validate all pushes and pull requests --- .github/workflows/ci.yml | 28 ++++++++++++++++++++-------- .github/workflows/deploy.yml | 7 ++++--- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73b5c3a..995214d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,18 +2,30 @@ name: CI on: push: - branches: [main] pull_request: - branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - check-schema: + validate: + name: Validate repository runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 with: node-version: "24" - - run: npm ci - - run: npm run check - - run: npm run docs:build + cache: npm + - name: Install dependencies + run: npm ci + - name: Check schemas, package, and packed consumers + run: npm run check + - name: Build documentation + run: npm run docs:build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a8528ba..974d320 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,21 +16,22 @@ concurrency: jobs: build: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: npm - name: Setup Pages uses: actions/configure-pages@v4 - name: Install dependencies run: npm ci + - name: Validate repository + run: npm run check - name: Build with VitePress run: npm run docs:build - name: Upload artifact From dc94c50563aca78a7137ace19022bc9cdf735f05 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Sat, 5 Sep 2026 18:03:09 -0700 Subject: [PATCH 17/33] refactor: clarify task routing and lifecycle flows - Extract input projection, correlation, and context utilities - Decompose V2 input resolution and schema construction - Clarify task update queues and close a notification startup race --- packages/ext-tasks/src/client/execution.ts | 81 +++++---- .../ext-tasks/src/client/input-routing.ts | 143 +++++++++++++++- packages/ext-tasks/src/client/session.ts | 115 ++----------- .../src/client/task-lifecycle-races.test.ts | 67 ++++++++ .../ext-tasks/src/client/task-protocol-v2.ts | 161 +++++++++++------- .../src/client/v2-input-task.test.ts | 5 + packages/ext-tasks/src/core/v2/integration.ts | 96 +++++------ packages/ext-tasks/src/core/v2/schemas.ts | 135 ++++++++------- 8 files changed, 491 insertions(+), 312 deletions(-) diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts index 61141fb..14111cc 100644 --- a/packages/ext-tasks/src/client/execution.ts +++ b/packages/ext-tasks/src/client/execution.ts @@ -41,6 +41,11 @@ export const DEFAULT_TASK_POLL_INTERVAL_MS = 10; type TaskTurn = { readonly sequence: number; readonly snapshot: TaskSnapshot } | undefined; +function wakeAll(waiters: Set<() => void>): void { + for (const wake of waiters) wake(); + waiters.clear(); +} + export type TaskDriver = ( accept: (snapshot: TaskSnapshot) => void, waitForTurn: ( @@ -104,11 +109,11 @@ export class TaskExecution< } this.resultPromise = driver( (snapshot) => { - this.accept(snapshot); + this.acceptSnapshot(snapshot); }, (afterSequence, delayMs) => this.waitForTurn(afterSequence, delayMs), (afterSequence, observation) => - this.observeOrNotification(afterSequence, observation), + this.observeUntilNotification(afterSequence, observation), this.controller.signal, this.cancelledError, this.closedError, @@ -136,10 +141,8 @@ export class TaskExecution< } this.latestNotification = snapshot; this.notificationSequence += 1; - for (const wake of this.notificationWaiters) wake(); - this.notificationWaiters.clear(); - for (const wake of this.updateWaiters) wake(); - this.updateWaiters.clear(); + wakeAll(this.notificationWaiters); + wakeAll(this.updateWaiters); } updates(signal?: AbortSignal): AsyncIterable { @@ -153,21 +156,8 @@ export class TaskExecution< ): AsyncIterable { for (;;) { throwIfAborted(signal); - if (this.initialSnapshot !== undefined) { - const snapshot = this.initialSnapshot; - this.initialSnapshot = undefined; - yield snapshot; - continue; - } - if (this.pendingSnapshot !== undefined) { - const snapshot = this.pendingSnapshot; - this.pendingSnapshot = undefined; - yield snapshot; - continue; - } - if (this.terminalSnapshot !== undefined) { - const snapshot = this.terminalSnapshot; - this.terminalSnapshot = undefined; + const snapshot = this.takeQueuedUpdate(); + if (snapshot !== undefined) { yield snapshot; continue; } @@ -176,7 +166,23 @@ export class TaskExecution< } } - private accept(snapshot: TaskSnapshot): void { + private takeQueuedUpdate(): TaskSnapshot | undefined { + if (this.initialSnapshot !== undefined) { + const snapshot = this.initialSnapshot; + this.initialSnapshot = undefined; + return snapshot; + } + if (this.pendingSnapshot !== undefined) { + const snapshot = this.pendingSnapshot; + this.pendingSnapshot = undefined; + return snapshot; + } + const snapshot = this.terminalSnapshot; + this.terminalSnapshot = undefined; + return snapshot; + } + + private acceptSnapshot(snapshot: TaskSnapshot): void { if (this.closed) return; const bytes = deterministicJson(snapshot); if (bytes === this.lastAcceptedBytes) return; @@ -190,8 +196,7 @@ export class TaskExecution< } else if (this.terminalSnapshotBytes === undefined) { this.pendingSnapshot = snapshot; } - for (const wake of this.updateWaiters) wake(); - this.updateWaiters.clear(); + wakeAll(this.updateWaiters); } private async waitForUpdateOrResult(signal?: AbortSignal): Promise { @@ -223,7 +228,7 @@ export class TaskExecution< } } - private currentTurn(afterSequence: number): TaskTurn { + private notificationAfter(afterSequence: number): TaskTurn { if ( this.notificationSequence > afterSequence && this.latestNotification !== undefined @@ -240,12 +245,12 @@ export class TaskExecution< afterSequence: number, delayMs: number | undefined, ): Promise { - const current = this.currentTurn(afterSequence); + const current = this.notificationAfter(afterSequence); if (current !== undefined) return current; if (delayMs === undefined) { await Promise.resolve(); throwIfAborted(this.controller.signal); - return this.currentTurn(afterSequence); + return this.notificationAfter(afterSequence); } await new Promise((resolve, reject) => { const finish = (error?: unknown): void => { @@ -265,26 +270,30 @@ export class TaskExecution< this.notificationWaiters.add(onNotification); this.controller.signal.addEventListener("abort", onAbort, { once: true }); }); - return this.currentTurn(afterSequence); + return this.notificationAfter(afterSequence); } - private async observeOrNotification( + private async observeUntilNotification( afterSequence: number, observation: (signal: AbortSignal) => Promise, ): Promise { - const current = this.currentTurn(afterSequence); - if (current !== undefined) return current; - const observationLifecycle = linkAbortSignals(this.controller.signal); - const observationPromise = observation(observationLifecycle.signal); - void observationPromise.catch(() => {}); let wake: (() => void) | undefined; const notified = new Promise((resolve) => { wake = () => { - resolve(this.currentTurn(afterSequence)); + resolve(this.notificationAfter(afterSequence)); }; this.notificationWaiters.add(wake); }); + const current = this.notificationAfter(afterSequence); + if (current !== undefined) { + if (wake !== undefined) this.notificationWaiters.delete(wake); + return current; + } + + const observationLifecycle = linkAbortSignals(this.controller.signal); try { + const observationPromise = observation(observationLifecycle.signal); + void observationPromise.catch(() => {}); return await withAbort( Promise.race([ observationPromise.then((snapshot) => ({ @@ -360,7 +369,7 @@ export function deterministicJson(value: unknown): string { .join(",")}}`; } -/** Returns whether a V1 task status is terminal. */ +/** Returns whether a task status is terminal. */ export function terminalStatus(status: TaskV1["status"]): boolean { return ( status === "completed" || status === "failed" || status === "cancelled" diff --git a/packages/ext-tasks/src/client/input-routing.ts b/packages/ext-tasks/src/client/input-routing.ts index deeeedc..ec64db3 100644 --- a/packages/ext-tasks/src/client/input-routing.ts +++ b/packages/ext-tasks/src/client/input-routing.ts @@ -1,4 +1,9 @@ import type { JsonValue, TaskGeneration, TaskId } from "../core/index.js"; +import type { + ApplicationInputRequest, + InputCorrelationFailureReason, + ResolvedInputExchangeContext, +} from "./api.js"; import type { IncomingServerRequest, JsonRpcResponse } from "./port.js"; /** Returns object-valued request parameters, defaulting an omitted value to empty. */ @@ -35,6 +40,142 @@ export interface V1TaskInputCandidate { readonly signal?: AbortSignal; } +export type InputCandidate = + | OrdinaryInputCandidate + | V1TaskInputCandidate; + +export type RelatedTaskEvidence = + | { readonly kind: "absent" } + | { readonly kind: "invalid" } + | { readonly kind: "task-id"; readonly taskId: string }; + +export interface InputCandidateProjection { + readonly generation: TaskGeneration; + readonly toolName: string; + readonly executionId: string; + readonly applicationContext: TApplicationContext; +} + +export type InputCandidateResolution = + | { + readonly kind: "resolved"; + readonly candidate: InputCandidate; + } + | { + readonly kind: "failed"; + readonly reason: InputCorrelationFailureReason; + readonly candidates: readonly InputCandidateProjection[]; + }; + +/** Projects a supported wire request into the application input request shape. */ +export function projectApplicationInputRequest( + incoming: IncomingServerRequest, +): ApplicationInputRequest | undefined { + if ( + incoming.request === null || + Array.isArray(incoming.request) || + typeof incoming.request !== "object" + ) { + return undefined; + } + const wire = incoming.request as Readonly>; + if (wire.method === "elicitation/create") { + return { kind: "elicitation", params: requestParams(wire) }; + } + if (wire.method === "sampling/createMessage") { + return { kind: "sampling", params: requestParams(wire) }; + } + if (wire.method === "roots/list") { + return { + kind: "roots", + ...(Object.hasOwn(wire, "params") ? { params: requestParams(wire) } : {}), + }; + } + return undefined; +} + +/** Reads related-task metadata without conflating absence with malformed evidence. */ +export function readRelatedTaskEvidence( + request: ApplicationInputRequest, +): RelatedTaskEvidence { + const meta = request.params?._meta; + const relatedTaskKey = "io.modelcontextprotocol/related-task"; + if (meta === undefined) return { kind: "absent" }; + if (meta === null || Array.isArray(meta) || typeof meta !== "object") + return { kind: "invalid" }; + if (!Object.hasOwn(meta, relatedTaskKey)) return { kind: "absent" }; + const relatedTask = (meta as Readonly>)[ + relatedTaskKey + ]; + if ( + relatedTask === null || + Array.isArray(relatedTask) || + typeof relatedTask !== "object" || + typeof (relatedTask as Readonly>).taskId !== + "string" + ) { + return { kind: "invalid" }; + } + return { + kind: "task-id", + taskId: (relatedTask as Readonly>) + .taskId as string, + }; +} + +/** Resolves the unique input candidate while preserving correlation diagnostics. */ +export function resolveInputCandidate( + evidence: RelatedTaskEvidence, + ordinaryCandidates: readonly OrdinaryInputCandidate[], + taskCandidates: readonly V1TaskInputCandidate[], +): InputCandidateResolution { + const matches: readonly InputCandidate[] = + evidence.kind === "task-id" + ? taskCandidates.filter( + (candidate) => candidate.taskId === evidence.taskId, + ) + : [...ordinaryCandidates, ...taskCandidates]; + let reason: InputCorrelationFailureReason | undefined; + if (evidence.kind === "invalid") reason = "invalid-evidence"; + else if (matches.length > 1) reason = "ambiguous-matches"; + else if (matches.length === 0) { + reason = evidence.kind === "absent" ? "missing-evidence" : "zero-matches"; + } + if (reason !== undefined) { + return { + kind: "failed", + reason, + candidates: matches.map((candidate) => ({ + generation: candidate.generation, + toolName: candidate.toolName, + executionId: candidate.executionId, + applicationContext: candidate.applicationContext, + })), + }; + } + return { kind: "resolved", candidate: matches[0] }; +} + +/** Converts a matched candidate to handler context. */ +export function buildResolvedInputContext( + candidate: InputCandidate, +): ResolvedInputExchangeContext { + if (candidate.lifetime === "task-v1") { + return { + lifetime: "task-v1", + taskId: candidate.taskId, + applicationContext: candidate.applicationContext, + ...(candidate.signal === undefined ? {} : { signal: candidate.signal }), + }; + } + return { + lifetime: "basic", + executionId: candidate.executionId, + applicationContext: candidate.applicationContext, + ...(candidate.signal === undefined ? {} : { signal: candidate.signal }), + }; +} + let nextExecutionId = 0; /** Allocates a process-local identifier for an ordinary tool execution. */ @@ -59,7 +200,7 @@ export function defaultServerRequestResponse( return { kind: "error", error: { code: -32603, message: "Internal error" } }; } -/** Throws the abort reason when a signal has already been aborted. */ +/** Throws if the signal is aborted. */ export function throwIfAborted(signal: AbortSignal | undefined): void { if (signal?.aborted !== true) return; throw signal.reason instanceof Error diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index 8399eef..b84f96c 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -26,9 +26,6 @@ import { } from "../core/v2/index.js"; import { InputCorrelationError, - type ApplicationInputRequest, - type InputCorrelationFailureReason, - type ResolvedInputExchangeContext, type SerializedTaskReference, type TaskEnabledSession, type TaskHandle, @@ -43,9 +40,12 @@ import { reasonAsError, } from "./execution.js"; import { + buildResolvedInputContext, defaultServerRequestResponse, nextExecutionIdentifier, - requestParams, + projectApplicationInputRequest, + readRelatedTaskEvidence, + resolveInputCandidate, throwIfAborted, type OrdinaryInputCandidate, type V1TaskInputCandidate, @@ -521,115 +521,32 @@ class PortTaskEnabledSession< private async handleServerRequest( incoming: IncomingServerRequest, ): Promise { - if ( - incoming.request === null || - Array.isArray(incoming.request) || - typeof incoming.request !== "object" - ) { - return defaultServerRequestResponse(incoming); - } - const wire = incoming.request as Readonly>; - const method = wire.method; - const request: ApplicationInputRequest | undefined = - method === "elicitation/create" - ? { kind: "elicitation", params: requestParams(wire) } - : method === "sampling/createMessage" - ? { kind: "sampling", params: requestParams(wire) } - : method === "roots/list" - ? { - kind: "roots", - ...(Object.hasOwn(wire, "params") - ? { params: requestParams(wire) } - : {}), - } - : undefined; + const request = projectApplicationInputRequest(incoming); if (request === undefined) return defaultServerRequestResponse(incoming); - const taskCandidates = [...this.v1TaskInputCandidates.values()]; - const ordinaryCandidates = [...this.ordinaryInputCandidates.values()]; - const params = request.params; - const meta = params?._meta; - const relatedTaskKey = "io.modelcontextprotocol/related-task"; - let evidence: "absent" | "invalid" | { readonly taskId: string }; - if (meta === undefined) evidence = "absent"; - else if (meta === null || Array.isArray(meta) || typeof meta !== "object") - evidence = "invalid"; - else { - const relatedTask = (meta as Readonly>)[ - relatedTaskKey - ]; - if (!Object.hasOwn(meta, relatedTaskKey)) evidence = "absent"; - else if ( - relatedTask === null || - Array.isArray(relatedTask) || - typeof relatedTask !== "object" || - typeof (relatedTask as Readonly>).taskId !== - "string" - ) - evidence = "invalid"; - else - evidence = { - taskId: (relatedTask as Readonly>) - .taskId as string, - }; - } - const allCandidates = [...ordinaryCandidates, ...taskCandidates]; - const matches = - evidence === "absent" || evidence === "invalid" - ? allCandidates - : taskCandidates.filter( - (candidate) => candidate.taskId === evidence.taskId, - ); - const failureReason: InputCorrelationFailureReason | undefined = - evidence === "invalid" - ? "invalid-evidence" - : evidence === "absent" && matches.length === 0 - ? "missing-evidence" - : matches.length === 0 - ? "zero-matches" - : matches.length > 1 - ? "ambiguous-matches" - : undefined; - if (failureReason !== undefined) { - const candidates = matches.map((candidate) => ({ - generation: candidate.generation, - toolName: candidate.toolName, - executionId: candidate.executionId, - applicationContext: candidate.applicationContext, - })); + + const resolution = resolveInputCandidate( + readRelatedTaskEvidence(request), + [...this.ordinaryInputCandidates.values()], + [...this.v1TaskInputCandidates.values()], + ); + if (resolution.kind === "failed") { this.reportBackgroundError( new InputCorrelationError( this.port.taskCapabilities.generation === "none" ? "v1" : this.port.taskCapabilities.generation, request.kind, - candidates, - failureReason, + resolution.candidates, + resolution.reason, ), ); return defaultServerRequestResponse(incoming); } - const candidate = matches[0]; if (this.options.onInputRequest === undefined) return defaultServerRequestResponse(incoming); + try { - const context: ResolvedInputExchangeContext = - candidate.lifetime === "task-v1" - ? { - lifetime: "task-v1", - taskId: candidate.taskId, - applicationContext: candidate.applicationContext, - ...(candidate.signal === undefined - ? {} - : { signal: candidate.signal }), - } - : { - lifetime: "basic", - executionId: candidate.executionId, - applicationContext: candidate.applicationContext, - ...(candidate.signal === undefined - ? {} - : { signal: candidate.signal }), - }; + const context = buildResolvedInputContext(resolution.candidate); const result = await this.options.onInputRequest(request, context); if (!isJsonValue(result)) throw new Error("Input handler returned a non-JSON value"); diff --git a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts index 66e5282..b3b5c60 100644 --- a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -621,6 +621,73 @@ describe("task lifecycle and races", () => { await session.close(); }); + it("captures a terminal notification emitted during observation startup", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let observationSignal: AbortSignal | undefined; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") { + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "synchronous-notification", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + pollIntervalMs: 10, + }), + }; + } + if (record.method === "tasks/get") { + const signal = options?.signal; + if (signal === undefined) + throw new Error("observation signal is required"); + observationSignal = signal; + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + taskId: "synchronous-notification", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { content: [] }, + }, + }), + ); + return new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + reject(asError(signal.reason)); + }, + { once: true }, + ); + }); + } + if (record.method === "tasks/cancel") { + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(observationSignal?.aborted).toBe(true); + await session.close(); + }); + it("caller abort does not poison the shared cancellation attempt", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); let cancelCalls = 0; diff --git a/packages/ext-tasks/src/client/task-protocol-v2.ts b/packages/ext-tasks/src/client/task-protocol-v2.ts index daaa507..934c813 100644 --- a/packages/ext-tasks/src/client/task-protocol-v2.ts +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -74,7 +74,7 @@ export function createTaskExecutionV2(options: { let notificationSequence = 0; const acquiredInputs = new Map(); if (current !== undefined) - await acquireInputs( + await resolveAndSubmitInputRequests( current, acquiredInputs, inputSignal, @@ -113,7 +113,7 @@ export function createTaskExecutionV2(options: { current = observed.snapshot.task as DetailedTaskV2; status = current.status; if (!isClosed()) accept({ generation: "v2", task: current }); - await acquireInputs( + await resolveAndSubmitInputRequests( current, acquiredInputs, inputSignal, @@ -166,79 +166,120 @@ export function createTaskExecutionV2(options: { ); } -async function acquireInputs( +type InputResolution = { + readonly inputKey: string; + readonly response: InputResponseV2; +}; + +function projectInputRequest(request: InputRequestV2): ApplicationInputRequest { + if (request.method === "sampling/createMessage") + return { kind: "sampling", params: request.params }; + if (request.method === "roots/list") + return { + kind: "roots", + ...(request.params === undefined ? {} : { params: request.params }), + }; + return { kind: "elicitation", params: request.params }; +} + +function responseSchemaForInputRequest( + request: InputRequestV2, +): z.ZodType { + if (request.method === "sampling/createMessage") + return CreateMessageResultV2Schema; + if (request.method === "roots/list") return ListRootsResultV2Schema; + return ElicitResultV2Schema; +} + +async function resolveInputRequest( task: DetailedTaskV2, + inputKey: string, + request: InputRequestV2, acquiredInputs: Map, inputSignal: AbortSignal, - signal: AbortSignal, options: { readonly applicationContext: TApplicationContext; readonly onInputRequest?: ApplicationInputHandler["handle"]; readonly reportError: (error: Error) => void; - readonly port: ConnectedMcpSessionPort; }, -): Promise { - if (task.status !== "input_required") return; - const inputResponses: Record = {}; - for (const [inputKey, inputRequest] of Object.entries(task.inputRequests)) { - const signature = deterministicJson(inputRequest); - const acquiredSignature = acquiredInputs.get(inputKey); - if (acquiredSignature !== undefined) { - if (acquiredSignature !== signature) - options.reportError( - new Error(`V2 task input key ${inputKey} was reused incompatibly`), - ); - continue; - } - acquiredInputs.set(inputKey, signature); - const request: InputRequestV2 = inputRequest; - const projected: ApplicationInputRequest = - request.method === "sampling/createMessage" - ? { kind: "sampling", params: request.params } - : request.method === "roots/list" - ? { - kind: "roots", - ...(request.params === undefined - ? {} - : { params: request.params }), - } - : { kind: "elicitation", params: request.params }; - let result: unknown; - if (options.onInputRequest === undefined) { - if (request.method !== "elicitation/create") continue; - result = { action: "cancel" }; - } else { - try { - result = await options.onInputRequest(projected, { - lifetime: "task-v2", - taskId: task.taskId, - inputKey, - applicationContext: options.applicationContext, - signal: inputSignal, - }); - } catch { - if (inputSignal.aborted) return; - if (request.method !== "elicitation/create") continue; - result = { action: "cancel" }; - } - } - try { - const responseSchema = - request.method === "sampling/createMessage" - ? CreateMessageResultV2Schema - : request.method === "roots/list" - ? ListRootsResultV2Schema - : ElicitResultV2Schema; - inputResponses[inputKey] = parseResult( - responseSchema as z.ZodType, - result as JsonValue, +): Promise { + const signature = deterministicJson(request); + const acquiredSignature = acquiredInputs.get(inputKey); + if (acquiredSignature !== undefined) { + if (acquiredSignature !== signature) + options.reportError( + new Error(`V2 task input key ${inputKey} was reused incompatibly`), ); + return undefined; + } + acquiredInputs.set(inputKey, signature); + + let result: unknown; + if (options.onInputRequest === undefined) { + if (request.method !== "elicitation/create") return undefined; + result = { action: "cancel" }; + } else { + try { + result = await options.onInputRequest(projectInputRequest(request), { + lifetime: "task-v2", + taskId: task.taskId, + inputKey, + applicationContext: options.applicationContext, + signal: inputSignal, + }); } catch (error) { + if (inputSignal.aborted) return undefined; options.reportError( error instanceof Error ? error : new Error(String(error)), ); + if (request.method !== "elicitation/create") return undefined; + result = { action: "cancel" }; } } + + try { + return { + inputKey, + response: parseResult( + responseSchemaForInputRequest(request), + result as JsonValue, + ), + }; + } catch (error) { + options.reportError( + error instanceof Error ? error : new Error(String(error)), + ); + return undefined; + } +} + +async function resolveAndSubmitInputRequests( + task: DetailedTaskV2, + acquiredInputs: Map, + inputSignal: AbortSignal, + signal: AbortSignal, + options: { + readonly applicationContext: TApplicationContext; + readonly onInputRequest?: ApplicationInputHandler["handle"]; + readonly reportError: (error: Error) => void; + readonly port: ConnectedMcpSessionPort; + }, +): Promise { + if (task.status !== "input_required") return; + const inputResponses: Record = {}; + for (const [inputKey, request] of Object.entries(task.inputRequests)) { + const resolution = await resolveInputRequest( + task, + inputKey, + request, + acquiredInputs, + inputSignal, + options, + ); + if (inputSignal.aborted) return; + if (resolution !== undefined) + inputResponses[resolution.inputKey] = resolution.response; + } if (inputSignal.aborted || Object.keys(inputResponses).length === 0) return; parseResult( UpdateTaskResultV2Schema, diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts index e03a9cc..735e093 100644 --- a/packages/ext-tasks/src/client/v2-input-task.test.ts +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -285,6 +285,7 @@ describe("V2 input and task behavior", () => { expect( errors.some((error) => error.message.includes("reused incompatibly")), ).toBe(true); + expect(errors.some((error) => error.message === "declined")).toBe(true); expect( port.requests.filter( (request) => expectRecord(request).method === "tasks/update", @@ -346,6 +347,7 @@ describe("V2 input and task behavior", () => { return { kind: "result", result: { resultType: "complete" } }; throw new Error(`unexpected method ${formatJson(method)}`); }; + const errors: Error[] = []; const session = withTasks(port, { tools: { currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), @@ -354,6 +356,7 @@ describe("V2 input and task behavior", () => { await Promise.resolve(); throw new Error("declined"); }, + onError: (error) => errors.push(error), }); const execution = await session.callTool("x"); await expect(execution.result()).resolves.toMatchObject({ @@ -368,6 +371,8 @@ describe("V2 input and task behavior", () => { ).toEqual({ elicit: { action: "cancel" }, }); + expect(errors).toHaveLength(3); + expect(errors.every((error) => error.message === "declined")).toBe(true); await session.close(); }); diff --git a/packages/ext-tasks/src/core/v2/integration.ts b/packages/ext-tasks/src/core/v2/integration.ts index a2506ae..6ebb6a6 100644 --- a/packages/ext-tasks/src/core/v2/integration.ts +++ b/packages/ext-tasks/src/core/v2/integration.ts @@ -25,6 +25,22 @@ import { function parsed(schema: z.ZodType, value: unknown): value is T { return schema.safeParse(value).success; } + +function asObjectRecord( + value: unknown, +): Readonly> | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Readonly>) + : undefined; +} + +function hasOwnTaskExtension(value: unknown): boolean { + const extensions = asObjectRecord(value); + return ( + extensions !== undefined && + Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2) + ); +} export const isTaskV2: (value: unknown) => value is TaskV2 = ( value: unknown, ): value is TaskV2 => parsed(TaskV2Schema, value); @@ -76,27 +92,12 @@ export function isToolCallTaskResultV2( * returning false for malformed or missing containers. */ export function hasTaskClientCapabilityV2(value: unknown): boolean { - if (value === null || typeof value !== "object" || Array.isArray(value)) - return false; - const meta = (value as { _meta?: unknown })._meta; - if (meta === null || typeof meta !== "object" || Array.isArray(meta)) - return false; - const capabilities = (meta as Record)[ - CLIENT_CAPABILITIES_META_KEY_V2 - ]; - if ( - capabilities === null || - typeof capabilities !== "object" || - Array.isArray(capabilities) - ) - return false; - const extensions = (capabilities as { extensions?: unknown }).extensions; - return ( - extensions !== null && - typeof extensions === "object" && - !Array.isArray(extensions) && - Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2) + const params = asObjectRecord(value); + const metadata = asObjectRecord(params?._meta); + const clientCapabilities = asObjectRecord( + metadata?.[CLIENT_CAPABILITIES_META_KEY_V2], ); + return hasOwnTaskExtension(clientCapabilities?.extensions); } /** * Narrows an object-shaped server capability envelope when its extensions own the tasks key. @@ -104,15 +105,8 @@ export function hasTaskClientCapabilityV2(value: unknown): boolean { export function hasTaskServerCapabilityV2( value: unknown, ): value is ServerTaskCapabilityEnvelopeV2 { - if (value === null || typeof value !== "object" || Array.isArray(value)) - return false; - const extensions = (value as { extensions?: unknown }).extensions; - return ( - extensions !== null && - typeof extensions === "object" && - !Array.isArray(extensions) && - Object.prototype.hasOwnProperty.call(extensions, TASKS_EXTENSION_ID_V2) - ); + const serverCapabilities = asObjectRecord(value); + return hasOwnTaskExtension(serverCapabilities?.extensions); } /** @@ -122,17 +116,14 @@ export function hasTaskServerCapabilityV2( export function withTaskCapabilityV2< T extends Readonly>, >(params: T): T & Readonly> { - const wireMeta = params._meta; - const base: Readonly> = - wireMeta !== null && - typeof wireMeta === "object" && - !Array.isArray(wireMeta) - ? (wireMeta as Readonly>) - : {}; + const existingMetadata = asObjectRecord(params._meta) ?? {}; const capability = { extensions: { [TASKS_EXTENSION_ID_V2]: {} } }; return { ...params, - _meta: { ...base, [CLIENT_CAPABILITIES_META_KEY_V2]: capability }, + _meta: { + ...existingMetadata, + [CLIENT_CAPABILITIES_META_KEY_V2]: capability, + }, }; } @@ -150,16 +141,13 @@ export function contributeTaskFilterV2< readonly taskIds: readonly string[]; }; } { - const notifications = filter.notifications; - const prior: Readonly> = - notifications !== null && - typeof notifications === "object" && - !Array.isArray(notifications) - ? (notifications as Readonly>) - : {}; + const existingNotifications = asObjectRecord(filter.notifications) ?? {}; return { ...filter, - notifications: { ...prior, taskIds: [...new Set(taskIds)] }, + notifications: { + ...existingNotifications, + taskIds: [...new Set(taskIds)], + }, }; } /** @@ -167,17 +155,11 @@ export function contributeTaskFilterV2< * any enclosing value is malformed or any ID is not a string. */ export function readAcceptedTaskIdsV2(value: unknown): readonly string[] { - if (value === null || typeof value !== "object" || Array.isArray(value)) - return []; - const notifications = (value as { notifications?: unknown }).notifications; - if ( - notifications === null || - typeof notifications !== "object" || - Array.isArray(notifications) - ) - return []; - const ids = (notifications as { taskIds?: unknown }).taskIds; - return Array.isArray(ids) && ids.every((id) => typeof id === "string") - ? [...ids] + const acceptedFilter = asObjectRecord(value); + const notifications = asObjectRecord(acceptedFilter?.notifications); + const acceptedTaskIds = notifications?.taskIds; + return Array.isArray(acceptedTaskIds) && + acceptedTaskIds.every((taskId) => typeof taskId === "string") + ? [...acceptedTaskIds] : []; } diff --git a/packages/ext-tasks/src/core/v2/schemas.ts b/packages/ext-tasks/src/core/v2/schemas.ts index 8660006..76300b7 100644 --- a/packages/ext-tasks/src/core/v2/schemas.ts +++ b/packages/ext-tasks/src/core/v2/schemas.ts @@ -16,6 +16,22 @@ const JsonObjectSchema = z.custom>>( "Expected a JSON object", ); const MetaSchema = JsonObjectSchema; +const copyUndeclaredJsonKeys = ( + target: Record, + source: Readonly>, + declaredKeys: ReadonlySet, +): void => { + for (const key of Object.keys(source)) { + if (declaredKeys.has(key)) continue; + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value: source[key], + writable: true, + }); + } +}; + const openObject = (shape: T) => { const validator = z.object(shape).catchall(JsonValueSchema); const declaredKeys = new Set(Object.keys(shape)); @@ -23,16 +39,11 @@ const openObject = (shape: T) => { const parsed = validator.safeParse(value); if (parsed.success) { const data = parsed.data; - const source = value as Readonly>; - for (const key of Object.keys(source)) { - if (declaredKeys.has(key)) continue; - Object.defineProperty(data, key, { - configurable: true, - enumerable: true, - value: source[key], - writable: true, - }); - } + copyUndeclaredJsonKeys( + data, + value as Readonly>, + declaredKeys, + ); return data; } for (const issue of parsed.error.issues) @@ -73,40 +84,45 @@ const ContentBaseShape = { annotations: AnnotationsV2Schema.optional(), _meta: MetaSchema.optional(), }; +const TextContentBlockV2Schema = openObject({ + ...ContentBaseShape, + type: z.literal("text"), + text: z.string(), +}); +const ImageContentBlockV2Schema = openObject({ + ...ContentBaseShape, + type: z.literal("image"), + data: z.string(), + mimeType: z.string(), +}); +const AudioContentBlockV2Schema = openObject({ + ...ContentBaseShape, + type: z.literal("audio"), + data: z.string(), + mimeType: z.string(), +}); +const ResourceLinkContentBlockV2Schema = openObject({ + ...ContentBaseShape, + type: z.literal("resource_link"), + name: z.string(), + uri: z.string(), + title: z.string().optional(), + description: z.string().optional(), + mimeType: z.string().optional(), + size: z.int().optional(), + icons: z.array(IconV2Schema).optional(), +}); +const EmbeddedResourceContentBlockV2Schema = openObject({ + ...ContentBaseShape, + type: z.literal("resource"), + resource: ResourceContentsV2Schema, +}); const ContentBlockV2Schema = z.union([ - openObject({ - ...ContentBaseShape, - type: z.literal("text"), - text: z.string(), - }), - openObject({ - ...ContentBaseShape, - type: z.literal("image"), - data: z.string(), - mimeType: z.string(), - }), - openObject({ - ...ContentBaseShape, - type: z.literal("audio"), - data: z.string(), - mimeType: z.string(), - }), - openObject({ - ...ContentBaseShape, - type: z.literal("resource_link"), - name: z.string(), - uri: z.string(), - title: z.string().optional(), - description: z.string().optional(), - mimeType: z.string().optional(), - size: z.int().optional(), - icons: z.array(IconV2Schema).optional(), - }), - openObject({ - ...ContentBaseShape, - type: z.literal("resource"), - resource: ResourceContentsV2Schema, - }), + TextContentBlockV2Schema, + ImageContentBlockV2Schema, + AudioContentBlockV2Schema, + ResourceLinkContentBlockV2Schema, + EmbeddedResourceContentBlockV2Schema, ]); const ToolV2Schema = openObject({ @@ -196,6 +212,7 @@ const ListRootsResultV2Schema = openObject({ const ElicitResultV2Schema = openObject({ action: z.enum(["accept", "decline", "cancel"]), }); +// Response shapes overlap, so this union is intentionally non-discriminated. const InputResponseUnionV2Schema = z.union([ ElicitResultV2Schema, ListRootsResultV2Schema, @@ -242,11 +259,15 @@ const CreateTaskResultV2Schema = z.object({ _meta: MetaSchema.optional(), }); const RpcBaseShape = { jsonrpc: z.literal("2.0"), id: RequestIdV2Schema }; -const GetTaskRequestV2Schema = z.object({ - ...RpcBaseShape, - method: z.literal("tasks/get"), - params: z.object({ taskId: z.string() }), -}); +const taskIdRequestV2Schema = ( + method: TMethod, +) => + z.object({ + ...RpcBaseShape, + method: z.literal(method), + params: z.object({ taskId: z.string() }), + }); +const GetTaskRequestV2Schema = taskIdRequestV2Schema("tasks/get"); const UpdateTaskRequestV2Schema = z.object({ ...RpcBaseShape, method: z.literal("tasks/update"), @@ -255,11 +276,7 @@ const UpdateTaskRequestV2Schema = z.object({ inputResponses: InputResponsesV2Schema, }), }); -const CancelTaskRequestV2Schema = z.object({ - ...RpcBaseShape, - method: z.literal("tasks/cancel"), - params: z.object({ taskId: z.string() }), -}); +const CancelTaskRequestV2Schema = taskIdRequestV2Schema("tasks/cancel"); const GetTaskResultV2Schema = z.intersection( DetailedTaskV2Schema, z.object({ @@ -267,14 +284,14 @@ const GetTaskResultV2Schema = z.intersection( _meta: MetaSchema.optional(), }), ); -const UpdateTaskResultV2Schema = openObject({ +const CompleteOperationResultShape = { resultType: CompleteResultTypeSchema, _meta: MetaSchema.optional(), -}); -const CancelTaskResultV2Schema = openObject({ - resultType: CompleteResultTypeSchema, - _meta: MetaSchema.optional(), -}); +}; +const completeOperationResultV2Schema = () => + openObject(CompleteOperationResultShape); +const UpdateTaskResultV2Schema = completeOperationResultV2Schema(); +const CancelTaskResultV2Schema = completeOperationResultV2Schema(); const TaskStatusNotificationParamsV2Schema = z.intersection( DetailedTaskV2Schema, From de04cd862f96d703be626178fc3c6932bf16a47d Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Sat, 5 Sep 2026 18:48:00 -0700 Subject: [PATCH 18/33] chore: clarify task execution state flows Centralize snapshot arbitration and observation sequencing behind named driver contexts. Decompose V2 task and input processing, with regression coverage for terminal authority and one-shot input keys. --- packages/ext-tasks/src/client/execution.ts | 189 +++++---- .../src/client/task-lifecycle-races.test.ts | 81 ++++ .../ext-tasks/src/client/task-protocol-v1.ts | 48 +-- .../ext-tasks/src/client/task-protocol-v2.ts | 396 ++++++++++-------- .../src/client/v1-input-task.test.ts | 80 ++++ .../src/client/v2-input-task.test.ts | 6 +- 6 files changed, 526 insertions(+), 274 deletions(-) diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts index 14111cc..b67669b 100644 --- a/packages/ext-tasks/src/client/execution.ts +++ b/packages/ext-tasks/src/client/execution.ts @@ -46,60 +46,77 @@ function wakeAll(waiters: Set<() => void>): void { waiters.clear(); } -export type TaskDriver = ( - accept: (snapshot: TaskSnapshot) => void, - waitForTurn: ( +export interface TaskDriverContext { + readonly accept: (snapshot: TaskSnapshot) => TaskSnapshot; + readonly nextObservation: ( afterSequence: number, delayMs: number | undefined, - ) => Promise, - observe: ( - afterSequence: number, observation: (signal: AbortSignal) => Promise, - ) => Promise, - signal: AbortSignal, - cancelledError: Error, - closedError: Error, - isClosed: () => boolean, - inputSignal: AbortSignal, + ) => Promise; + readonly signal: AbortSignal; + readonly inputSignal: AbortSignal; + readonly errors: { + readonly cancelled: Error; + readonly closed: Error; + }; + readonly isClosed: () => boolean; +} + +export type TaskDriver = ( + context: TaskDriverContext, ) => Promise; +interface TaskExecutionOptions { + readonly applicationContext: TApplicationContext; + readonly handle: TaskHandle; + readonly endpointId: string; + readonly initialSnapshot: TaskSnapshot; + readonly driver: TaskDriver; + readonly cancelTask: (signal?: AbortSignal) => Promise; + readonly lifecycleSignal?: AbortSignal; +} + export class TaskExecution< TResult, TApplicationContext, > implements ToolExecutionCommon { readonly kind = "task" as const; + readonly applicationContext: TApplicationContext; + readonly handle: TaskHandle; + private readonly endpointId: string; + private readonly cancelTask: (signal?: AbortSignal) => Promise; private readonly controller = new AbortController(); private readonly inputController = new AbortController(); private readonly cancellationController = new AbortController(); private readonly resultPromise: Promise; private readonly cancelledError = new Error("Task was cancelled"); private readonly closedError = new TaskExecutionClosedError(); - private readonly notificationWaiters = new Set<() => void>(); + private readonly turnWaiters = new Set<() => void>(); private readonly updateWaiters = new Set<() => void>(); private initialSnapshot: TaskSnapshot | undefined; private pendingSnapshot: TaskSnapshot | undefined; private terminalSnapshot: TaskSnapshot | undefined; - private terminalSnapshotBytes: string | undefined; + private authoritativeTerminalSnapshot: TaskSnapshot | undefined; private lastAcceptedBytes: string; private notificationSequence = 0; - private latestNotification: TaskSnapshot | undefined; + private latestNotifiedSnapshot: TaskSnapshot | undefined; private updatesAcquired = false; private cancelPromise: Promise | undefined; private closed = false; - constructor( - readonly applicationContext: TApplicationContext, - readonly handle: TaskHandle, - private readonly endpointId: string, - initialSnapshot: TaskSnapshot, - driver: TaskDriver, - private readonly cancelTask: (signal?: AbortSignal) => Promise, - lifecycleSignal?: AbortSignal, - ) { - this.initialSnapshot = initialSnapshot; - if (terminalStatus(initialSnapshot.task.status)) + constructor(options: TaskExecutionOptions) { + this.applicationContext = options.applicationContext; + this.handle = options.handle; + this.endpointId = options.endpointId; + this.cancelTask = options.cancelTask; + this.initialSnapshot = options.initialSnapshot; + const initialBytes = deterministicJson(options.initialSnapshot); + this.lastAcceptedBytes = initialBytes; + if (terminalStatus(options.initialSnapshot.task.status)) { + this.authoritativeTerminalSnapshot = options.initialSnapshot; this.inputController.abort(); - this.lastAcceptedBytes = deterministicJson(initialSnapshot); + } + const { lifecycleSignal } = options; if (lifecycleSignal !== undefined) { const abort = (): void => { this.controller.abort(lifecycleSignal.reason); @@ -107,19 +124,18 @@ export class TaskExecution< if (lifecycleSignal.aborted) abort(); else lifecycleSignal.addEventListener("abort", abort, { once: true }); } - this.resultPromise = driver( - (snapshot) => { - this.acceptSnapshot(snapshot); + this.resultPromise = options.driver({ + accept: (snapshot) => this.acceptSnapshot(snapshot), + nextObservation: (afterSequence, delayMs, observation) => + this.nextObservation(afterSequence, delayMs, observation), + signal: this.controller.signal, + inputSignal: this.inputController.signal, + errors: { + cancelled: this.cancelledError, + closed: this.closedError, }, - (afterSequence, delayMs) => this.waitForTurn(afterSequence, delayMs), - (afterSequence, observation) => - this.observeUntilNotification(afterSequence, observation), - this.controller.signal, - this.cancelledError, - this.closedError, - () => this.closed, - this.inputController.signal, - ); + isClosed: () => this.closed, + }); } serializeReference(): SerializedTaskReference { @@ -129,20 +145,7 @@ export class TaskExecution< onNotification(snapshot: TaskSnapshot): void { if (this.closed || snapshot.generation !== this.handle.generation) return; if (snapshot.task.taskId !== this.handle.taskId) return; - const bytes = deterministicJson(snapshot); - if (terminalStatus(snapshot.task.status)) { - this.inputController.abort(); - if (this.terminalSnapshotBytes === undefined) { - this.terminalSnapshot = snapshot; - this.terminalSnapshotBytes = bytes; - } - } else if (this.terminalSnapshotBytes !== undefined) { - return; - } - this.latestNotification = snapshot; - this.notificationSequence += 1; - wakeAll(this.notificationWaiters); - wakeAll(this.updateWaiters); + this.transitionSnapshot(snapshot, "notification"); } updates(signal?: AbortSignal): AsyncIterable { @@ -182,21 +185,43 @@ export class TaskExecution< return snapshot; } - private acceptSnapshot(snapshot: TaskSnapshot): void { - if (this.closed) return; + private acceptSnapshot(snapshot: TaskSnapshot): TaskSnapshot { + if (this.closed) return snapshot; + return this.transitionSnapshot(snapshot, "accepted"); + } + + private transitionSnapshot( + snapshot: TaskSnapshot, + source: "accepted" | "notification", + ): TaskSnapshot { + // The first terminal snapshot is authoritative across polling, notifications, + // result driving, and the update stream. Nothing may advance after it. + if (this.authoritativeTerminalSnapshot !== undefined) + return this.authoritativeTerminalSnapshot; const bytes = deterministicJson(snapshot); - if (bytes === this.lastAcceptedBytes) return; - this.lastAcceptedBytes = bytes; + if (source === "accepted" && bytes === this.lastAcceptedBytes) + return snapshot; + + let queuedUpdate = false; if (terminalStatus(snapshot.task.status)) { + this.terminalSnapshot = snapshot; + queuedUpdate = true; + this.authoritativeTerminalSnapshot = snapshot; this.inputController.abort(); - if (bytes !== this.terminalSnapshotBytes) { - this.terminalSnapshot ??= snapshot; - this.terminalSnapshotBytes ??= bytes; - } - } else if (this.terminalSnapshotBytes === undefined) { + } else if (source === "accepted") { this.pendingSnapshot = snapshot; + queuedUpdate = true; + } + + if (source === "accepted") { + this.lastAcceptedBytes = bytes; + } else { + this.latestNotifiedSnapshot = snapshot; + this.notificationSequence += 1; + wakeAll(this.turnWaiters); } - wakeAll(this.updateWaiters); + if (queuedUpdate) wakeAll(this.updateWaiters); + return snapshot; } private async waitForUpdateOrResult(signal?: AbortSignal): Promise { @@ -228,14 +253,14 @@ export class TaskExecution< } } - private notificationAfter(afterSequence: number): TaskTurn { + private notifiedSnapshotAfter(afterSequence: number): TaskTurn { if ( this.notificationSequence > afterSequence && - this.latestNotification !== undefined + this.latestNotifiedSnapshot !== undefined ) { return { sequence: this.notificationSequence, - snapshot: this.latestNotification, + snapshot: this.latestNotifiedSnapshot, }; } return undefined; @@ -245,48 +270,54 @@ export class TaskExecution< afterSequence: number, delayMs: number | undefined, ): Promise { - const current = this.notificationAfter(afterSequence); + const current = this.notifiedSnapshotAfter(afterSequence); if (current !== undefined) return current; if (delayMs === undefined) { await Promise.resolve(); throwIfAborted(this.controller.signal); - return this.notificationAfter(afterSequence); + return this.notifiedSnapshotAfter(afterSequence); } await new Promise((resolve, reject) => { const finish = (error?: unknown): void => { clearTimeout(timeout); - this.notificationWaiters.delete(onNotification); + this.turnWaiters.delete(onTurn); this.controller.signal.removeEventListener("abort", onAbort); if (error === undefined) resolve(); else reject(reasonAsError(error)); }; - const onNotification = (): void => { + const onTurn = (): void => { finish(); }; const onAbort = (): void => { finish(this.controller.signal.reason); }; - const timeout = setTimeout(onNotification, Math.max(0, delayMs)); - this.notificationWaiters.add(onNotification); + const timeout = setTimeout(onTurn, Math.max(0, delayMs)); + this.turnWaiters.add(onTurn); this.controller.signal.addEventListener("abort", onAbort, { once: true }); }); - return this.notificationAfter(afterSequence); + return this.notifiedSnapshotAfter(afterSequence); } - private async observeUntilNotification( + private async nextObservation( afterSequence: number, + delayMs: number | undefined, observation: (signal: AbortSignal) => Promise, ): Promise { + const turn = await this.waitForTurn(afterSequence, delayMs); + if (turn !== undefined) return turn; + let wake: (() => void) | undefined; const notified = new Promise((resolve) => { wake = () => { - resolve(this.notificationAfter(afterSequence)); + resolve(this.notifiedSnapshotAfter(afterSequence)); }; - this.notificationWaiters.add(wake); + this.turnWaiters.add(wake); }); - const current = this.notificationAfter(afterSequence); + // Register before checking again so a notification cannot land between the + // clean check and observer registration. + const current = this.notifiedSnapshotAfter(afterSequence); if (current !== undefined) { - if (wake !== undefined) this.notificationWaiters.delete(wake); + if (wake !== undefined) this.turnWaiters.delete(wake); return current; } @@ -307,7 +338,7 @@ export class TaskExecution< } finally { if (!observationLifecycle.signal.aborted) observationLifecycle.abort(); observationLifecycle.dispose(); - if (wake !== undefined) this.notificationWaiters.delete(wake); + if (wake !== undefined) this.turnWaiters.delete(wake); } } diff --git a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts index b3b5c60..dcc51c1 100644 --- a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -340,6 +340,87 @@ describe("task lifecycle and races", () => { await session.close(); }); + it("uses the same first terminal snapshot for updates and result", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async (request) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "first-terminal", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + pollIntervalMs: 1000, + }), + }; + if (record.method === "tasks/get") return new Promise(() => {}); + if (record.method === "tasks/cancel") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const iterator = execution.updates()[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ + value: { task: { status: "working" } }, + }); + + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "first-terminal", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { + resultType: "complete", + content: [{ type: "text", text: "first" }], + }, + }, + }), + ); + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: { + resultType: "complete", + taskId: "first-terminal", + status: "failed", + createdAt: "a", + lastUpdatedAt: "c", + ttlMs: null, + error: { code: -32000, message: "late terminal" }, + }, + }), + ); + + await expect(iterator.next()).resolves.toMatchObject({ + value: { task: { status: "completed", lastUpdatedAt: "b" } }, + }); + await expect(iterator.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + await expect(execution.result()).resolves.toEqual({ + resultType: "complete", + content: [{ type: "text", text: "first" }], + }); + await session.close(); + }); + it("does not retry complete JSON-RPC task errors", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); let getCalls = 0; diff --git a/packages/ext-tasks/src/client/task-protocol-v1.ts b/packages/ext-tasks/src/client/task-protocol-v1.ts index 6cc58b8..ddb89c0 100644 --- a/packages/ext-tasks/src/client/task-protocol-v1.ts +++ b/packages/ext-tasks/src/client/task-protocol-v1.ts @@ -31,33 +31,22 @@ export function createTaskExecutionV1(options: { }): TaskExecution { const { applicationContext, handle, initialTask, resultSchema, port } = options; - return new TaskExecution( + return new TaskExecution({ applicationContext, handle, - port.endpointId, - { generation: "v1", task: initialTask }, - async ( - accept, - waitForTurn, - observe, - signal, - cancelledError, - closedError, - isClosed, - ) => { + endpointId: port.endpointId, + initialSnapshot: { generation: "v1", task: initialTask }, + driver: async (context) => { let current = initialTask; let notificationSequence = 0; while (!terminalStatus(current.status)) { - const turn = await waitForTurn( + const observed = await context.nextObservation( notificationSequence, Math.max( DEFAULT_TASK_POLL_INTERVAL_MS, current.pollInterval ?? DEFAULT_TASK_POLL_INTERVAL_MS, ), - ); - const observed = - turn ?? - (await observe(notificationSequence, (observationSignal) => + (observationSignal) => dispatchWithRetry( port, { method: "tasks/get", params: { taskId: handle.taskId } }, @@ -70,28 +59,35 @@ export function createTaskExecutionV1(options: { responseResult(response), ), })), - )); - if (observed?.snapshot.generation !== "v1") continue; + ); + if (observed === undefined) continue; + if (observed.snapshot.generation !== "v1") + throw new Error("V1 task driver received a non-V1 snapshot"); notificationSequence = observed.sequence; current = observed.snapshot.task; - if (!isClosed()) accept({ generation: "v1", task: current }); + if (!context.isClosed()) { + const accepted = context.accept({ generation: "v1", task: current }); + if (accepted.generation !== "v1") + throw new Error("V1 task driver accepted a non-V1 snapshot"); + current = accepted.task; + } } - if (isClosed()) throw closedError; - if (current.status === "cancelled") throw cancelledError; + if (context.isClosed()) throw context.errors.closed; + if (current.status === "cancelled") throw context.errors.cancelled; if (current.status === "failed") throw new Error(current.statusMessage ?? "Task failed"); const taskResult = responseResult( await dispatchWithRetry( port, { method: "tasks/result", params: { taskId: handle.taskId } }, - signal, + context.signal, "observe", ), ); parseResult(TaskResultV1Schema, taskResult); return parseResult(resultSchema, taskResult); }, - async (signal) => { + cancelTask: async (signal) => { const capabilities = port.taskCapabilities; if ( capabilities.generation !== "v1" || @@ -113,6 +109,6 @@ export function createTaskExecutionV1(options: { ), ); }, - options.lifecycleSignal, - ); + lifecycleSignal: options.lifecycleSignal, + }); } diff --git a/packages/ext-tasks/src/client/task-protocol-v2.ts b/packages/ext-tasks/src/client/task-protocol-v2.ts index 934c813..a8d897c 100644 --- a/packages/ext-tasks/src/client/task-protocol-v2.ts +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -26,6 +26,7 @@ import { TaskExecution, deterministicJson, terminalStatus, + type TaskDriverContext, } from "./execution.js"; import { parseResult, @@ -34,8 +35,7 @@ import { type ConnectedMcpSessionPort, } from "./port.js"; -/** Creates an execution controller for an existing V2 task. */ -export function createTaskExecutionV2(options: { +interface TaskExecutionV2Options { readonly applicationContext: TApplicationContext; readonly handle: TaskHandle & { readonly generation: "v2" }; readonly initialTask: TaskV2; @@ -45,124 +45,197 @@ export function createTaskExecutionV2(options: { readonly lifecycleSignal: AbortSignal; readonly onInputRequest?: ApplicationInputHandler["handle"]; readonly reportError: (error: Error) => void; -}): TaskExecution { - const { - applicationContext, - handle, - initialTask, - initialDetailedTask, - resultSchema, - port, - } = options; - return new TaskExecution( - applicationContext, - handle, - port.endpointId, - { generation: "v2", task: initialTask }, - async ( - accept, - waitForTurn, - observe, - signal, - cancelledError, - closedError, - isClosed, - inputSignal, - ) => { - let status = initialTask.status; - let current = initialDetailedTask; - let notificationSequence = 0; - const acquiredInputs = new Map(); - if (current !== undefined) - await resolveAndSubmitInputRequests( - current, - acquiredInputs, - inputSignal, - signal, - options, - ); - while (!terminalStatus(status)) { - const delayMs = Math.max( - DEFAULT_TASK_POLL_INTERVAL_MS, - current?.pollIntervalMs ?? - initialTask.pollIntervalMs ?? - DEFAULT_TASK_POLL_INTERVAL_MS, - ); - const turn = await waitForTurn(notificationSequence, delayMs); - const observed = - turn ?? - (await observe(notificationSequence, (observationSignal) => - dispatchWithRetry( - port, - { - method: "tasks/get", - params: withTaskCapabilityV2({ taskId: handle.taskId }), - }, - observationSignal, - "observe", - ).then((response) => ({ - generation: "v2" as const, - task: parseResult( - GetTaskResultV2Schema, - responseResult(response), - ), - })), - )); - if (observed?.snapshot.generation !== "v2") continue; - notificationSequence = observed.sequence; - current = observed.snapshot.task as DetailedTaskV2; - status = current.status; - if (!isClosed()) accept({ generation: "v2", task: current }); - await resolveAndSubmitInputRequests( - current, - acquiredInputs, - inputSignal, - signal, - options, - ); - } - if (isClosed()) throw closedError; - if (current === undefined) { - current = parseResult( - GetTaskResultV2Schema, - responseResult( - await dispatchWithRetry( - port, - { - method: "tasks/get", - params: withTaskCapabilityV2({ taskId: handle.taskId }), - }, - signal, - "observe", - ), - ), - ); - if (!isClosed()) accept({ generation: "v2", task: current }); - } - if (current.status === "cancelled") throw cancelledError; - if (current.status === "failed") - throw new JsonRpcResponseError(current.error); - if (current.status !== "completed") - throw new Error(`Unsupported terminal task status: ${current.status}`); - return parseResult(resultSchema, current.result); - }, - async (signal) => { - parseResult( - CancelTaskResultV2Schema, - responseResult( - await dispatchWithRetry( - port, - { - method: "tasks/cancel", - params: withTaskCapabilityV2({ taskId: handle.taskId }), - }, - signal, - "mutate", - ), - ), - ); - }, - options.lifecycleSignal, +} + +interface V2TaskRpcContext { + readonly port: ConnectedMcpSessionPort; + readonly handle: TaskHandle & { readonly generation: "v2" }; +} + +interface V2InputContext extends V2TaskRpcContext { + readonly applicationContext: TApplicationContext; + readonly onInputRequest?: ApplicationInputHandler["handle"]; + readonly reportError: (error: Error) => void; + readonly acquiredRequestLedger: InputRequestLedger; + readonly inputSignal: AbortSignal; + readonly signal: AbortSignal; +} + +type InputAcquisition = + | { readonly kind: "new" } + | { readonly kind: "duplicate" } + | { readonly kind: "incompatible" }; + +/** + * Tracks each input key and request fingerprint. The first acquisition wins, + * including when handling fails or is aborted; later identical requests are + * duplicates, and different requests are incompatible. + */ +class InputRequestLedger { + private readonly fingerprints = new Map(); + + acquire(inputKey: string, request: InputRequestV2): InputAcquisition { + const fingerprint = deterministicJson(request); + const acquiredFingerprint = this.fingerprints.get(inputKey); + if (acquiredFingerprint === undefined) { + this.fingerprints.set(inputKey, fingerprint); + return { kind: "new" }; + } + return acquiredFingerprint === fingerprint + ? { kind: "duplicate" } + : { kind: "incompatible" }; + } +} + +/** Creates an execution controller for an existing V2 task. */ +export function createTaskExecutionV2( + options: TaskExecutionV2Options, +): TaskExecution { + const rpcContext: V2TaskRpcContext = { + port: options.port, + handle: options.handle, + }; + return new TaskExecution({ + applicationContext: options.applicationContext, + handle: options.handle, + endpointId: options.port.endpointId, + initialSnapshot: { generation: "v2", task: options.initialTask }, + driver: (driverContext) => + driveTaskExecutionV2({ options, rpcContext, driverContext }), + cancelTask: (signal) => cancelTask({ rpcContext, signal }), + lifecycleSignal: options.lifecycleSignal, + }); +} + +async function driveTaskExecutionV2(args: { + readonly options: TaskExecutionV2Options; + readonly rpcContext: V2TaskRpcContext; + readonly driverContext: TaskDriverContext; +}): Promise { + const { options, rpcContext, driverContext } = args; + let knownStatus = options.initialTask.status; + let latestDetailedTask = options.initialDetailedTask; + let lastNotificationSequence = 0; + const acquiredRequestLedger = new InputRequestLedger(); + const inputContext: V2InputContext = { + ...rpcContext, + applicationContext: options.applicationContext, + onInputRequest: options.onInputRequest, + reportError: options.reportError, + acquiredRequestLedger, + inputSignal: driverContext.inputSignal, + signal: driverContext.signal, + }; + + if (latestDetailedTask !== undefined) + await resolveAndSubmitInputRequests({ + task: latestDetailedTask, + inputContext, + }); + + while (!terminalStatus(knownStatus)) { + const delayMs = Math.max( + DEFAULT_TASK_POLL_INTERVAL_MS, + latestDetailedTask?.pollIntervalMs ?? + options.initialTask.pollIntervalMs ?? + DEFAULT_TASK_POLL_INTERVAL_MS, + ); + const observed = await driverContext.nextObservation( + lastNotificationSequence, + delayMs, + async (signal) => ({ + generation: "v2", + task: await fetchDetailedTask({ rpcContext, signal }), + }), + ); + if (observed === undefined) continue; + if (observed.snapshot.generation !== "v2") + throw new Error("V2 task driver received a non-V2 snapshot"); + + lastNotificationSequence = observed.sequence; + const accepted = driverContext.isClosed() + ? observed.snapshot + : driverContext.accept(observed.snapshot); + if (accepted.generation !== "v2") + throw new Error("V2 task driver accepted a non-V2 snapshot"); + knownStatus = accepted.task.status; + latestDetailedTask = accepted.task as DetailedTaskV2; + await resolveAndSubmitInputRequests({ + task: latestDetailedTask, + inputContext, + }); + } + + if (driverContext.isClosed()) throw driverContext.errors.closed; + if (latestDetailedTask === undefined) + latestDetailedTask = await fetchDetailedTask({ + rpcContext, + signal: driverContext.signal, + }); + return resolveTerminalTaskResult({ + task: latestDetailedTask, + resultSchema: options.resultSchema, + cancelledError: driverContext.errors.cancelled, + }); +} + +async function fetchDetailedTask(args: { + readonly rpcContext: V2TaskRpcContext; + readonly signal: AbortSignal; +}): Promise { + const { rpcContext, signal } = args; + return parseResult( + GetTaskResultV2Schema, + responseResult( + await dispatchWithRetry( + rpcContext.port, + { + method: "tasks/get", + params: withTaskCapabilityV2({ taskId: rpcContext.handle.taskId }), + }, + signal, + "observe", + ), + ), + ); +} + +function resolveTerminalTaskResult(args: { + readonly task: DetailedTaskV2; + readonly resultSchema: z.ZodType; + readonly cancelledError: Error; +}): TResult { + const { task, resultSchema, cancelledError } = args; + switch (task.status) { + case "cancelled": + throw cancelledError; + case "failed": + throw new JsonRpcResponseError(task.error); + case "completed": + return parseResult(resultSchema, task.result); + default: + throw new Error(`Unsupported terminal task status: ${task.status}`); + } +} + +async function cancelTask(args: { + readonly rpcContext: V2TaskRpcContext; + readonly signal?: AbortSignal; +}): Promise { + const { rpcContext, signal } = args; + parseResult( + CancelTaskResultV2Schema, + responseResult( + await dispatchWithRetry( + rpcContext.port, + { + method: "tasks/cancel", + params: withTaskCapabilityV2({ taskId: rpcContext.handle.taskId }), + }, + signal, + "mutate", + ), + ), ); } @@ -191,45 +264,41 @@ function responseSchemaForInputRequest( return ElicitResultV2Schema; } -async function resolveInputRequest( - task: DetailedTaskV2, - inputKey: string, - request: InputRequestV2, - acquiredInputs: Map, - inputSignal: AbortSignal, - options: { - readonly applicationContext: TApplicationContext; - readonly onInputRequest?: ApplicationInputHandler["handle"]; - readonly reportError: (error: Error) => void; - }, -): Promise { - const signature = deterministicJson(request); - const acquiredSignature = acquiredInputs.get(inputKey); - if (acquiredSignature !== undefined) { - if (acquiredSignature !== signature) - options.reportError( +async function resolveInputRequest(args: { + readonly task: DetailedTaskV2; + readonly inputKey: string; + readonly request: InputRequestV2; + readonly inputContext: V2InputContext; +}): Promise { + const { task, inputKey, request, inputContext } = args; + const acquisition = inputContext.acquiredRequestLedger.acquire( + inputKey, + request, + ); + if (acquisition.kind !== "new") { + if (acquisition.kind === "incompatible") + inputContext.reportError( new Error(`V2 task input key ${inputKey} was reused incompatibly`), ); return undefined; } - acquiredInputs.set(inputKey, signature); let result: unknown; - if (options.onInputRequest === undefined) { + if (inputContext.onInputRequest === undefined) { if (request.method !== "elicitation/create") return undefined; result = { action: "cancel" }; } else { try { - result = await options.onInputRequest(projectInputRequest(request), { + result = await inputContext.onInputRequest(projectInputRequest(request), { lifetime: "task-v2", taskId: task.taskId, inputKey, - applicationContext: options.applicationContext, - signal: inputSignal, + applicationContext: inputContext.applicationContext, + signal: inputContext.inputSignal, }); } catch (error) { - if (inputSignal.aborted) return undefined; - options.reportError( + if (inputContext.inputSignal.aborted) return undefined; + inputContext.reportError( error instanceof Error ? error : new Error(String(error)), ); if (request.method !== "elicitation/create") return undefined; @@ -246,51 +315,46 @@ async function resolveInputRequest( ), }; } catch (error) { - options.reportError( + inputContext.reportError( error instanceof Error ? error : new Error(String(error)), ); return undefined; } } -async function resolveAndSubmitInputRequests( - task: DetailedTaskV2, - acquiredInputs: Map, - inputSignal: AbortSignal, - signal: AbortSignal, - options: { - readonly applicationContext: TApplicationContext; - readonly onInputRequest?: ApplicationInputHandler["handle"]; - readonly reportError: (error: Error) => void; - readonly port: ConnectedMcpSessionPort; - }, -): Promise { +async function resolveAndSubmitInputRequests(args: { + readonly task: DetailedTaskV2; + readonly inputContext: V2InputContext; +}): Promise { + const { task, inputContext } = args; if (task.status !== "input_required") return; const inputResponses: Record = {}; for (const [inputKey, request] of Object.entries(task.inputRequests)) { - const resolution = await resolveInputRequest( + const resolution = await resolveInputRequest({ task, inputKey, request, - acquiredInputs, - inputSignal, - options, - ); - if (inputSignal.aborted) return; + inputContext, + }); + if (inputContext.inputSignal.aborted) return; if (resolution !== undefined) inputResponses[resolution.inputKey] = resolution.response; } - if (inputSignal.aborted || Object.keys(inputResponses).length === 0) return; + if ( + inputContext.inputSignal.aborted || + Object.keys(inputResponses).length === 0 + ) + return; parseResult( UpdateTaskResultV2Schema, responseResult( await dispatchWithRetry( - options.port, + inputContext.port, { method: "tasks/update", params: withTaskCapabilityV2({ taskId: task.taskId, inputResponses }), }, - signal, + inputContext.signal, "mutate", ), ), diff --git a/packages/ext-tasks/src/client/v1-input-task.test.ts b/packages/ext-tasks/src/client/v1-input-task.test.ts index b27325c..bf08e8d 100644 --- a/packages/ext-tasks/src/client/v1-input-task.test.ts +++ b/packages/ext-tasks/src/client/v1-input-task.test.ts @@ -641,6 +641,86 @@ describe("V1 input and task behavior", () => { await session.close(); }); + it("keeps a notified V1 terminal authoritative after update delivery", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } } }, + }); + const tool: ToolV1 = { + name: "notified", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }; + let getCalls = 0; + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + task: { + taskId: "v1-notified", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttl: null, + pollInterval: 1000, + }, + }), + }; + if (record.method === "tasks/get") { + getCalls += 1; + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => { + reject(asError(options.signal?.reason)); + }, + { once: true }, + ); + }); + } + if (record.method === "tasks/result") + return { + kind: "result", + result: asJson({ content: [{ type: "text", text: "notified" }] }), + }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { tools: { currentTool: () => tool } }); + const execution = await session.callTool("notified"); + const iterator = execution.updates()[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ + value: { task: { status: "working" } }, + }); + + port.notify( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks/status", + params: { + taskId: "v1-notified", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, + }, + }), + ); + await expect(iterator.next()).resolves.toMatchObject({ + value: { task: { status: "completed" } }, + }); + await expect(iterator.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + await expect(execution.result()).resolves.toEqual({ + content: [{ type: "text", text: "notified" }], + }); + expect(getCalls).toBeLessThanOrEqual(1); + await session.close(); + }); + it("identifies unsupported V1 cancellation without dispatching it", async () => { const port = new FakePort({ generation: "v1", diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts index 735e093..6890e67 100644 --- a/packages/ext-tasks/src/client/v2-input-task.test.ts +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -211,7 +211,7 @@ describe("V2 input and task behavior", () => { ); }); - it("reports incompatible repeated V2 keys without reacquiring or updating", async () => { + it("does not reacquire repeated V2 keys and reports incompatible reuse", async () => { const errors: Error[] = []; const port = new FakePort({ generation: "v2", capabilities: {} }); let getCalls = 0; @@ -233,7 +233,7 @@ describe("V2 input and task behavior", () => { }; if (method === "tasks/get") { getCalls += 1; - if (getCalls <= 2) + if (getCalls <= 3) return { kind: "result", result: asJson({ @@ -245,7 +245,7 @@ describe("V2 input and task behavior", () => { ttlMs: null, inputRequests: { same: - getCalls === 1 + getCalls <= 2 ? { method: "roots/list" } : { method: "sampling/createMessage", params: {} }, }, From dc3ce0f5cdb7e53f6d747c483e01ca9188ccf1f9 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Sat, 5 Sep 2026 22:34:04 -0700 Subject: [PATCH 19/33] chore: simplify deferred value flows Replace forward-declared branch results with focused helpers and explicit outcomes. Keep timing-sensitive session locals and iterative cursor state unchanged. --- .../ext-tasks/src/client/input-routing.ts | 18 +++-- packages/ext-tasks/src/client/session.ts | 38 ++++++----- .../ext-tasks/src/client/task-protocol-v2.ts | 68 ++++++++++++------- packages/ext-tasks/src/core/index.ts | 26 ++++--- 4 files changed, 94 insertions(+), 56 deletions(-) diff --git a/packages/ext-tasks/src/client/input-routing.ts b/packages/ext-tasks/src/client/input-routing.ts index ec64db3..8d23cb6 100644 --- a/packages/ext-tasks/src/client/input-routing.ts +++ b/packages/ext-tasks/src/client/input-routing.ts @@ -123,6 +123,17 @@ export function readRelatedTaskEvidence( }; } +function correlationFailureReason( + evidence: RelatedTaskEvidence, + matchCount: number, +): InputCorrelationFailureReason | undefined { + if (evidence.kind === "invalid") return "invalid-evidence"; + if (matchCount > 1) return "ambiguous-matches"; + if (matchCount === 0) + return evidence.kind === "absent" ? "missing-evidence" : "zero-matches"; + return undefined; +} + /** Resolves the unique input candidate while preserving correlation diagnostics. */ export function resolveInputCandidate( evidence: RelatedTaskEvidence, @@ -135,12 +146,7 @@ export function resolveInputCandidate( (candidate) => candidate.taskId === evidence.taskId, ) : [...ordinaryCandidates, ...taskCandidates]; - let reason: InputCorrelationFailureReason | undefined; - if (evidence.kind === "invalid") reason = "invalid-evidence"; - else if (matches.length > 1) reason = "ambiguous-matches"; - else if (matches.length === 0) { - reason = evidence.kind === "absent" ? "missing-evidence" : "zero-matches"; - } + const reason = correlationFailureReason(evidence, matches.length); if (reason !== undefined) { return { kind: "failed", diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index b84f96c..3cfc7ed 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -396,28 +396,34 @@ class PortTaskEnabledSession< } } + private lateTaskCancellationParams( + result: JsonValue, + generation: SessionTaskCapabilities["generation"], + callAsTaskV1: boolean, + ): JsonValue | undefined { + if (generation === "v1" && callAsTaskV1) { + const parsed = CreateTaskResultV1Schema.safeParse(result); + if (!parsed.success) return undefined; + return { taskId: parsed.data.task.taskId as TaskId }; + } + if (generation !== "v2" || !isCreateTaskResultV2(result)) return undefined; + const parsed = CreateTaskResultV2Schema.safeParse(result); + if (!parsed.success) return undefined; + return withTaskCapabilityV2({ taskId: parsed.data.taskId as TaskId }); + } + private cleanupLateTaskCreation( response: JsonRpcResponse, generation: SessionTaskCapabilities["generation"], callAsTaskV1: boolean, ): void { if (response.kind !== "result") return; - let taskId: TaskId | undefined; - let params: JsonValue | undefined; - if (generation === "v1" && callAsTaskV1) { - const parsed = CreateTaskResultV1Schema.safeParse(response.result); - if (parsed.success) { - taskId = parsed.data.task.taskId as TaskId; - params = { taskId }; - } - } else if (generation === "v2" && isCreateTaskResultV2(response.result)) { - const parsed = CreateTaskResultV2Schema.safeParse(response.result); - if (parsed.success) { - taskId = parsed.data.taskId as TaskId; - params = withTaskCapabilityV2({ taskId }); - } - } - if (taskId === undefined || params === undefined) return; + const params = this.lateTaskCancellationParams( + response.result, + generation, + callAsTaskV1, + ); + if (params === undefined) return; void dispatchWithRetry( this.port, { method: "tasks/cancel", params }, diff --git a/packages/ext-tasks/src/client/task-protocol-v2.ts b/packages/ext-tasks/src/client/task-protocol-v2.ts index a8d897c..a989f70 100644 --- a/packages/ext-tasks/src/client/task-protocol-v2.ts +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -264,6 +264,43 @@ function responseSchemaForInputRequest( return ElicitResultV2Schema; } +type InputHandlerOutcome = + | { readonly kind: "result"; readonly value: unknown } + | { readonly kind: "skipped" }; + +async function invokeInputHandler(args: { + readonly task: DetailedTaskV2; + readonly inputKey: string; + readonly request: InputRequestV2; + readonly inputContext: V2InputContext; +}): Promise { + const { task, inputKey, request, inputContext } = args; + if (inputContext.onInputRequest === undefined) + return request.method === "elicitation/create" + ? { kind: "result", value: { action: "cancel" } } + : { kind: "skipped" }; + try { + return { + kind: "result", + value: await inputContext.onInputRequest(projectInputRequest(request), { + lifetime: "task-v2", + taskId: task.taskId, + inputKey, + applicationContext: inputContext.applicationContext, + signal: inputContext.inputSignal, + }), + }; + } catch (error) { + if (inputContext.inputSignal.aborted) return { kind: "skipped" }; + inputContext.reportError( + error instanceof Error ? error : new Error(String(error)), + ); + return request.method === "elicitation/create" + ? { kind: "result", value: { action: "cancel" } } + : { kind: "skipped" }; + } +} + async function resolveInputRequest(args: { readonly task: DetailedTaskV2; readonly inputKey: string; @@ -283,35 +320,20 @@ async function resolveInputRequest(args: { return undefined; } - let result: unknown; - if (inputContext.onInputRequest === undefined) { - if (request.method !== "elicitation/create") return undefined; - result = { action: "cancel" }; - } else { - try { - result = await inputContext.onInputRequest(projectInputRequest(request), { - lifetime: "task-v2", - taskId: task.taskId, - inputKey, - applicationContext: inputContext.applicationContext, - signal: inputContext.inputSignal, - }); - } catch (error) { - if (inputContext.inputSignal.aborted) return undefined; - inputContext.reportError( - error instanceof Error ? error : new Error(String(error)), - ); - if (request.method !== "elicitation/create") return undefined; - result = { action: "cancel" }; - } - } + const outcome = await invokeInputHandler({ + task, + inputKey, + request, + inputContext, + }); + if (outcome.kind === "skipped") return undefined; try { return { inputKey, response: parseResult( responseSchemaForInputRequest(request), - result as JsonValue, + outcome.value as JsonValue, ), }; } catch (error) { diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts index ffc75aa..f920d0d 100644 --- a/packages/ext-tasks/src/core/index.ts +++ b/packages/ext-tasks/src/core/index.ts @@ -22,6 +22,17 @@ export function taskId(value: string): TaskId { return value as TaskId; } +function isJsonObject( + candidate: object, + visit: (value: unknown) => boolean, +): boolean { + const prototype = Reflect.getPrototypeOf(candidate); + return ( + (prototype === Object.prototype || prototype === null) && + Object.values(candidate).every(visit) + ); +} + /** * Checks recursively whether a value is JSON-compatible, rejecting non-finite numbers, * sparse arrays, non-plain objects, and cyclic references. @@ -39,17 +50,10 @@ export function isJsonValue(value: unknown): value is JsonValue { if (typeof candidate !== "object") return false; if (visiting.has(candidate)) return false; visiting.add(candidate); - let valid: boolean; - if (Array.isArray(candidate)) { - valid = - candidate.length === Object.keys(candidate).length && - candidate.every(visit); - } else { - const prototype = Reflect.getPrototypeOf(candidate); - valid = - (prototype === Object.prototype || prototype === null) && - Object.values(candidate).every(visit); - } + const valid = Array.isArray(candidate) + ? candidate.length === Object.keys(candidate).length && + candidate.every(visit) + : isJsonObject(candidate, visit); visiting.delete(candidate); return valid; }; From 00dde128ea33c8c594b388bd990bde91da4a2e1b Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Mon, 7 Sep 2026 08:51:44 -0700 Subject: [PATCH 20/33] feat: harden task client integration - Finalize generation-tagged declarations and codec-neutral results - Correct task input routing, recovery ownership, and lifecycle cleanup - Add packed exports and SDK peer-range contract validation - Document the initial client integration API without naming consumers Signed-off-by: Luca Chang --- .github/workflows/ci.yml | 22 ++ package.json | 3 +- packages/ext-tasks/README.md | 146 +++++++- packages/ext-tasks/scripts/check-exports.mjs | 27 +- .../ext-tasks/scripts/check-peer-range.mjs | 133 +++++++ packages/ext-tasks/src/client/api.ts | 81 +++-- .../src/client/client-adapter.test.ts | 104 +++++- .../client/declarations-capabilities.test.ts | 24 +- packages/ext-tasks/src/client/execution.ts | 64 +++- .../client/immediate-session-basics.test.ts | 127 ++++++- packages/ext-tasks/src/client/index.ts | 11 + .../ext-tasks/src/client/input-routing.ts | 10 +- packages/ext-tasks/src/client/port.ts | 56 ++- .../src/client/sdk-client-adapter.ts | 69 +++- packages/ext-tasks/src/client/session.ts | 342 ++++++++++-------- .../src/client/task-lifecycle-races.test.ts | 99 ++++- .../ext-tasks/src/client/task-protocol-v1.ts | 36 +- .../ext-tasks/src/client/task-protocol-v2.ts | 109 ++++-- .../src/client/task-resumption.test.ts | 233 ++++++++++-- .../ext-tasks/src/client/tool-declarations.ts | 55 ++- .../src/client/v1-input-task.test.ts | 70 ++-- .../src/client/v2-input-task.test.ts | 39 +- packages/ext-tasks/src/core/index.test.ts | 21 +- packages/ext-tasks/src/core/index.ts | 32 +- packages/ext-tasks/src/core/v1/schemas.ts | 7 +- packages/ext-tasks/src/core/v2/schemas.ts | 7 +- .../test-support/client/fake-port.ts | 19 +- 27 files changed, 1493 insertions(+), 453 deletions(-) create mode 100644 packages/ext-tasks/scripts/check-peer-range.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 995214d..74f454e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,25 @@ jobs: run: npm run check - name: Build documentation run: npm run docs:build + + peer-range: + name: Peer range (${{ matrix.client-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + client-version: ["2.0.0", "2"] + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - name: Install dependencies + run: npm ci + - name: Build package + run: npm run build:package + - name: Check packed package against client peer endpoint + run: npm run check:peer-range -- ${{ matrix.client-version }} diff --git a/package.json b/package.json index b59c053..32a839b 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "check:schema:json": "tsx scripts/generate-schemas.ts --check", "docs:dev": "vitepress dev", "docs:build": "vitepress build", - "docs:preview": "vitepress preview" + "docs:preview": "vitepress preview", + "check:peer-range": "node packages/ext-tasks/scripts/check-peer-range.mjs" }, "devDependencies": { "mermaid": "^11.17.0", diff --git a/packages/ext-tasks/README.md b/packages/ext-tasks/README.md index cdf8532..4ccc878 100644 --- a/packages/ext-tasks/README.md +++ b/packages/ext-tasks/README.md @@ -1,30 +1,145 @@ # `@modelcontextprotocol/ext-tasks` -Protocol Zod schemas and requester-side lifecycle support for MCP Tasks. +Requester-side lifecycle support and versioned protocol schemas for MCP Tasks. -This package intentionally has no root entry point. Import the role or protocol -generation needed by the application: +This package has no root entry point. Import the role or protocol generation needed by the application: ```ts -import { withTasks } from "@modelcontextprotocol/ext-tasks/client"; +import { + createSessionPortFromClient, + withTasks, +} from "@modelcontextprotocol/ext-tasks/client"; ``` +## Session setup and endpoint identity + +`withTasks` accepts only a connected `ConnectedMcpSessionPort`. Every port has an explicit, stable `endpointId`; serialized task references use it to fail closed when resumed against another endpoint. + +For an MCP SDK Client, create and own the port separately: + ```ts -import { TaskV2Schema } from "@modelcontextprotocol/ext-tasks/core/v2"; -import * as z from "zod/v4"; +const port = createSessionPortFromClient(client, endpointId); +const session = withTasks(port, { onError }); + +try { + const execution = await session.callTool("generate_report", { + format: "pdf", + }); + const result = await execution.result(); +} finally { + await session.close(); // releases ext-tasks resources; never closes the borrowed port + port[Symbol.dispose](); // restores SDK Client callbacks; never closes the Client transport +} +``` + +The adapter support matrix is explicit: + +| Connected session | `createSessionPortFromClient` requirement | Dispatch path | +| ------------------- | ----------------------------------------- | ---------------------------------------------------------- | +| V1 Tasks | `client`, `endpointId` | SDK `Client.request` | +| No Tasks capability | `client`, `endpointId` | SDK `Client.request` | +| V2 Tasks | `client`, `endpointId`, `{ rawDispatch }` | Host request coordinator for V2 `tools/call` and `tasks/*` | + +SDK Client 2.x has no public raw request coordinator that can safely share request IDs, authentication recovery, cancellation, and inbound-response ownership. Therefore V2 without `rawDispatch` throws while constructing the port, before a session or call exists. `rawDispatch` must be supplied explicitly; no property is discovered on the Client object. + +```ts +const port = createSessionPortFromClient(client, endpointId, { + rawDispatch: hostRequestCoordinator.dispatch, +}); +``` + +Do not implement `rawDispatch` with a standalone `client.transport.send`: that bypasses the SDK coordinator and competes for responses. + +## Tool declaration providers + +A host-supplied provider returns an explicitly tagged generated declaration. Generation is never inferred from optional tool properties. -TaskV2Schema.parse(taskPayload); +```ts +import { + toolDeclarationV1, + toolDeclarationV2, + withTasks, +} from "@modelcontextprotocol/ext-tasks/client"; -const execution = await withTasks(client).callTool( - "generate_report", - undefined, - { - resultSchema: z.object({ reportUrl: z.url() }), +const session = withTasks(port, { + tools: { + currentTool(name) { + const rawTool = pool.currentRawTool(name); + if (rawTool === undefined) return undefined; + return pool.generation === "v1" + ? toolDeclarationV1(rawTool) + : toolDeclarationV2(rawTool); + }, }, -); -const { reportUrl } = await execution.result(); +}); ``` +Hosts that own tool discovery can retain each raw generated declaration and tag it without projecting or reparsing it. When no provider is supplied, the package performs generation-specific `tools/list` parsing itself. Duplicate names reject the refresh deterministically; no first/last winner is selected. + +## Runtime codecs + +The public projection API is schema-library neutral. `RuntimeCodec` receives an already-decoded `JsonValue` and returns a success value or `ProtocolDecodeError`. Zod remains an implementation convenience of the versioned generated schema subpaths, not a public client API requirement. + +```ts +import { + ProtocolDecodeError, + type RuntimeCodec, +} from "@modelcontextprotocol/ext-tasks/core"; + +const reportCodec: RuntimeCodec<{ reportUrl: string }> = { + parse(value) { + if ( + value !== null && + !Array.isArray(value) && + typeof value === "object" && + "reportUrl" in value && + typeof value.reportUrl === "string" + ) { + return { success: true, value: { reportUrl: value.reportUrl } }; + } + return { + success: false, + error: new ProtocolDecodeError("Expected reportUrl"), + }; + }, +}; + +const execution = await session.callTool("generate_report", undefined, { + resultCodec: reportCodec, +}); +``` + +`@modelcontextprotocol/ext-tasks/core` has no runtime imports or dependencies. Generated Zod schemas remain available from `/core/v1` and `/core/v2`: + +```ts +import { TaskV2Schema } from "@modelcontextprotocol/ext-tasks/core/v2"; + +const task = TaskV2Schema.parse(taskPayload); +``` + +## Embedding hosts + +Calls can preserve host-owned request metadata and transport routing while adding Tasks protocol fields: + +```ts +await session.callTool("generate_report", undefined, { + metadata: { traceId }, + headers: { "x-routing-key": routingKey }, + taskTtl: 60_000, // V1 task requests only +}); +``` + +`session.close()` and execution `close()` are idempotent and resolving. Teardown failures with a session owner are reported through `onError`. Closing a settled execution does not cancel it; closing an incomplete task starts one best-effort cooperative cancellation attempt without allowing a nonresponsive server to block teardown. + +## Initial-release API migration + +- Replace `withTasks(client, { endpointId, ...options })` with `const port = createSessionPortFromClient(client, endpointId, adapterOptions); const session = withTasks(port, options)`. +- For a V2 Client adapter, pass `{ rawDispatch }` explicitly when constructing the port. There is no Client property probe and no per-call fallback. +- Replace `resultSchema` with a library-neutral `resultCodec: RuntimeCodec`. +- Replace provider returns of raw `ToolV1 | ToolV2` with `toolDeclarationV1(rawTool)` or `toolDeclarationV2(rawTool)`. +- Remove generic arguments and `applicationContext` reads from `InputCorrelationError`; candidates contain only generation, tool name, and execution ID. +- Dispose a Client-backed port separately from closing the task-enabled session. + The public package subpaths are: - `@modelcontextprotocol/ext-tasks/core` @@ -32,5 +147,4 @@ The public package subpaths are: - `@modelcontextprotocol/ext-tasks/core/v2` - `@modelcontextprotocol/ext-tasks/client` -Source is emitted as ESM JavaScript, TypeScript declarations, and source maps -in `dist/`. +Source is emitted as ESM JavaScript, TypeScript declarations, and source maps in `dist/`. diff --git a/packages/ext-tasks/scripts/check-exports.mjs b/packages/ext-tasks/scripts/check-exports.mjs index 8924054..a20c2bc 100644 --- a/packages/ext-tasks/scripts/check-exports.mjs +++ b/packages/ext-tasks/scripts/check-exports.mjs @@ -19,7 +19,7 @@ const manifest = JSON.parse( const packageName = manifest.name; const publicSubpaths = ["core", "core/v1", "core/v2", "client"]; const expectedRuntimeExports = { - core: ["JsonValueSchema", "isJsonValue", "taskId"], + core: ["JsonValueCodec", "ProtocolDecodeError", "isJsonValue", "taskId"], "core/v1": [ "CallToolAsTaskRequestV1Schema", "CallToolRequestV1Schema", @@ -114,15 +114,16 @@ const expectedRuntimeExports = { "JsonRpcResponseError", "TaskCancellationUnsupportedError", "TaskExecutionClosedError", + "TaskRecoveryOwnershipError", "TaskUpdatesAlreadyAcquiredError", "createSessionPortFromClient", + "toolDeclarationV1", + "toolDeclarationV2", "withTasks", ], }; const removedCoreNames = [ "DecodePath", - "ProtocolDecodeError", - "RuntimeCodec", "createRuntimeCodec", "expectEnum", "expectNumber", @@ -372,13 +373,20 @@ async function checkPackedContract() { ) .join("\n"); const positiveSource = `${positiveImports} -import { withTasks, type ConnectedMcpSessionPort } from "${packageName}/client"; -import * as z from "zod/v4"; +import { withTasks } from "${packageName}/client"; +import type { ConnectedMcpSessionPort } from "${packageName}/client"; +import { ProtocolDecodeError } from "${packageName}/core"; +import type { RuntimeCodec } from "${packageName}/core"; declare const port: ConnectedMcpSessionPort; +const resultCodec: RuntimeCodec = { + parse(value) { + if (value !== null && !Array.isArray(value) && typeof value === "object" && "value" in value && typeof value.value === "string") + return { success: true, value: value.value.length }; + return { success: false, error: new ProtocolDecodeError("Expected value") }; + }, +}; const session = withTasks(port); -const execution = await session.callTool("example", undefined, { - resultSchema: z.object({ value: z.string() }).transform(({ value }) => value.length), -}); +const execution = await session.callTool("example", undefined, { resultCodec }); const inferred: number = await execution.result(); void inferred; `; @@ -422,7 +430,8 @@ void inferred; ], [ "removed-result-codec-option", - `import { withTasks, type ConnectedMcpSessionPort } from "${packageName}/client"; + `import { withTasks } from "${packageName}/client"; +import type { ConnectedMcpSessionPort } from "${packageName}/client"; declare const port: ConnectedMcpSessionPort; void withTasks(port).callTool("example", undefined, { resultCodec: {} });`, ], diff --git a/packages/ext-tasks/scripts/check-peer-range.mjs b/packages/ext-tasks/scripts/check-peer-range.mjs new file mode 100644 index 0000000..40f0587 --- /dev/null +++ b/packages/ext-tasks/scripts/check-peer-range.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDirectory = fileURLToPath(new URL("../", import.meta.url)); +const repositoryDirectory = resolve(packageDirectory, "../.."); +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; +const clientSpecifier = process.argv[2]; + +assert.match( + clientSpecifier ?? "", + /^(?:2|2\.0\.0)$/u, + "Pass exactly 2.0.0 or 2: 2 resolves the latest stable 2.x release.", +); + +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: packageDirectory, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); +} + +async function main() { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "ext-tasks-peer-")); + try { + const packDirectory = join(temporaryDirectory, "pack"); + const consumerDirectory = join(temporaryDirectory, "consumer"); + await Promise.all([mkdir(packDirectory), mkdir(consumerDirectory)]); + + const [{ filename }] = JSON.parse( + run(npm, [ + "pack", + "--ignore-scripts", + "--json", + "--pack-destination", + packDirectory, + ]), + ); + const tarball = join(packDirectory, filename); + await writeFile( + join(consumerDirectory, "package.json"), + JSON.stringify({ private: true, type: "module" }), + ); + + run( + npm, + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-package-lock", + `@modelcontextprotocol/client@${clientSpecifier}`, + tarball, + ], + { cwd: consumerDirectory }, + ); + + const installedClientManifest = JSON.parse( + await readFile( + join( + consumerDirectory, + "node_modules", + "@modelcontextprotocol", + "client", + "package.json", + ), + "utf8", + ), + ); + assert.match( + installedClientManifest.version, + /^2\./u, + `Expected @modelcontextprotocol/client 2.x, received ${installedClientManifest.version}`, + ); + + await writeFile( + join(consumerDirectory, "adapter.ts"), + `import { withTasks } from "@modelcontextprotocol/ext-tasks/client"; +import type { ConnectedMcpSessionPort } from "@modelcontextprotocol/ext-tasks/client"; +import type { Client } from "@modelcontextprotocol/client"; + +declare const port: ConnectedMcpSessionPort; +declare const client: Client; +const session = withTasks(port); +void session; +void client; +`, + ); + await writeFile( + join(consumerDirectory, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + noEmit: true, + skipLibCheck: true, + }, + files: ["adapter.ts"], + }), + ); + run( + process.execPath, + [ + resolve(repositoryDirectory, "node_modules/typescript/bin/tsc"), + "-p", + "tsconfig.json", + ], + { cwd: consumerDirectory }, + ); + + await writeFile( + join(consumerDirectory, "runtime.mjs"), + 'await import("@modelcontextprotocol/ext-tasks/client");\n', + ); + run(process.execPath, ["runtime.mjs"], { cwd: consumerDirectory }); + + console.log( + `Validated packed @modelcontextprotocol/ext-tasks against @modelcontextprotocol/client ${installedClientManifest.version}`, + ); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} + +await main(); diff --git a/packages/ext-tasks/src/client/api.ts b/packages/ext-tasks/src/client/api.ts index 2798548..14e0ba0 100644 --- a/packages/ext-tasks/src/client/api.ts +++ b/packages/ext-tasks/src/client/api.ts @@ -1,18 +1,20 @@ import type { JsonValue, + RuntimeCodec, TaskGeneration, TaskId, TaskSnapshot, } from "../core/index.js"; -import type { z } from "zod/v4"; import type { CallToolResultV1, TaskEligibleMethodV1, + ToolV1, } from "../core/v1/index.js"; import type { CallToolResultV2, ErrorV2, TaskEligibleMethodV2, + ToolV2, } from "../core/v2/index.js"; export class JsonRpcResponseError extends Error { @@ -29,16 +31,45 @@ export class JsonRpcResponseError extends Error { } } +export class TaskRecoveryOwnershipError extends Error { + constructor( + readonly generation: TaskGeneration, + readonly taskId: TaskId, + readonly originalOperation: string, + readonly activeOriginalOperation: string, + ) { + const collision = originalOperation !== activeOriginalOperation; + super( + collision + ? `Task recovery identity collides with active operation ${activeOriginalOperation}` + : "Task recovery already has an active owner", + ); + this.name = "TaskRecoveryOwnershipError"; + } +} + +export type ToolDeclaration = + | { + readonly generation: "v1"; + readonly tool: ToolV1; + } + | { + readonly generation: "v2"; + readonly tool: ToolV2; + }; + +/** Tags a generated V1 tool declaration for a host-supplied provider. */ +export function toolDeclarationV1(tool: ToolV1): ToolDeclaration { + return { generation: "v1", tool }; +} + +/** Tags a generated V2 tool declaration for a host-supplied provider. */ +export function toolDeclarationV2(tool: ToolV2): ToolDeclaration { + return { generation: "v2", tool }; +} + export interface ToolDeclarationProvider { - currentTool(name: string): - | { - readonly name: string; - readonly inputSchema: Readonly>; - readonly execution?: { - readonly taskSupport?: "forbidden" | "optional" | "required"; - }; - } - | undefined; + currentTool(name: string): ToolDeclaration | undefined; } export type ApplicationInputRequest = @@ -115,18 +146,17 @@ export type InputCorrelationFailureReason = | "zero-matches" | "ambiguous-matches"; -export interface InputCorrelationCandidate { +export interface InputCorrelationCandidate { readonly generation: TaskGeneration; readonly toolName: string; readonly executionId: string; - readonly applicationContext: TApplicationContext; } -export class InputCorrelationError extends Error { +export class InputCorrelationError extends Error { constructor( readonly generation: TaskGeneration, readonly requestKind: ApplicationInputRequest["kind"], - readonly candidates: readonly InputCorrelationCandidate[], + readonly candidates: readonly InputCorrelationCandidate[], readonly reason: InputCorrelationFailureReason, ) { super(`Input request correlation failed: ${reason}`); @@ -196,21 +226,30 @@ export class TaskCancellationUnsupportedError extends Error { } } +/** Options for one tool call, including host-owned wire context. */ +export interface ToolCallOptions { + readonly resultCodec?: RuntimeCodec; + readonly applicationContext?: TApplicationContext; + readonly signal?: AbortSignal; + readonly preferTask?: boolean; + /** Arbitrary request metadata preserved alongside package-owned keys. */ + readonly metadata?: Readonly>; + /** Additional headers for the initiating call and task follow-up requests. */ + readonly headers?: Readonly>; + /** Requested V1 task lifetime in milliseconds. Ignored when no V1 task is requested. */ + readonly taskTtl?: number; +} + export interface TaskEnabledSession { callTool( name: string, params?: Readonly>, - options?: { - readonly resultSchema?: z.ZodType; - readonly applicationContext?: TApplicationContext; - readonly signal?: AbortSignal; - readonly preferTask?: boolean; - }, + options?: ToolCallOptions, ): Promise>; resumeTask( reference: SerializedTaskReference, options?: { - readonly resultSchema?: z.ZodType; + readonly resultCodec?: RuntimeCodec; readonly applicationContext?: TApplicationContext; readonly signal?: AbortSignal; }, diff --git a/packages/ext-tasks/src/client/client-adapter.test.ts b/packages/ext-tasks/src/client/client-adapter.test.ts index c6c1f9f..3c3f903 100644 --- a/packages/ext-tasks/src/client/client-adapter.test.ts +++ b/packages/ext-tasks/src/client/client-adapter.test.ts @@ -3,10 +3,10 @@ import { ProtocolError, SdkError, SdkErrorCode, - type ClientContext, } from "@modelcontextprotocol/client"; +import type { ClientContext } from "@modelcontextprotocol/client"; import { describe, expect, it, vi } from "vitest"; -import { type JsonValue } from "../core/index.js"; +import type { JsonValue } from "../core/index.js"; import { createSessionPortFromClient, withTasks } from "./index.js"; const client = () => new Client({ name: "test", version: "1" }); @@ -47,6 +47,60 @@ describe("Client adapter", () => { }); }); + it("forwards headers through SDK request options", async () => { + const sdk = client(); + const request = vi + .spyOn(sdk, "request") + .mockResolvedValueOnce({ ok: true }); + const port = createSessionPortFromClient(sdk, "headers"); + await port.dispatch( + { method: "custom/method" }, + { context: { headers: { "x-trace": "trace-1" } } }, + ); + expect(request.mock.calls[0]?.[2]).toEqual({ + headers: { "x-trace": "trace-1" }, + }); + }); + + it("routes V2 task traffic through raw dispatch before SDK validation", async () => { + const sdk = client(); + vi.spyOn(sdk, "getProtocolEra").mockReturnValue("modern"); + vi.spyOn(sdk, "getServerCapabilities").mockReturnValue({ + extensions: { "io.modelcontextprotocol/tasks": {} }, + }); + const request = vi.spyOn(sdk, "request"); + const rawDispatch = vi.fn().mockResolvedValue({ + kind: "result", + result: { resultType: "task", taskId: "task-1" }, + }); + const port = createSessionPortFromClient(sdk, "modern", { rawDispatch }); + const options = { context: { headers: { "x-trace": "trace-2" } } }; + await expect( + port.dispatch({ method: "tools/call", params: { name: "x" } }, options), + ).resolves.toEqual({ + kind: "result", + result: { resultType: "task", taskId: "task-1" }, + }); + expect(rawDispatch).toHaveBeenCalledWith( + { method: "tools/call", params: { name: "x" } }, + options, + ); + expect(request).not.toHaveBeenCalled(); + }); + + it("fails V2 port construction before send when no raw coordinator exists", () => { + const sdk = client(); + vi.spyOn(sdk, "getProtocolEra").mockReturnValue("modern"); + vi.spyOn(sdk, "getServerCapabilities").mockReturnValue({ + extensions: { "io.modelcontextprotocol/tasks": {} }, + }); + const request = vi.spyOn(sdk, "request"); + expect(() => createSessionPortFromClient(sdk, "modern")).toThrow( + "requires options.rawDispatch", + ); + expect(request).not.toHaveBeenCalled(); + }); + it("wraps cancellation and local SDK failures as non-retryable DispatchError", async () => { const sdk = client(); const request = vi.spyOn(sdk, "request"); @@ -89,9 +143,19 @@ describe("Client adapter", () => { vi.spyOn(modern, "getServerCapabilities").mockReturnValue({ extensions: { "io.modelcontextprotocol/tasks": {} }, }); - expect( - createSessionPortFromClient(modern, "modern").taskCapabilities, - ).toEqual({ generation: "v2", capabilities: {} }); + expect(() => createSessionPortFromClient(modern, "modern")).toThrow( + "requires options.rawDispatch", + ); + const modernPort = createSessionPortFromClient(modern, "modern", { + rawDispatch: async () => { + await Promise.resolve(); + return { kind: "result", result: {} }; + }, + }); + expect(modernPort.taskCapabilities).toEqual({ + generation: "v2", + capabilities: {}, + }); const absent = client(); vi.spyOn(absent, "getProtocolEra").mockReturnValue("modern"); vi.spyOn(absent, "getServerCapabilities").mockReturnValue({ @@ -100,6 +164,8 @@ describe("Client adapter", () => { expect( createSessionPortFromClient(absent, "none").taskCapabilities, ).toEqual({ generation: "none" }); + legacyPort[Symbol.dispose](); + modernPort[Symbol.dispose](); }); it("forwards inbound requests and settles results and full errors", async () => { @@ -231,23 +297,27 @@ describe("Client adapter", () => { } } const foreign = new ForeignClient(); - const session = withTasks(foreign as unknown as Client, { - endpointId: "foreign-client", + const port = createSessionPortFromClient( + foreign as unknown as Client, + "foreign-client", + ); + const session = withTasks(port, { tools: { currentTool: () => undefined }, }); const execution = await session.callTool("x"); await expect(execution.result()).resolves.toEqual({ content: [] }); expect(foreign.request).toHaveBeenCalled(); await session.close(); + port[Symbol.dispose](); }); - it("supports Client sessions through withTasks and restores callbacks", async () => { + it("supports explicitly adapted Client sessions and restores callbacks", async () => { const sdk = client(); const request = vi.spyOn(sdk, "request").mockResolvedValue({ content: [] }); const prior = vi.fn(() => Promise.resolve({ prior: true })); sdk.fallbackRequestHandler = prior; - const session = withTasks(sdk, { - endpointId: "raw-client", + const port = createSessionPortFromClient(sdk, "raw-client"); + const session = withTasks(port, { tools: { currentTool: () => undefined }, }); const execution = await session.callTool("x"); @@ -268,6 +338,7 @@ describe("Client adapter", () => { context, ); await session.close(); + port[Symbol.dispose](); expect(sdk.fallbackRequestHandler).toBe(prior); expect(sdk.transport).toBeUndefined(); }); @@ -283,13 +354,18 @@ describe("Client adapter", () => { throw sentinel; }, ); - const session = withTasks(sdk, { - endpointId: "close-failure", + const port = createSessionPortFromClient(sdk, "close-failure"); + const errors: Error[] = []; + const session = withTasks(port, { signal: controller.signal, tools: { currentTool: () => undefined }, + onError: (error) => errors.push(error), }); - await expect(session.close()).rejects.toBe(sentinel); - await expect(session.close()).rejects.toBe(sentinel); + const closing = session.close(); + await expect(closing).resolves.toBeUndefined(); + expect(session.close()).toBe(closing); + expect(errors).toContain(sentinel); + port[Symbol.dispose](); expect(sdk.fallbackRequestHandler).toBe(prior); const replacement = createSessionPortFromClient(sdk, "close-failure"); replacement[Symbol.dispose](); diff --git a/packages/ext-tasks/src/client/declarations-capabilities.test.ts b/packages/ext-tasks/src/client/declarations-capabilities.test.ts index 521543d..8509577 100644 --- a/packages/ext-tasks/src/client/declarations-capabilities.test.ts +++ b/packages/ext-tasks/src/client/declarations-capabilities.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { type ToolV1 } from "../core/v1/index.js"; -import { DispatchError, withTasks, type JsonRpcResponse } from "./index.js"; +import type { ToolV1 } from "../core/v1/index.js"; +import { DispatchError, toolDeclarationV1, withTasks } from "./index.js"; +import type { JsonRpcResponse } from "./index.js"; import { FakePort, asJson, @@ -186,8 +187,7 @@ describe("declarations and capabilities", () => { await session.close(); }); - it("reports duplicate tools and aborts managed discovery on close", async () => { - const errors: Error[] = []; + it("rejects duplicate tools deterministically and aborts managed discovery on close", async () => { const duplicatePort = new FakePort({ generation: "v1", capabilities: {} }); duplicatePort.dispatchHandler = async (request) => { await Promise.resolve(); @@ -209,13 +209,15 @@ describe("declarations and capabilities", () => { } return { kind: "result", result: asJson({ content: [] }) }; }; - const duplicateSession = withTasks(duplicatePort, { - onError: (error) => errors.push(error), - }); - await duplicateSession.callTool("duplicate"); - expect(errors.map((error) => error.message)).toContain( + const duplicateSession = withTasks(duplicatePort); + await expect(duplicateSession.callTool("duplicate")).rejects.toThrow( "Duplicate tool declaration: duplicate", ); + expect( + duplicatePort.requests.every( + (request) => expectRecord(request).method === "tools/list", + ), + ).toBe(true); await duplicateSession.close(); const callAbortPort = new FakePort(); @@ -286,7 +288,9 @@ describe("declarations and capabilities", () => { callController.signal, "removeEventListener", ); - const session = withTasks(port, { tools: { currentTool: () => v1Tool } }); + const session = withTasks(port, { + tools: { currentTool: () => toolDeclarationV1(v1Tool) }, + }); for (let attempt = 0; attempt < 3; attempt += 1) { await expect( session.callTool("x", undefined, { signal: callController.signal }), diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts index b67669b..4bd1340 100644 --- a/packages/ext-tasks/src/client/execution.ts +++ b/packages/ext-tasks/src/client/execution.ts @@ -1,5 +1,8 @@ -import type { TaskSnapshot } from "../core/index.js"; -import type { z } from "zod/v4"; +import { + ProtocolDecodeError, + type RuntimeCodec, + type TaskSnapshot, +} from "../core/index.js"; import { CallToolResultV1Schema, type CallToolResultV1, @@ -20,11 +23,36 @@ import type { SessionTaskCapabilities } from "./port.js"; import { linkAbortSignals, withAbort } from "./port.js"; import { throwIfAborted } from "./input-routing.js"; -/** Selects the default tool-result schema for the negotiated task generation. */ -export function defaultResultSchema( +function codecFromSchema(schema: { + safeParse( + value: unknown, + ): + | { readonly success: true; readonly data: T } + | { readonly success: false; readonly error: unknown }; +}): RuntimeCodec { + return { + parse(value) { + const decoded = schema.safeParse(value); + return decoded.success + ? { success: true, value: decoded.data } + : { + success: false, + error: new ProtocolDecodeError( + "Protocol value failed schema validation", + { cause: decoded.error }, + ), + }; + }, + }; +} + +/** Selects the default tool-result codec for the negotiated task generation. */ +export function defaultResultCodec( generation: SessionTaskCapabilities["generation"], -): z.ZodType { - return generation === "v2" ? CallToolResultV2Schema : CallToolResultV1Schema; +): RuntimeCodec { + return generation === "v2" + ? codecFromSchema(CallToolResultV2Schema) + : codecFromSchema(CallToolResultV1Schema); } /** Normalizes an invalidation or abort reason to an Error instance. */ @@ -102,6 +130,8 @@ export class TaskExecution< private latestNotifiedSnapshot: TaskSnapshot | undefined; private updatesAcquired = false; private cancelPromise: Promise | undefined; + private closePromise: Promise | undefined; + private settled = false; private closed = false; constructor(options: TaskExecutionOptions) { @@ -136,6 +166,14 @@ export class TaskExecution< }, isClosed: () => this.closed, }); + void this.resultPromise.then( + () => { + this.settled = true; + }, + () => { + this.settled = true; + }, + ); } serializeReference(): SerializedTaskReference { @@ -363,15 +401,17 @@ export class TaskExecution< } close(): Promise { - if (!this.closed) { - this.closed = true; - this.controller.abort(this.closedError); - this.inputController.abort(this.closedError); + if (this.closePromise !== undefined) return this.closePromise; + const shouldCancel = !this.settled; + this.closed = true; + this.controller.abort(this.closedError); + this.inputController.abort(this.closedError); + if (shouldCancel) void this.cancel().catch(() => { // Cooperative cancellation is best effort during close. }); - } - return Promise.resolve(); + this.closePromise = Promise.resolve(); + return this.closePromise; } [Symbol.asyncDispose](): Promise { diff --git a/packages/ext-tasks/src/client/immediate-session-basics.test.ts b/packages/ext-tasks/src/client/immediate-session-basics.test.ts index 474b25d..fda2831 100644 --- a/packages/ext-tasks/src/client/immediate-session-basics.test.ts +++ b/packages/ext-tasks/src/client/immediate-session-basics.test.ts @@ -1,8 +1,16 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { type JsonValue } from "../core/index.js"; -import { z } from "zod/v4"; -import { DispatchError, JsonRpcResponseError, withTasks } from "./index.js"; +import { + ProtocolDecodeError, + type JsonValue, + type RuntimeCodec, +} from "../core/index.js"; +import { + DispatchError, + JsonRpcResponseError, + toolDeclarationV1, + withTasks, +} from "./index.js"; import { FakePort, asJson } from "../../test-support/client/fake-port.js"; describe("immediate and session basics", () => { @@ -44,17 +52,116 @@ describe("immediate and session basics", () => { ); }); - it("uses an application result schema at the dispatch boundary", async () => { + it("preserves call metadata and transport headers", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + await session.callTool("x", undefined, { + metadata: { trace: "request-1" }, + headers: { "x-routing-key": "route-1" }, + }); + expect(port.requests).toEqual([ + { + method: "tools/call", + params: { + name: "x", + _meta: { + trace: "request-1", + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }, + }, + ]); + expect(port.dispatchOptions[0]?.context?.headers).toEqual({ + "x-routing-key": "route-1", + }); + await session.close(); + }); + + it("adds requested TTL only to V1 task calls", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { cancel: {}, requests: { tools: { call: {} } } }, + }); + port.response = { + kind: "result", + result: { + task: { + taskId: "task-ttl", + status: "working", + createdAt: "now", + lastUpdatedAt: "now", + ttl: 5000, + }, + }, + }; + const session = withTasks(port, { + tools: { + currentTool: () => + toolDeclarationV1({ + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }), + }, + }); + const execution = await session.callTool("x", undefined, { + taskTtl: 5000, + headers: { "x-routing-key": "route-task" }, + }); + expect(port.requests[0]).toEqual({ + method: "tools/call", + params: { name: "x", task: { ttl: 5000 } }, + }); + port.response = { + kind: "result", + result: { + taskId: "task-ttl", + status: "cancelled", + createdAt: "now", + lastUpdatedAt: "later", + ttl: 5000, + }, + }; + await execution.cancel(); + expect(port.requests[1]).toEqual({ + method: "tasks/cancel", + params: { taskId: "task-ttl" }, + }); + expect(port.dispatchOptions[1]?.context?.headers).toEqual({ + "x-routing-key": "route-task", + }); + await session.close(); + }); + + it("uses an application result codec at the dispatch boundary", async () => { const port = new FakePort(); port.response = { kind: "result", result: { answer: 42 } }; - const resultSchema = z - .object({ answer: z.number() }) - .transform(({ answer }) => String(answer)); + const resultCodec: RuntimeCodec = { + parse(value) { + const answer = + value !== null && + !Array.isArray(value) && + typeof value === "object" && + "answer" in value + ? value.answer + : undefined; + return typeof answer === "number" + ? { success: true, value: String(answer) } + : { + success: false, + error: new ProtocolDecodeError("Expected answer"), + }; + }, + }; const session = withTasks(port, { tools: { currentTool: () => undefined }, }); const execution = await session.callTool("answer", undefined, { - resultSchema, + resultCodec, applicationContext: "ctx", }); expect(execution.applicationContext).toBe("ctx"); @@ -62,8 +169,8 @@ describe("immediate and session basics", () => { port.response = { kind: "result", result: { answer: "invalid" } }; await expect( - session.callTool("answer", undefined, { resultSchema }), - ).rejects.toBeInstanceOf(z.ZodError); + session.callTool("answer", undefined, { resultCodec }), + ).rejects.toBeInstanceOf(ProtocolDecodeError); await session.close(); }); diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts index c04338b..a406e16 100644 --- a/packages/ext-tasks/src/client/index.ts +++ b/packages/ext-tasks/src/client/index.ts @@ -5,7 +5,10 @@ export { JsonRpcResponseError, TaskCancellationUnsupportedError, TaskExecutionClosedError, + TaskRecoveryOwnershipError, TaskUpdatesAlreadyAcquiredError, + toolDeclarationV1, + toolDeclarationV2, } from "./api.js"; export type { ApplicationCreateMessageResult, @@ -20,6 +23,8 @@ export type { SerializedTaskReference, TaskEnabledSession, TaskHandle, + ToolCallOptions, + ToolDeclaration, ToolDeclarationProvider, ToolExecution, ToolExecutionCommon, @@ -28,10 +33,16 @@ export type { export { DispatchError } from "./port.js"; export type { ConnectedMcpSessionPort, + DispatchContext, + DispatchOptions, IncomingServerRequest, JsonRpcResponse, SessionTaskCapabilities, } from "./port.js"; export { createSessionPortFromClient } from "./sdk-client-adapter.js"; +export type { + ClientSessionPortOptions, + RawClientDispatch, +} from "./sdk-client-adapter.js"; export { withTasks } from "./session.js"; export type { TaskEligibleMethodV2 } from "../core/v2/index.js"; diff --git a/packages/ext-tasks/src/client/input-routing.ts b/packages/ext-tasks/src/client/input-routing.ts index 8d23cb6..4934c60 100644 --- a/packages/ext-tasks/src/client/input-routing.ts +++ b/packages/ext-tasks/src/client/input-routing.ts @@ -49,11 +49,10 @@ export type RelatedTaskEvidence = | { readonly kind: "invalid" } | { readonly kind: "task-id"; readonly taskId: string }; -export interface InputCandidateProjection { +export interface InputCandidateProjection { readonly generation: TaskGeneration; readonly toolName: string; readonly executionId: string; - readonly applicationContext: TApplicationContext; } export type InputCandidateResolution = @@ -64,7 +63,7 @@ export type InputCandidateResolution = | { readonly kind: "failed"; readonly reason: InputCorrelationFailureReason; - readonly candidates: readonly InputCandidateProjection[]; + readonly candidates: readonly InputCandidateProjection[]; }; /** Projects a supported wire request into the application input request shape. */ @@ -145,7 +144,9 @@ export function resolveInputCandidate( ? taskCandidates.filter( (candidate) => candidate.taskId === evidence.taskId, ) - : [...ordinaryCandidates, ...taskCandidates]; + : evidence.kind === "absent" + ? ordinaryCandidates + : []; const reason = correlationFailureReason(evidence, matches.length); if (reason !== undefined) { return { @@ -155,7 +156,6 @@ export function resolveInputCandidate( generation: candidate.generation, toolName: candidate.toolName, executionId: candidate.executionId, - applicationContext: candidate.applicationContext, })), }; } diff --git a/packages/ext-tasks/src/client/port.ts b/packages/ext-tasks/src/client/port.ts index 2f8bb7e..9c3ce69 100644 --- a/packages/ext-tasks/src/client/port.ts +++ b/packages/ext-tasks/src/client/port.ts @@ -1,5 +1,8 @@ -import type { JsonValue } from "../core/index.js"; -import type { z } from "zod/v4"; +import { + ProtocolDecodeError, + type JsonValue, + type RuntimeCodec, +} from "../core/index.js"; import type { ServerTaskCapabilitiesV1 } from "../core/v1/index.js"; import type { ErrorV2, TasksExtensionCapabilityV2 } from "../core/v2/index.js"; import { JsonRpcResponseError } from "./api.js"; @@ -25,12 +28,25 @@ export interface IncomingServerRequest { readonly requestContext: unknown; } +/** Per-dispatch transport context preserved by task follow-up requests. */ +export interface DispatchContext { + /** Additional transport headers. HTTP transports send these on this request. */ + readonly headers?: Readonly>; +} + +/** Options for one port dispatch. */ +export interface DispatchOptions { + readonly signal?: AbortSignal; + /** Host-owned context that must remain attached to task lifecycle requests. */ + readonly context?: DispatchContext; +} + export interface ConnectedMcpSessionPort { readonly endpointId: string; readonly taskCapabilities: SessionTaskCapabilities; dispatch( request: JsonValue, - options?: { readonly signal?: AbortSignal }, + options?: DispatchOptions, ): Promise; onServerRequest( handler: (incoming: IncomingServerRequest) => Promise, @@ -113,10 +129,14 @@ export function linkAbortSignals( export async function dispatchWithRetry( port: ConnectedMcpSessionPort, request: JsonValue, - signal: AbortSignal | undefined, + dispatchOptions: DispatchOptions | AbortSignal | undefined, retry: "observe" | "mutate", ): Promise { - const options = signal === undefined ? undefined : { signal }; + const options = + dispatchOptions instanceof AbortSignal + ? { signal: dispatchOptions } + : dispatchOptions; + const signal = options?.signal; try { return await port.dispatch(request, options); } catch (error) { @@ -131,9 +151,29 @@ export async function dispatchWithRetry( } } -/** Validates and parses a JSON-RPC result with the supplied Zod schema. */ -export function parseResult(schema: z.ZodType, value: JsonValue): T { - return schema.parse(value); +interface InternalSchema { + safeParse( + value: unknown, + ): + | { readonly success: true; readonly data: T } + | { readonly success: false; readonly error: unknown }; +} + +/** Validates and parses a JSON-RPC result with a public codec or internal schema. */ +export function parseResult( + codec: RuntimeCodec | InternalSchema, + value: JsonValue, +): T { + if ("safeParse" in codec) { + const decoded = codec.safeParse(value); + if (decoded.success) return decoded.data; + throw new ProtocolDecodeError("Protocol value failed schema validation", { + cause: decoded.error, + }); + } + const decoded = codec.parse(value); + if (decoded.success) return decoded.value; + throw decoded.error; } /** Unwraps a JSON-RPC result or throws the response error. */ diff --git a/packages/ext-tasks/src/client/sdk-client-adapter.ts b/packages/ext-tasks/src/client/sdk-client-adapter.ts index 109e190..31ee8ec 100644 --- a/packages/ext-tasks/src/client/sdk-client-adapter.ts +++ b/packages/ext-tasks/src/client/sdk-client-adapter.ts @@ -1,15 +1,13 @@ -import { - Client, - ProtocolError, - type StandardSchemaV1, -} from "@modelcontextprotocol/client"; -import { isJsonValue, type JsonValue } from "../core/index.js"; -import type { SessionTaskCapabilities } from "./port.js"; -import { - DispatchError, - type ConnectedMcpSessionPort, - type IncomingServerRequest, - type JsonRpcResponse, +import { Client, ProtocolError } from "@modelcontextprotocol/client"; +import type { StandardSchemaV1 } from "@modelcontextprotocol/client"; +import { isJsonValue } from "../core/index.js"; +import type { JsonValue } from "../core/index.js"; +import type { DispatchOptions, SessionTaskCapabilities } from "./port.js"; +import { DispatchError } from "./port.js"; +import type { + ConnectedMcpSessionPort, + IncomingServerRequest, + JsonRpcResponse, } from "./port.js"; const jsonValueSchema: StandardSchemaV1 = { @@ -80,6 +78,26 @@ function isTaskInputMethod(method: string): boolean { ); } +/** Host-owned request coordinator used when SDK wire codecs reject V2 task traffic. */ +export type RawClientDispatch = ( + request: JsonValue, + options?: DispatchOptions, +) => Promise; + +/** Options for adapting an SDK Client. */ +export interface ClientSessionPortOptions { + readonly rawDispatch?: RawClientDispatch; +} + +function requiresRawDispatch( + capabilities: SessionTaskCapabilities, + request: JsonValue, +): boolean { + if (capabilities.generation !== "v2") return false; + const method = asClientRequest(request).method; + return method === "tools/call" || method.startsWith("tasks/"); +} + type ClientPublicSurface = Pick< Client, | "request" @@ -188,16 +206,21 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { constructor( private readonly client: ClientPublicSurface, readonly endpointId: string, + private readonly rawDispatch?: RawClientDispatch, ) { if (adaptedClients.has(client)) throw new TypeError( "An ext-tasks adapter is already active for this Client", ); + this.taskCapabilities = clientTaskCapabilities(client); + if (this.taskCapabilities.generation === "v2" && rawDispatch === undefined) + throw new TypeError( + "SDK Client cannot safely coordinate V2 task wire shapes; createSessionPortFromClient requires options.rawDispatch for a V2 session", + ); this.previousFallbackRequestHandler = client.fallbackRequestHandler; this.previousFallbackNotificationHandler = client.fallbackNotificationHandler; this.previousOnclose = client.onclose; - this.taskCapabilities = clientTaskCapabilities(client); adaptedClients.add(client); client.fallbackRequestHandler = this.fallbackRequestHandler; client.fallbackNotificationHandler = this.fallbackNotificationHandler; @@ -210,13 +233,25 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { async dispatch( request: JsonValue, - options: { readonly signal?: AbortSignal } = {}, + options: DispatchOptions = {}, ): Promise { try { + if (requiresRawDispatch(this.taskCapabilities, request)) { + if (this.rawDispatch === undefined) + throw new DispatchError( + "SDK Client cannot dispatch V2 task wire shapes; provide rawDispatch", + ); + return await this.rawDispatch(request, options); + } const result = await this.client.request( asClientRequest(request), jsonValueSchema, - options.signal === undefined ? {} : { signal: options.signal }, + { + ...(options.signal === undefined ? {} : { signal: options.signal }), + ...(options.context?.headers === undefined + ? {} + : { headers: options.context.headers }), + }, ); return { kind: "result", result }; } catch (error) { @@ -231,6 +266,7 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { }, }; } + if (error instanceof DispatchError) throw error; throw new DispatchError("MCP client request failed", false, { cause: error, }); @@ -284,6 +320,7 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { export function createSessionPortFromClient( client: Client, endpointId: string, + options: ClientSessionPortOptions = {}, ): ConnectedMcpSessionPort & Disposable { - return new ClientSessionPort(client, endpointId); + return new ClientSessionPort(client, endpointId, options.rawDispatch); } diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index 3cfc7ed..0057810 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -1,42 +1,39 @@ -import { Client } from "@modelcontextprotocol/client"; -import { - isJsonValue, - type JsonValue, - type TaskId, - type TaskSnapshot, +import { isJsonValue } from "../core/index.js"; +import type { + JsonValue, + RuntimeCodec, + TaskId, + TaskSnapshot, } from "../core/index.js"; -import type { z } from "zod/v4"; import { CreateTaskResultV1Schema, GetTaskResultV1Schema, TaskStatusNotificationV1Schema, shouldCallToolAsTaskV1, - type CallToolResultV1, - type TaskV1, - type ToolV1, } from "../core/v1/index.js"; +import type { CallToolResultV1, TaskV1 } from "../core/v1/index.js"; import { CreateTaskResultV2Schema, GetTaskResultV2Schema, TaskStatusNotificationV2Schema, isCreateTaskResultV2, withTaskCapabilityV2, - type CallToolResultV2, - type DetailedTaskV2, } from "../core/v2/index.js"; -import { - InputCorrelationError, - type SerializedTaskReference, - type TaskEnabledSession, - type TaskHandle, - type ToolDeclarationProvider, - type ToolExecution, - type WithTasksOptions, +import type { CallToolResultV2, DetailedTaskV2 } from "../core/v2/index.js"; +import { InputCorrelationError, TaskRecoveryOwnershipError } from "./api.js"; +import type { + SerializedTaskReference, + TaskEnabledSession, + TaskHandle, + ToolCallOptions, + ToolDeclarationProvider, + ToolExecution, + WithTasksOptions, } from "./api.js"; import { ImmediateExecution, TaskExecution, - defaultResultSchema, + defaultResultCodec, reasonAsError, } from "./execution.js"; import { @@ -47,8 +44,10 @@ import { readRelatedTaskEvidence, resolveInputCandidate, throwIfAborted, - type OrdinaryInputCandidate, - type V1TaskInputCandidate, +} from "./input-routing.js"; +import type { + OrdinaryInputCandidate, + V1TaskInputCandidate, } from "./input-routing.js"; import { parseResult, @@ -56,31 +55,56 @@ import { linkAbortSignals, responseResult, withAbort, - type ConnectedMcpSessionPort, - type IncomingServerRequest, - type JsonRpcResponse, - type SessionTaskCapabilities, } from "./port.js"; -import { - ClientSessionPort, - isClientPublicSurface, - isConnectedMcpSessionPort, -} from "./sdk-client-adapter.js"; +import type { + ConnectedMcpSessionPort, + IncomingServerRequest, + JsonRpcResponse, + SessionTaskCapabilities, +} from "./port.js"; import { ManagedToolDeclarations } from "./tool-declarations.js"; import { createTaskExecutionV1 } from "./task-protocol-v1.js"; import { createTaskExecutionV2 } from "./task-protocol-v2.js"; +type TaskIdentityOwner = { + readonly originalOperation: string; + readonly token: symbol; +}; + +function taskIdentityKey(reference: { + readonly generation: "v1" | "v2"; + readonly taskId: TaskId; +}): string { + return `${reference.generation}:${reference.taskId}`; +} + function isSupportedTaskReferenceOperation(reference: { readonly originalOperation: unknown; }): boolean { return reference.originalOperation === "tools/call"; } +function selectResultCodec( + generation: SessionTaskCapabilities["generation"], + codec: RuntimeCodec | undefined, +): RuntimeCodec { + if (codec !== undefined) return codec; + const fallback = defaultResultCodec(generation); + return { + parse(value) { + const decoded = fallback.parse(value); + if (!decoded.success) return decoded; + // TResult defaults to the generated result union; callers choosing another TResult must provide resultCodec. + return { success: true, value: decoded.value as TResult }; + }, + }; +} + class PortTaskEnabledSession< TApplicationContext, > implements TaskEnabledSession { private closed = false; - private closeError: Error | undefined; + private closePromise: Promise | undefined; private readonly lifecycleController = new AbortController(); private invalidationError: Error | undefined; private readonly disposeListeners: readonly (() => void)[]; @@ -97,6 +121,7 @@ class PortTaskEnabledSession< private readonly activeTaskExecutions = new Set< TaskExecution >(); + private readonly taskIdentityOwners = new Map(); constructor( private readonly port: ConnectedMcpSessionPort, @@ -156,12 +181,7 @@ class PortTaskEnabledSession< async callTool( name: string, params?: Readonly>, - options: { - readonly resultSchema?: z.ZodType; - readonly applicationContext?: TApplicationContext; - readonly signal?: AbortSignal; - readonly preferTask?: boolean; - } = {}, + options: ToolCallOptions = {}, ): Promise> { this.assertUsable(); const callLifecycle = linkAbortSignals( @@ -180,39 +200,41 @@ class PortTaskEnabledSession< throw error; } if ( - this.port.taskCapabilities.generation === "v2" && declaration !== undefined && - "execution" in declaration + declaration.generation !== this.port.taskCapabilities.generation && + this.port.taskCapabilities.generation !== "none" ) { callLifecycle.dispose(); throw new Error( - "V1 tool declaration is incompatible with the V2 session", + `${declaration.generation.toUpperCase()} tool declaration is incompatible with the ${this.port.taskCapabilities.generation.toUpperCase()} session`, ); } const requestParams: Record = { name }; if (params !== undefined) requestParams.arguments = params; + if (options.metadata !== undefined) requestParams._meta = options.metadata; const generation = this.port.taskCapabilities.generation; const callAsTaskV1 = generation === "v1" && - declaration !== undefined && - "execution" in declaration && + declaration?.generation === "v1" && shouldCallToolAsTaskV1( this.port.taskCapabilities.capabilities, - declaration as ToolV1, + declaration.tool, options.preferTask, ); - if (callAsTaskV1) requestParams.task = {}; + if (callAsTaskV1) + requestParams.task = + options.taskTtl === undefined ? {} : { ttl: options.taskTtl }; + const dispatchContext = + options.headers === undefined ? undefined : { headers: options.headers }; const executionId = nextExecutionIdentifier(); - if (!callAsTaskV1) { - this.ordinaryInputCandidates.set(executionId, { - lifetime: "basic", - generation: generation === "none" ? "v1" : generation, - toolName: name, - executionId, - applicationContext: options.applicationContext as TApplicationContext, - signal: callSignal, - }); - } + this.ordinaryInputCandidates.set(executionId, { + lifetime: "basic", + generation: generation === "none" ? "v1" : generation, + toolName: name, + executionId, + applicationContext: options.applicationContext as TApplicationContext, + signal: callSignal, + }); const dispatchPromise = dispatchWithRetry( this.port, { @@ -222,7 +244,7 @@ class PortTaskEnabledSession< ? withTaskCapabilityV2(requestParams) : requestParams, }, - callSignal, + { signal: callSignal, context: dispatchContext }, "mutate", ); let response: JsonRpcResponse; @@ -248,9 +270,7 @@ class PortTaskEnabledSession< throw error; } const wireResult = responseResult(response); - const schema = - options.resultSchema ?? - (defaultResultSchema(generation) as z.ZodType); + const codec = selectResultCodec(generation, options.resultCodec); if (generation === "v1" && callAsTaskV1) { const created = parseResult(CreateTaskResultV1Schema, wireResult); @@ -259,23 +279,29 @@ class PortTaskEnabledSession< taskId: created.task.taskId as TaskId, originalOperation: "tools/call", }; + const releaseTaskIdentity = this.acquireTaskIdentity(handle); const execution = createTaskExecutionV1({ applicationContext: options.applicationContext as TApplicationContext, handle, initialTask: created.task, - resultSchema: schema, + resultCodec: codec, port: this.port, + dispatchContext, lifecycleSignal: this.lifecycleController.signal, }); - return this.trackTaskExecution(execution, { - lifetime: "task-v1", - generation: "v1", - taskId: created.task.taskId as TaskId, - toolName: name, - executionId, - applicationContext: options.applicationContext as TApplicationContext, - signal: execution.inputSignal(), - }); + return this.trackTaskExecution( + execution, + { + lifetime: "task-v1", + generation: "v1", + taskId: created.task.taskId as TaskId, + toolName: name, + executionId, + applicationContext: options.applicationContext as TApplicationContext, + signal: execution.inputSignal(), + }, + releaseTaskIdentity, + ); } if (generation === "v2" && isCreateTaskResultV2(wireResult)) { @@ -285,23 +311,27 @@ class PortTaskEnabledSession< taskId: created.taskId as TaskId, originalOperation: "tools/call", }; + const releaseTaskIdentity = this.acquireTaskIdentity(handle); return this.trackTaskExecution( createTaskExecutionV2({ applicationContext: options.applicationContext as TApplicationContext, handle, initialTask: created, - resultSchema: schema, + resultCodec: codec, port: this.port, + dispatchContext, lifecycleSignal: this.lifecycleController.signal, onInputRequest: this.options.onInputRequest, reportError: (error) => { this.reportBackgroundError(error); }, }), + undefined, + releaseTaskIdentity, ); } - const resultPromise = Promise.resolve(parseResult(schema, wireResult)); + const resultPromise = Promise.resolve(parseResult(codec, wireResult)); return new ImmediateExecution( options.applicationContext as TApplicationContext, resultPromise, @@ -311,7 +341,7 @@ class PortTaskEnabledSession< async resumeTask( reference: SerializedTaskReference, options: { - readonly resultSchema?: z.ZodType; + readonly resultCodec?: RuntimeCodec; readonly applicationContext?: TApplicationContext; readonly signal?: AbortSignal; } = {}, @@ -322,8 +352,21 @@ class PortTaskEnabledSession< throw new Error("Task reference belongs to a different endpoint"); if (reference.generation !== capabilities.generation) throw new Error("Task reference generation does not match this session"); + const activeTaskIdentity = this.taskIdentityOwners.get( + taskIdentityKey(reference), + ); + if (activeTaskIdentity !== undefined) { + throw new TaskRecoveryOwnershipError( + reference.generation, + reference.taskId, + reference.originalOperation, + activeTaskIdentity.originalOperation, + ); + } if (!isSupportedTaskReferenceOperation(reference)) throw new Error("Task reference operation is not supported"); + const releaseTaskIdentity = this.acquireTaskIdentity(reference); + let taskIdentityTransferred = false; const resumeLifecycle = linkAbortSignals( this.lifecycleController.signal, @@ -331,9 +374,7 @@ class PortTaskEnabledSession< ); const resumeSignal = resumeLifecycle.signal; const executionId = nextExecutionIdentifier(); - const schema = - options.resultSchema ?? - (defaultResultSchema(reference.generation) as z.ZodType); + const codec = selectResultCodec(reference.generation, options.resultCodec); try { throwIfAborted(resumeSignal); const response = await dispatchWithRetry( @@ -360,38 +401,51 @@ class PortTaskEnabledSession< applicationContext: options.applicationContext as TApplicationContext, handle: reference, initialTask: task, - resultSchema: schema, + resultCodec: codec, port: this.port, lifecycleSignal: this.lifecycleController.signal, }); - return this.trackTaskExecution(execution, { - lifetime: "task-v1", - generation: "v1", - taskId: reference.taskId, - toolName: "", - executionId, - applicationContext: options.applicationContext as TApplicationContext, - signal: execution.inputSignal(), - }); + const tracked = this.trackTaskExecution( + execution, + { + lifetime: "task-v1", + generation: "v1", + taskId: reference.taskId, + toolName: "", + executionId, + applicationContext: + options.applicationContext as TApplicationContext, + signal: execution.inputSignal(), + }, + releaseTaskIdentity, + ); + taskIdentityTransferred = true; + return tracked; } const task = parseResult(GetTaskResultV2Schema, responseResult(response)); - return this.trackTaskExecution( - createTaskExecutionV2({ - applicationContext: options.applicationContext as TApplicationContext, - handle: reference, - initialTask: task, - initialDetailedTask: task, - resultSchema: schema, - port: this.port, - lifecycleSignal: this.lifecycleController.signal, - onInputRequest: this.options.onInputRequest, - reportError: (error) => { - this.reportBackgroundError(error); - }, - }), + const execution = createTaskExecutionV2({ + applicationContext: options.applicationContext as TApplicationContext, + handle: reference, + initialTask: task, + initialDetailedTask: task, + resultCodec: codec, + port: this.port, + lifecycleSignal: this.lifecycleController.signal, + onInputRequest: this.options.onInputRequest, + reportError: (error) => { + this.reportBackgroundError(error); + }, + }); + const tracked = this.trackTaskExecution( + execution, + undefined, + releaseTaskIdentity, ); + taskIdentityTransferred = true; + return tracked; } finally { + if (!taskIdentityTransferred) releaseTaskIdentity(); resumeLifecycle.dispose(); } } @@ -435,11 +489,18 @@ class PortTaskEnabledSession< } close(): Promise { - if (!this.closed) { - this.closed = true; - for (const execution of this.activeTaskExecutions) { - void execution.close().catch(() => {}); - } + if (this.closePromise !== undefined) return this.closePromise; + this.closed = true; + this.closePromise = (async () => { + const childClosures = [...this.activeTaskExecutions].map( + async (execution) => { + try { + await execution.close(); + } catch (error) { + this.reportBackgroundError(reasonAsError(error)); + } + }, + ); this.lifecycleController.abort( new Error("Task-enabled session is closed"), ); @@ -447,22 +508,47 @@ class PortTaskEnabledSession< try { dispose(); } catch (error) { - this.closeError ??= reasonAsError(error); + this.reportBackgroundError(reasonAsError(error)); } } - } - return this.closeError === undefined - ? Promise.resolve() - : Promise.reject(this.closeError); + await Promise.all(childClosures); + })(); + return this.closePromise; } [Symbol.asyncDispose](): Promise { return this.close(); } + private acquireTaskIdentity( + reference: SerializedTaskReference | TaskHandle, + ): () => void { + const key = taskIdentityKey(reference); + const active = this.taskIdentityOwners.get(key); + if (active !== undefined) { + throw new TaskRecoveryOwnershipError( + reference.generation, + reference.taskId, + reference.originalOperation, + active.originalOperation, + ); + } + const owner: TaskIdentityOwner = { + originalOperation: reference.originalOperation, + token: Symbol(key), + }; + this.taskIdentityOwners.set(key, owner); + return () => { + if (this.taskIdentityOwners.get(key)?.token === owner.token) { + this.taskIdentityOwners.delete(key); + } + }; + } + private trackTaskExecution( execution: TaskExecution, v1InputCandidate?: V1TaskInputCandidate, + releaseTaskIdentity?: () => void, ): TaskExecution { const tracked = execution as TaskExecution; this.activeTaskExecutions.add(tracked); @@ -490,6 +576,7 @@ class PortTaskEnabledSession< this.activeTaskExecutions.delete(tracked); if (v1InputCandidate !== undefined) this.v1TaskInputCandidates.delete(v1InputCandidate.executionId); + releaseTaskIdentity?.(); }); return execution; } @@ -577,39 +664,10 @@ class PortTaskEnabledSession< } } -/** Adds task execution support to a connected session port or MCP SDK client. */ +/** Adds task execution support to a connected MCP session port. */ export function withTasks( session: ConnectedMcpSessionPort, - options?: WithTasksOptions, -): TaskEnabledSession; -export function withTasks( - client: Client, - options: WithTasksOptions & { - readonly endpointId: string; - }, -): TaskEnabledSession; -export function withTasks( - session: ConnectedMcpSessionPort | Client, - options: WithTasksOptions & { - readonly endpointId?: string; - } = {}, + options: WithTasksOptions = {}, ): TaskEnabledSession { - if (isConnectedMcpSessionPort(session)) - return new PortTaskEnabledSession(session, options); - if (!isClientPublicSurface(session)) - throw new TypeError( - "withTasks requires a ConnectedMcpSessionPort or Client-compatible object", - ); - const endpointId = options.endpointId; - if (endpointId === undefined) - throw new TypeError("withTasks(Client) requires options.endpointId"); - const port = new ClientSessionPort(session, endpointId); - try { - return new PortTaskEnabledSession(port, options, () => { - port[Symbol.dispose](); - }); - } catch (error) { - port[Symbol.dispose](); - throw error; - } + return new PortTaskEnabledSession(session, options); } diff --git a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts index dcc51c1..47a8608 100644 --- a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -5,6 +5,7 @@ import { JsonRpcResponseError, TaskExecutionClosedError, TaskUpdatesAlreadyAcquiredError, + toolDeclarationV2, withTasks, } from "./index.js"; import { deterministicJson } from "./execution.js"; @@ -56,7 +57,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -86,6 +88,65 @@ describe("task lifecycle and races", () => { await session.close(); }); + it("does not emit an unhandled rejection when cancellation is followed by close", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "cancel-close-consumers", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => { + reject(asError(options.signal?.reason)); + }, + { once: true }, + ); + }); + if (record.method === "tasks/cancel") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const result = execution.result(); + const updates = execution.updates()[Symbol.asyncIterator](); + await expect(updates.next()).resolves.toMatchObject({ + value: { task: { status: "working" } }, + }); + const unhandled: unknown[] = []; + const onUnhandledRejection = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + try { + await execution.cancel(); + await execution.close(); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled).toEqual([]); + await expect(result).rejects.toBeInstanceOf(TaskExecutionClosedError); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + await session.close(); + }); + it("session close cancels and closes active task executions", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); let cancelCalls = 0; @@ -121,7 +182,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -173,7 +235,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -227,7 +290,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -269,7 +333,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -364,7 +429,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -447,7 +513,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -489,7 +556,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -576,7 +644,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -619,7 +688,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -674,7 +744,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -757,7 +828,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -808,7 +880,8 @@ describe("task lifecycle and races", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); diff --git a/packages/ext-tasks/src/client/task-protocol-v1.ts b/packages/ext-tasks/src/client/task-protocol-v1.ts index ddb89c0..953716f 100644 --- a/packages/ext-tasks/src/client/task-protocol-v1.ts +++ b/packages/ext-tasks/src/client/task-protocol-v1.ts @@ -1,36 +1,40 @@ /** Generation-specific requester-side V1 task execution. */ -import type { z } from "zod/v4"; +import type { RuntimeCodec } from "../core/index.js"; import { CancelTaskResultV1Schema, GetTaskResultV1Schema, TaskResultV1Schema, - type TaskV1, } from "../core/v1/index.js"; -import { TaskCancellationUnsupportedError, type TaskHandle } from "./api.js"; +import type { TaskV1 } from "../core/v1/index.js"; +import { TaskCancellationUnsupportedError } from "./api.js"; +import type { TaskHandle } from "./api.js"; import { DEFAULT_TASK_POLL_INTERVAL_MS, TaskExecution, terminalStatus, } from "./execution.js"; -import { - parseResult, - dispatchWithRetry, - responseResult, - type ConnectedMcpSessionPort, -} from "./port.js"; +import { parseResult, dispatchWithRetry, responseResult } from "./port.js"; +import type { ConnectedMcpSessionPort, DispatchContext } from "./port.js"; /** Creates an execution controller for an existing V1 task. */ export function createTaskExecutionV1(options: { readonly applicationContext: TApplicationContext; readonly handle: TaskHandle & { readonly generation: "v1" }; readonly initialTask: TaskV1; - readonly resultSchema: z.ZodType; + readonly resultCodec: RuntimeCodec; readonly port: ConnectedMcpSessionPort; + readonly dispatchContext?: DispatchContext; readonly lifecycleSignal: AbortSignal; }): TaskExecution { - const { applicationContext, handle, initialTask, resultSchema, port } = - options; + const { + applicationContext, + dispatchContext, + handle, + initialTask, + resultCodec, + port, + } = options; return new TaskExecution({ applicationContext, handle, @@ -50,7 +54,7 @@ export function createTaskExecutionV1(options: { dispatchWithRetry( port, { method: "tasks/get", params: { taskId: handle.taskId } }, - observationSignal, + { signal: observationSignal, context: dispatchContext }, "observe", ).then((response) => ({ generation: "v1" as const, @@ -80,12 +84,12 @@ export function createTaskExecutionV1(options: { await dispatchWithRetry( port, { method: "tasks/result", params: { taskId: handle.taskId } }, - context.signal, + { signal: context.signal, context: dispatchContext }, "observe", ), ); parseResult(TaskResultV1Schema, taskResult); - return parseResult(resultSchema, taskResult); + return parseResult(resultCodec, taskResult); }, cancelTask: async (signal) => { const capabilities = port.taskCapabilities; @@ -103,7 +107,7 @@ export function createTaskExecutionV1(options: { method: "tasks/cancel", params: { taskId: handle.taskId }, }, - signal, + { signal, context: dispatchContext }, "mutate", ), ), diff --git a/packages/ext-tasks/src/client/task-protocol-v2.ts b/packages/ext-tasks/src/client/task-protocol-v2.ts index a989f70..5cf260a 100644 --- a/packages/ext-tasks/src/client/task-protocol-v2.ts +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -1,6 +1,6 @@ /** Generation-specific requester-side V2 task execution. */ -import type { JsonValue } from "../core/index.js"; +import type { JsonValue, RuntimeCodec } from "../core/index.js"; import type { z } from "zod/v4"; import { CancelTaskResultV2Schema, @@ -33,6 +33,7 @@ import { dispatchWithRetry, responseResult, type ConnectedMcpSessionPort, + type DispatchContext, } from "./port.js"; interface TaskExecutionV2Options { @@ -40,8 +41,9 @@ interface TaskExecutionV2Options { readonly handle: TaskHandle & { readonly generation: "v2" }; readonly initialTask: TaskV2; readonly initialDetailedTask?: DetailedTaskV2; - readonly resultSchema: z.ZodType; + readonly resultCodec: RuntimeCodec; readonly port: ConnectedMcpSessionPort; + readonly dispatchContext?: DispatchContext; readonly lifecycleSignal: AbortSignal; readonly onInputRequest?: ApplicationInputHandler["handle"]; readonly reportError: (error: Error) => void; @@ -49,6 +51,7 @@ interface TaskExecutionV2Options { interface V2TaskRpcContext { readonly port: ConnectedMcpSessionPort; + readonly dispatchContext?: DispatchContext; readonly handle: TaskHandle & { readonly generation: "v2" }; } @@ -66,25 +69,38 @@ type InputAcquisition = | { readonly kind: "duplicate" } | { readonly kind: "incompatible" }; -/** - * Tracks each input key and request fingerprint. The first acquisition wins, - * including when handling fails or is aborted; later identical requests are - * duplicates, and different requests are incompatible. - */ +/** Reserves input keys during handling and commits them only after update succeeds. */ class InputRequestLedger { - private readonly fingerprints = new Map(); + private readonly fingerprints = new Map< + string, + { + readonly fingerprint: string; + readonly state: "reserved" | "committed"; + } + >(); acquire(inputKey: string, request: InputRequestV2): InputAcquisition { const fingerprint = deterministicJson(request); - const acquiredFingerprint = this.fingerprints.get(inputKey); - if (acquiredFingerprint === undefined) { - this.fingerprints.set(inputKey, fingerprint); + const acquired = this.fingerprints.get(inputKey); + if (acquired === undefined) { + this.fingerprints.set(inputKey, { fingerprint, state: "reserved" }); return { kind: "new" }; } - return acquiredFingerprint === fingerprint + return acquired.fingerprint === fingerprint ? { kind: "duplicate" } : { kind: "incompatible" }; } + + commit(inputKey: string): void { + const acquired = this.fingerprints.get(inputKey); + if (acquired !== undefined) + this.fingerprints.set(inputKey, { ...acquired, state: "committed" }); + } + + release(inputKey: string): void { + if (this.fingerprints.get(inputKey)?.state === "reserved") + this.fingerprints.delete(inputKey); + } } /** Creates an execution controller for an existing V2 task. */ @@ -93,6 +109,7 @@ export function createTaskExecutionV2( ): TaskExecution { const rpcContext: V2TaskRpcContext = { port: options.port, + dispatchContext: options.dispatchContext, handle: options.handle, }; return new TaskExecution({ @@ -174,7 +191,7 @@ async function driveTaskExecutionV2(args: { }); return resolveTerminalTaskResult({ task: latestDetailedTask, - resultSchema: options.resultSchema, + resultCodec: options.resultCodec, cancelledError: driverContext.errors.cancelled, }); } @@ -193,7 +210,7 @@ async function fetchDetailedTask(args: { method: "tasks/get", params: withTaskCapabilityV2({ taskId: rpcContext.handle.taskId }), }, - signal, + { signal, context: rpcContext.dispatchContext }, "observe", ), ), @@ -202,17 +219,17 @@ async function fetchDetailedTask(args: { function resolveTerminalTaskResult(args: { readonly task: DetailedTaskV2; - readonly resultSchema: z.ZodType; + readonly resultCodec: RuntimeCodec; readonly cancelledError: Error; }): TResult { - const { task, resultSchema, cancelledError } = args; + const { task, resultCodec, cancelledError } = args; switch (task.status) { case "cancelled": throw cancelledError; case "failed": throw new JsonRpcResponseError(task.error); case "completed": - return parseResult(resultSchema, task.result); + return parseResult(resultCodec, task.result); default: throw new Error(`Unsupported terminal task status: ${task.status}`); } @@ -232,7 +249,7 @@ async function cancelTask(args: { method: "tasks/cancel", params: withTaskCapabilityV2({ taskId: rpcContext.handle.taskId }), }, - signal, + { signal, context: rpcContext.dispatchContext }, "mutate", ), ), @@ -269,12 +286,11 @@ type InputHandlerOutcome = | { readonly kind: "skipped" }; async function invokeInputHandler(args: { - readonly task: DetailedTaskV2; readonly inputKey: string; readonly request: InputRequestV2; readonly inputContext: V2InputContext; }): Promise { - const { task, inputKey, request, inputContext } = args; + const { inputKey, request, inputContext } = args; if (inputContext.onInputRequest === undefined) return request.method === "elicitation/create" ? { kind: "result", value: { action: "cancel" } } @@ -284,7 +300,7 @@ async function invokeInputHandler(args: { kind: "result", value: await inputContext.onInputRequest(projectInputRequest(request), { lifetime: "task-v2", - taskId: task.taskId, + taskId: inputContext.handle.taskId, inputKey, applicationContext: inputContext.applicationContext, signal: inputContext.inputSignal, @@ -302,12 +318,11 @@ async function invokeInputHandler(args: { } async function resolveInputRequest(args: { - readonly task: DetailedTaskV2; readonly inputKey: string; readonly request: InputRequestV2; readonly inputContext: V2InputContext; }): Promise { - const { task, inputKey, request, inputContext } = args; + const { inputKey, request, inputContext } = args; const acquisition = inputContext.acquiredRequestLedger.acquire( inputKey, request, @@ -321,12 +336,14 @@ async function resolveInputRequest(args: { } const outcome = await invokeInputHandler({ - task, inputKey, request, inputContext, }); - if (outcome.kind === "skipped") return undefined; + if (outcome.kind === "skipped") { + inputContext.acquiredRequestLedger.release(inputKey); + return undefined; + } try { return { @@ -340,6 +357,7 @@ async function resolveInputRequest(args: { inputContext.reportError( error instanceof Error ? error : new Error(String(error)), ); + inputContext.acquiredRequestLedger.release(inputKey); return undefined; } } @@ -353,7 +371,6 @@ async function resolveAndSubmitInputRequests(args: { const inputResponses: Record = {}; for (const [inputKey, request] of Object.entries(task.inputRequests)) { const resolution = await resolveInputRequest({ - task, inputKey, request, inputContext, @@ -367,18 +384,32 @@ async function resolveAndSubmitInputRequests(args: { Object.keys(inputResponses).length === 0 ) return; - parseResult( - UpdateTaskResultV2Schema, - responseResult( - await dispatchWithRetry( - inputContext.port, - { - method: "tasks/update", - params: withTaskCapabilityV2({ taskId: task.taskId, inputResponses }), - }, - inputContext.signal, - "mutate", + try { + parseResult( + UpdateTaskResultV2Schema, + responseResult( + await dispatchWithRetry( + inputContext.port, + { + method: "tasks/update", + params: withTaskCapabilityV2({ + taskId: inputContext.handle.taskId, + inputResponses, + }), + }, + { + signal: inputContext.signal, + context: inputContext.dispatchContext, + }, + "mutate", + ), ), - ), - ); + ); + for (const inputKey of Object.keys(inputResponses)) + inputContext.acquiredRequestLedger.commit(inputKey); + } catch (error) { + for (const inputKey of Object.keys(inputResponses)) + inputContext.acquiredRequestLedger.release(inputKey); + throw error; + } } diff --git a/packages/ext-tasks/src/client/task-resumption.test.ts b/packages/ext-tasks/src/client/task-resumption.test.ts index 803fb03..c00dad7 100644 --- a/packages/ext-tasks/src/client/task-resumption.test.ts +++ b/packages/ext-tasks/src/client/task-resumption.test.ts @@ -1,13 +1,17 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { type TaskId } from "../core/index.js"; +import type { TaskId } from "../core/index.js"; import { DispatchError, - InputCorrelationError, + TaskRecoveryOwnershipError, + toolDeclarationV1, + toolDeclarationV2, withTasks, - type JsonRpcResponse, - type SessionTaskCapabilities, - type SerializedTaskReference, +} from "./index.js"; +import type { + JsonRpcResponse, + SessionTaskCapabilities, + SerializedTaskReference, } from "./index.js"; import { FakePort, @@ -59,7 +63,7 @@ describe("task reference resumption", () => { ); }); - it("labels resumed V1 candidates without inventing a tool name", async () => { + it("does not misroute evidence-free initiating input to a resumed task", async () => { const port = new FakePort( { generation: "v1", @@ -112,13 +116,22 @@ describe("task reference resumption", () => { throw new Error(`unexpected method ${formatJson(record.method)}`); }; const errors: Error[] = []; + const contexts: unknown[] = []; const session = withTasks(port, { tools: { currentTool: (name) => name === "ordinary" - ? { name, inputSchema: { type: "object" } } + ? toolDeclarationV1({ + name, + inputSchema: { type: "object" }, + }) : undefined, }, + onInputRequest: async (_request, context) => { + await Promise.resolve(); + contexts.push(context); + return { action: "accept" } as never; + }, onError: (error) => errors.push(error), }); const resumed = await session.resumeTask({ @@ -129,16 +142,15 @@ describe("task reference resumption", () => { }); const ordinary = session.callTool("ordinary"); while (finishOrdinary === undefined) await Promise.resolve(); - await port.serve({ method: "elicitation/create", params: {} }); - expect(errors).toHaveLength(1); - const candidates = (errors[0] as InputCorrelationError).candidates; - expect(candidates.map((candidate) => candidate.toolName)).toEqual([ - "ordinary", - "", - ]); - expect(candidates.every((candidate) => !("taskId" in candidate))).toBe( - true, - ); + await expect( + port.serve({ method: "elicitation/create", params: {} }), + ).resolves.toEqual({ kind: "result", result: { action: "accept" } }); + expect(errors).toEqual([]); + expect(contexts).toHaveLength(1); + expect(contexts[0]).toMatchObject({ + lifetime: "basic", + applicationContext: undefined, + }); finishOrdinary({ kind: "result", result: { content: [] } }); await ordinary; await resumed.close(); @@ -208,12 +220,15 @@ describe("task reference resumption", () => { tools: { currentTool: () => generation === "v1" - ? { + ? toolDeclarationV1({ name: "roundtrip", - inputSchema: {}, + inputSchema: { type: "object" }, execution: { taskSupport: "required" }, - } - : { name: "roundtrip", inputSchema: {} }, + }) + : toolDeclarationV2({ + name: "roundtrip", + inputSchema: { type: "object" }, + }), }, }); const sourceExecution = await sourceSession.callTool("roundtrip"); @@ -363,4 +378,180 @@ describe("task reference resumption", () => { { numRuns: 10 }, ); }); + + it("gives one concurrent V2 resume ownership of input handling and updates", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let releaseGet: ((response: JsonRpcResponse) => void) | undefined; + let getCalls = 0; + let handlerCalls = 0; + port.dispatchHandler = async (request) => { + const method = expectRecord(request).method; + if (method === "tasks/get") { + getCalls += 1; + if (getCalls === 1) + return new Promise((resolve) => { + releaseGet = resolve; + }); + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "owned-resume", + status: "completed", + createdAt: "a", + lastUpdatedAt: "c", + ttlMs: null, + result: { content: [] }, + }), + }; + } + if (method === "tasks/update") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async () => { + await Promise.resolve(); + handlerCalls += 1; + return { roots: [] } as never; + }, + }); + const reference = { + endpointId: port.endpointId, + generation: "v2", + taskId: "owned-resume" as TaskId, + originalOperation: "tools/call", + } as const; + const first = session.resumeTask(reference); + await Promise.resolve(); + await expect(session.resumeTask(reference)).rejects.toBeInstanceOf( + TaskRecoveryOwnershipError, + ); + expect(getCalls).toBe(1); + releaseGet?.({ + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "owned-resume", + status: "input_required", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + inputRequests: { only: { method: "roots/list" } }, + }), + }); + const execution = await first; + await expect(execution.result()).resolves.toMatchObject({ content: [] }); + expect(handlerCalls).toBe(1); + expect( + port.requests.filter( + (request) => expectRecord(request).method === "tasks/update", + ), + ).toHaveLength(1); + await session.close(); + }); + + it("fails closed when an active task identity uses another original operation", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async () => + new Promise(() => { + // Keep the first recovery active while collision identity is checked. + }); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const reference = { + endpointId: port.endpointId, + generation: "v2", + taskId: "operation-collision" as TaskId, + originalOperation: "tools/call", + } as const; + void session.resumeTask(reference).catch(() => {}); + await Promise.resolve(); + const collision = { + ...reference, + originalOperation: "resources/read", + } as never; + await expect(session.resumeTask(collision)).rejects.toMatchObject({ + name: "TaskRecoveryOwnershipError", + activeOriginalOperation: "tools/call", + originalOperation: "resources/read", + }); + expect(port.requests).toHaveLength(1); + await session.close(); + }); + + it("releases a failed resume reservation", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let calls = 0; + port.dispatchHandler = async () => { + await Promise.resolve(); + calls += 1; + if (calls === 1) throw new Error("resume failed"); + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "failed-resume", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { content: [] }, + }), + }; + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const reference = { + endpointId: port.endpointId, + generation: "v2", + taskId: "failed-resume" as TaskId, + originalOperation: "tools/call", + } as const; + await expect(session.resumeTask(reference)).rejects.toThrow( + "resume failed", + ); + const execution = await session.resumeTask(reference); + await expect(execution.result()).resolves.toMatchObject({ content: [] }); + expect(calls).toBe(2); + await session.close(); + }); + + it("allows a new resume after the prior owner settles terminally", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async () => { + await Promise.resolve(); + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "terminal-resume", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { content: [] }, + }), + }; + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const reference = { + endpointId: port.endpointId, + generation: "v2", + taskId: "terminal-resume" as TaskId, + originalOperation: "tools/call", + } as const; + const first = await session.resumeTask(reference); + await first.result(); + await Promise.resolve(); + const second = await session.resumeTask(reference); + await expect(second.result()).resolves.toMatchObject({ content: [] }); + expect(port.requests).toHaveLength(2); + await session.close(); + }); }); diff --git a/packages/ext-tasks/src/client/tool-declarations.ts b/packages/ext-tasks/src/client/tool-declarations.ts index 2c8988e..718d9b6 100644 --- a/packages/ext-tasks/src/client/tool-declarations.ts +++ b/packages/ext-tasks/src/client/tool-declarations.ts @@ -1,16 +1,16 @@ import type { JsonValue } from "../core/index.js"; -import { ToolV1Schema, type ToolV1 } from "../core/v1/index.js"; -import { ToolV2Schema, type ToolV2 } from "../core/v2/index.js"; -import type { z } from "zod/v4"; -import { JsonRpcResponseError, type ToolDeclarationProvider } from "./api.js"; +import { ToolV1Schema } from "../core/v1/index.js"; +import { ToolV2Schema } from "../core/v2/index.js"; +import { + JsonRpcResponseError, + type ToolDeclaration, + type ToolDeclarationProvider, +} from "./api.js"; import type { ConnectedMcpSessionPort } from "./port.js"; import { throwIfAborted } from "./input-routing.js"; -const ToolV1Parser = ToolV1Schema as unknown as z.ZodType; -const ToolV2Parser = ToolV2Schema as unknown as z.ZodType; - export class ManagedToolDeclarations implements ToolDeclarationProvider { - private tools = new Map(); + private tools = new Map(); private refreshSequence = 0; private refreshController: AbortController | undefined; private initialReady: Promise; @@ -24,7 +24,7 @@ export class ManagedToolDeclarations implements ToolDeclarationProvider { void this.initialReady.catch(() => {}); } - currentTool(name: string): ToolV1 | ToolV2 | undefined { + currentTool(name: string): ToolDeclaration | undefined { return this.tools.get(name); } @@ -98,7 +98,7 @@ export class ManagedToolDeclarations implements ToolDeclarationProvider { this.refreshController?.abort(); const controller = new AbortController(); this.refreshController = controller; - const decoded = new Map(); + const decoded = new Map(); let cursor: string | undefined; do { const response = await this.port.dispatch( @@ -121,29 +121,22 @@ export class ManagedToolDeclarations implements ToolDeclarationProvider { const listed = result.tools; if (!Array.isArray(listed)) throw new Error("tools/list result must contain tools"); - const generation = ( - this.port.taskCapabilities as { - readonly generation: "none" | "v1" | "v2"; - } - ).generation; + const generation = this.port.taskCapabilities.generation; for (const value of listed) { - const parsed = - generation === "v1" - ? ToolV1Parser.safeParse(value) - : generation === "v2" - ? ToolV2Parser.safeParse(value) - : (() => { - const v2 = ToolV2Parser.safeParse(value); - return v2.success ? v2 : ToolV1Parser.safeParse(value); - })(); - if (!parsed.success) throw parsed.error; - const tool = parsed.data as ToolV1 | ToolV2; - if (decoded.has(tool.name)) { - this.reportError( - new Error(`Duplicate tool declaration: ${tool.name}`), - ); + let declaration: ToolDeclaration; + if (generation === "v1") { + const parsed = ToolV1Schema.safeParse(value); + if (!parsed.success) throw parsed.error; + declaration = { generation: "v1", tool: parsed.data }; + } else { + const parsed = ToolV2Schema.safeParse(value); + if (!parsed.success) throw parsed.error; + declaration = { generation: "v2", tool: parsed.data }; } - decoded.set(tool.name, tool); + const toolName = declaration.tool.name; + if (decoded.has(toolName)) + throw new Error(`Duplicate tool declaration: ${toolName}`); + decoded.set(toolName, declaration); } cursor = typeof result.nextCursor === "string" ? result.nextCursor : undefined; diff --git a/packages/ext-tasks/src/client/v1-input-task.test.ts b/packages/ext-tasks/src/client/v1-input-task.test.ts index bf08e8d..7d5c918 100644 --- a/packages/ext-tasks/src/client/v1-input-task.test.ts +++ b/packages/ext-tasks/src/client/v1-input-task.test.ts @@ -1,17 +1,15 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { type JsonValue } from "../core/index.js"; -import { - type ServerTaskCapabilitiesV1, - type ToolV1, -} from "../core/v1/index.js"; +import type { JsonValue } from "../core/index.js"; +import type { ServerTaskCapabilitiesV1, ToolV1 } from "../core/v1/index.js"; import { InputCorrelationError, TaskCancellationUnsupportedError, TaskExecutionClosedError, + toolDeclarationV1, withTasks, - type JsonRpcResponse, } from "./index.js"; +import type { JsonRpcResponse } from "./index.js"; import { FakePort, asJson, @@ -161,7 +159,7 @@ describe("V1 input and task behavior", () => { requestKind: "elicitation", }); expect( - (errors[0] as InputCorrelationError).candidates.map( + (errors[0] as InputCorrelationError).candidates.map( (candidate) => candidate.toolName, ), ).toEqual(["first", "second"]); @@ -233,11 +231,12 @@ describe("V1 input and task behavior", () => { const observed: unknown[] = []; const session = withTasks(port, { tools: { - currentTool: (name) => ({ - name, - inputSchema: { type: "object" }, - execution: { taskSupport: "required" }, - }), + currentTool: (name) => + toolDeclarationV1({ + name, + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }), }, onInputRequest: async (request, context) => { await Promise.resolve(); @@ -281,9 +280,7 @@ describe("V1 input and task behavior", () => { }, }; const settlement = await port.serve({ method, params: relatedTask }); - const succeeds = - (evidenceState === "absent" && candidateCount === 1) || - (evidenceState === "matching" && candidateCount > 0); + const succeeds = evidenceState === "matching" && candidateCount > 0; expect(observed).toHaveLength(succeeds ? 1 : 0); expect(errors).toHaveLength(succeeds ? 0 : 1); if (succeeds) { @@ -302,7 +299,7 @@ describe("V1 input and task behavior", () => { const expectedReason = evidenceState === "invalid" ? "invalid-evidence" - : evidenceState === "absent" && candidateCount === 0 + : evidenceState === "absent" ? "missing-evidence" : evidenceState === "missing" || candidateCount === 0 ? "zero-matches" @@ -310,12 +307,9 @@ describe("V1 input and task behavior", () => { expect(errors[0]).toBeInstanceOf(InputCorrelationError); expect(errors[0]).toMatchObject({ reason: expectedReason }); if (evidenceState === "invalid") { - const candidates = (errors[0] as InputCorrelationError) + const candidates = (errors[0] as InputCorrelationError) .candidates; - expect(candidates).toHaveLength(candidateCount); - expect( - candidates.every((candidate) => !("taskId" in candidate)), - ).toBe(true); + expect(candidates).toEqual([]); } expect(settlement).toEqual( method === "elicitation/create" @@ -430,11 +424,12 @@ describe("V1 input and task behavior", () => { let handlerSignal: AbortSignal | undefined; const session = withTasks(port, { tools: { - currentTool: () => ({ - name: "x", - inputSchema: { type: "object" }, - execution: { taskSupport: "required" }, - }), + currentTool: () => + toolDeclarationV1({ + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }), }, onInputRequest: async (_request, context) => { await Promise.resolve(); @@ -444,7 +439,14 @@ describe("V1 input and task behavior", () => { onError: (error) => errors.push(error), }); const execution = await session.callTool("x"); - await port.serve({ method: "elicitation/create", params: {} }); + await port.serve({ + method: "elicitation/create", + params: { + _meta: { + "io.modelcontextprotocol/related-task": { taskId: "lifecycle" }, + }, + }, + }); expect(handlerSignal?.aborted).toBe(false); await execution.close(); await expect(execution.result()).rejects.toBeInstanceOf( @@ -512,7 +514,7 @@ describe("V1 input and task behavior", () => { tools: { currentTool: () => { lookups += 1; - return tool; + return toolDeclarationV1(tool); }, }, }); @@ -601,7 +603,9 @@ describe("V1 input and task behavior", () => { } throw new Error(`unexpected method ${formatJson(record.method)}`); }; - const session = withTasks(port, { tools: { currentTool: () => tool } }); + const session = withTasks(port, { + tools: { currentTool: () => toolDeclarationV1(tool) }, + }); const execution = await session.callTool("long"); expect(execution.kind).toBe("task"); expect(execution.handle).toEqual({ @@ -687,7 +691,9 @@ describe("V1 input and task behavior", () => { }; throw new Error(`unexpected method ${formatJson(record.method)}`); }; - const session = withTasks(port, { tools: { currentTool: () => tool } }); + const session = withTasks(port, { + tools: { currentTool: () => toolDeclarationV1(tool) }, + }); const execution = await session.callTool("notified"); const iterator = execution.updates()[Symbol.asyncIterator](); await expect(iterator.next()).resolves.toMatchObject({ @@ -758,7 +764,9 @@ describe("V1 input and task behavior", () => { ); throw new Error(`unexpected method ${formatJson(record.method)}`); }; - const session = withTasks(port, { tools: { currentTool: () => tool } }); + const session = withTasks(port, { + tools: { currentTool: () => toolDeclarationV1(tool) }, + }); const execution = await session.callTool("x"); await expect(execution.cancel()).rejects.toBeInstanceOf( TaskCancellationUnsupportedError, diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts index 6890e67..05489eb 100644 --- a/packages/ext-tasks/src/client/v2-input-task.test.ts +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -1,6 +1,6 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { withTasks } from "./index.js"; +import { toolDeclarationV2, withTasks } from "./index.js"; import { FakePort, asJson, @@ -12,7 +12,7 @@ import { describe("V2 input and task behavior", () => { it("drives a V2 task to its inline terminal result", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); - const tool = { name: "long", inputSchema: { type: "object" } }; + const tool = { name: "long", inputSchema: { type: "object" as const } }; port.dispatchHandler = async (request) => { await Promise.resolve(); const record = expectRecord(request); @@ -49,7 +49,9 @@ describe("V2 input and task behavior", () => { return { kind: "result", result: { resultType: "complete" } }; throw new Error(`unexpected method ${formatJson(record.method)}`); }; - const session = withTasks(port, { tools: { currentTool: () => tool } }); + const session = withTasks(port, { + tools: { currentTool: () => toolDeclarationV2(tool) }, + }); const execution = await session.callTool("long"); expect(execution.kind).toBe("task"); expect(execution.handle).toEqual({ @@ -149,10 +151,11 @@ describe("V2 input and task behavior", () => { }; const session = withTasks<{ marker: string }>(port, { tools: { - currentTool: () => ({ - name: "x", - inputSchema: { type: "object" }, - }), + currentTool: () => + toolDeclarationV2({ + name: "x", + inputSchema: { type: "object" }, + }), }, onInputRequest: async (request, context) => { await Promise.resolve(); @@ -268,7 +271,8 @@ describe("V2 input and task behavior", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, onInputRequest: async () => { await Promise.resolve(); @@ -281,10 +285,10 @@ describe("V2 input and task behavior", () => { await expect(execution.result()).resolves.toMatchObject({ resultType: "complete", }); - expect(handlerCalls).toBe(1); + expect(handlerCalls).toBe(3); expect( errors.some((error) => error.message.includes("reused incompatibly")), - ).toBe(true); + ).toBe(false); expect(errors.some((error) => error.message === "declined")).toBe(true); expect( port.requests.filter( @@ -350,7 +354,8 @@ describe("V2 input and task behavior", () => { const errors: Error[] = []; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, onInputRequest: async () => { await Promise.resolve(); @@ -416,7 +421,8 @@ describe("V2 input and task behavior", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ name: "x", inputSchema: { type: "object" } }), + currentTool: () => + toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), }, onInputRequest: (_request, context) => { handlerSignal = context.signal; @@ -516,10 +522,11 @@ describe("V2 input and task behavior", () => { }; const session = withTasks(port, { tools: { - currentTool: () => ({ - name: "x", - inputSchema: { type: "object" }, - }), + currentTool: () => + toolDeclarationV2({ + name: "x", + inputSchema: { type: "object" }, + }), }, }); const execution = await session.callTool("x"); diff --git a/packages/ext-tasks/src/core/index.test.ts b/packages/ext-tasks/src/core/index.test.ts index 9cf0ead..bc756d5 100644 --- a/packages/ext-tasks/src/core/index.test.ts +++ b/packages/ext-tasks/src/core/index.test.ts @@ -2,7 +2,8 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; import { - JsonValueSchema, + JsonValueCodec, + ProtocolDecodeError, isJsonValue, taskId, type JsonValue, @@ -24,7 +25,7 @@ describe("core runtime contracts", () => { fc.assert( fc.property(jsonValue, (value) => { expect(isJsonValue(value)).toBe(true); - expect(JsonValueSchema.parse(value)).toEqual(value); + expect(JsonValueCodec.parse(value)).toEqual({ success: true, value }); }), ); fc.assert( @@ -32,7 +33,10 @@ describe("core runtime contracts", () => { fc.oneof(fc.constant(undefined), fc.bigInt(), fc.constant(Symbol("x"))), (value) => { expect(isJsonValue(value)).toBe(false); - expect(JsonValueSchema.safeParse(value).success).toBe(false); + const decoded = JsonValueCodec.parse(value as never); + expect(decoded.success).toBe(false); + if (!decoded.success) + expect(decoded.error).toBeInstanceOf(ProtocolDecodeError); }, ), ); @@ -64,14 +68,19 @@ describe("core runtime contracts", () => { ]; for (const value of nonJsonValues) { - expect(JsonValueSchema.safeParse(value).success).toBe(false); - expect(() => JsonValueSchema.parse(value)).toThrow(); + const decoded = JsonValueCodec.parse(value as never); + expect(decoded.success).toBe(false); + if (!decoded.success) + expect(decoded.error).toBeInstanceOf(ProtocolDecodeError); } }); it("accepts plain objects with null prototypes", () => { const value = Object.assign(Object.create(null) as object, { ok: true }); - expect(JsonValueSchema.parse(value)).toEqual(value); + expect(JsonValueCodec.parse(value as JsonValue)).toEqual({ + success: true, + value, + }); }); it("brands task identifiers without changing their wire value", () => { fc.assert( diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts index f920d0d..7e2dce5 100644 --- a/packages/ext-tasks/src/core/index.ts +++ b/packages/ext-tasks/src/core/index.ts @@ -1,4 +1,3 @@ -import * as z from "zod/v4"; import type { TaskV1 } from "./v1/index.js"; import type { DetailedTaskV2, TaskV2 } from "./v2/index.js"; @@ -13,6 +12,21 @@ export type JsonValue = | readonly JsonValue[] | { readonly [key: string]: JsonValue }; +export class ProtocolDecodeError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "ProtocolDecodeError"; + } +} + +export type RuntimeDecodeResult = + | { readonly success: true; readonly value: T } + | { readonly success: false; readonly error: ProtocolDecodeError }; + +export interface RuntimeCodec { + parse(value: JsonValue): RuntimeDecodeResult; +} + export type TaskSnapshot = | { readonly generation: "v1"; readonly task: TaskV1 } | { readonly generation: "v2"; readonly task: TaskV2 | DetailedTaskV2 }; @@ -60,8 +74,14 @@ export function isJsonValue(value: unknown): value is JsonValue { return visit(value); } -/** Validates the package's recursive JSON data model. */ -export const JsonValueSchema: z.ZodType = z.custom( - isJsonValue, - "Expected a JSON value", -); +/** Validates the package's recursive JSON data model without a schema-library dependency. */ +export const JsonValueCodec: RuntimeCodec = { + parse(value) { + return isJsonValue(value) + ? { success: true, value } + : { + success: false, + error: new ProtocolDecodeError("Expected a JSON value"), + }; + }, +}; diff --git a/packages/ext-tasks/src/core/v1/schemas.ts b/packages/ext-tasks/src/core/v1/schemas.ts index f639df4..63fad18 100644 --- a/packages/ext-tasks/src/core/v1/schemas.ts +++ b/packages/ext-tasks/src/core/v1/schemas.ts @@ -1,8 +1,13 @@ /** MCP Tasks V1 runtime schemas and schema-derived wire types. */ import * as z from "zod/v4"; -import { JsonValueSchema } from "../index.js"; +import { isJsonValue } from "../index.js"; +import type { JsonValue } from "../index.js"; +const JsonValueSchema: z.ZodType = z.custom( + isJsonValue, + "Expected a JSON value", +); const JsonRecordSchema = z.record(z.string(), JsonValueSchema); const ObjectJsonSchema = z .object({ type: z.literal("object") }) diff --git a/packages/ext-tasks/src/core/v2/schemas.ts b/packages/ext-tasks/src/core/v2/schemas.ts index 76300b7..d9baa33 100644 --- a/packages/ext-tasks/src/core/v2/schemas.ts +++ b/packages/ext-tasks/src/core/v2/schemas.ts @@ -1,8 +1,13 @@ /** MCP Tasks V2 Zod schemas and schema-inferred wire declarations. */ import { z } from "zod/v4"; -import { JsonValueSchema, isJsonValue, type JsonValue } from "../index.js"; +import { isJsonValue } from "../index.js"; +import type { JsonValue } from "../index.js"; +const JsonValueSchema: z.ZodType = z.custom( + isJsonValue, + "Expected a JSON value", +); export const TASKS_EXTENSION_ID_V2 = "io.modelcontextprotocol/tasks" as const; export const CLIENT_CAPABILITIES_META_KEY_V2 = "io.modelcontextprotocol/clientCapabilities" as const; diff --git a/packages/ext-tasks/test-support/client/fake-port.ts b/packages/ext-tasks/test-support/client/fake-port.ts index f74fc99..76b7325 100644 --- a/packages/ext-tasks/test-support/client/fake-port.ts +++ b/packages/ext-tasks/test-support/client/fake-port.ts @@ -1,10 +1,11 @@ -import { type JsonValue } from "../../src/core/index.js"; +import type { JsonValue } from "../../src/core/index.js"; import { z } from "zod/v4"; -import { - type ConnectedMcpSessionPort, - type IncomingServerRequest, - type JsonRpcResponse, - type SessionTaskCapabilities, +import type { + ConnectedMcpSessionPort, + DispatchOptions, + IncomingServerRequest, + JsonRpcResponse, + SessionTaskCapabilities, } from "../../src/client/index.js"; export const asJson = (value: unknown): JsonValue => @@ -26,12 +27,13 @@ export const asError = (reason: unknown): Error => export class FakePort implements ConnectedMcpSessionPort { readonly endpointId: string; readonly requests: JsonValue[] = []; + readonly dispatchOptions: (DispatchOptions | undefined)[] = []; readonly taskCapabilities: SessionTaskCapabilities; invalidated = false; response: JsonRpcResponse = { kind: "result", result: { content: [] } }; dispatchHandler?: ( request: JsonValue, - options?: { readonly signal?: AbortSignal }, + options?: DispatchOptions, ) => Promise; private requestHandler?: ( incoming: IncomingServerRequest, @@ -50,9 +52,10 @@ export class FakePort implements ConnectedMcpSessionPort { async dispatch( request: JsonValue, - options?: { readonly signal?: AbortSignal }, + options?: DispatchOptions, ): Promise { this.requests.push(request); + this.dispatchOptions.push(options); return this.dispatchHandler === undefined ? this.response : this.dispatchHandler(request, options); From 1c3da43f3f8685ae496746dc8cae447e04dc153f Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 8 Sep 2026 18:10:48 -0700 Subject: [PATCH 21/33] feat: add generation-neutral task sessions - Add semantic task sessions, controllers, settlement, and input routing - Add SDK raw-dispatch integration and receiver task bindings - Add runtime boundary adapters and generation-neutral projections - Remove obsolete task-result bridging and session history APIs - Expand protocol, lifecycle, packaging, and consumer validation Signed-off-by: Luca Chang --- packages/ext-tasks/README.md | 194 +++++-- packages/ext-tasks/package.json | 7 + packages/ext-tasks/scripts/check-exports.mjs | 63 ++- packages/ext-tasks/src/client/api.ts | 383 +++++++++++--- .../src/client/client-adapter.test.ts | 323 ++++++++++- .../client/declarations-capabilities.test.ts | 71 ++- packages/ext-tasks/src/client/execution.ts | 352 +++++++++--- .../client/immediate-session-basics.test.ts | 209 +++++++- packages/ext-tasks/src/client/index.ts | 41 +- .../ext-tasks/src/client/input-routing.ts | 80 ++- packages/ext-tasks/src/client/internal.ts | 283 ++++++++++ packages/ext-tasks/src/client/port.ts | 145 ++++- .../src/client/protocol-matrix.test.ts | 181 +++++++ .../client/request-input-continuation.test.ts | 259 +++++++++ .../src/client/sdk-client-adapter.ts | 237 +++++++-- .../src/client/session-facade.test.ts | 178 +++++++ packages/ext-tasks/src/client/session.ts | 425 +++++++++++---- .../src/client/task-controller.test.ts | 500 ++++++++++++++++++ .../ext-tasks/src/client/task-controller.ts | 164 ++++++ .../src/client/task-lifecycle-races.test.ts | 310 +++++++++-- .../ext-tasks/src/client/task-protocol-v1.ts | 64 +-- .../ext-tasks/src/client/task-protocol-v2.ts | 145 ++--- .../src/client/task-resumption.test.ts | 42 +- .../ext-tasks/src/client/tool-declarations.ts | 12 +- .../src/client/v1-input-task.test.ts | 51 +- .../src/client/v2-input-task.test.ts | 44 +- packages/ext-tasks/src/core/index.test.ts | 75 +++ packages/ext-tasks/src/core/index.ts | 98 +++- packages/ext-tasks/src/core/v2/schemas.ts | 20 +- packages/ext-tasks/src/receiver/index.ts | 484 +++++++++++++++++ .../ext-tasks/src/receiver/receiver.test.ts | 459 ++++++++++++++++ .../ext-tasks/test-support/client/semantic.ts | 45 ++ 32 files changed, 5336 insertions(+), 608 deletions(-) create mode 100644 packages/ext-tasks/src/client/internal.ts create mode 100644 packages/ext-tasks/src/client/protocol-matrix.test.ts create mode 100644 packages/ext-tasks/src/client/request-input-continuation.test.ts create mode 100644 packages/ext-tasks/src/client/session-facade.test.ts create mode 100644 packages/ext-tasks/src/client/task-controller.test.ts create mode 100644 packages/ext-tasks/src/client/task-controller.ts create mode 100644 packages/ext-tasks/src/receiver/index.ts create mode 100644 packages/ext-tasks/src/receiver/receiver.test.ts create mode 100644 packages/ext-tasks/test-support/client/semantic.ts diff --git a/packages/ext-tasks/README.md b/packages/ext-tasks/README.md index 4ccc878..402cb72 100644 --- a/packages/ext-tasks/README.md +++ b/packages/ext-tasks/README.md @@ -1,80 +1,152 @@ # `@modelcontextprotocol/ext-tasks` -Requester-side lifecycle support and versioned protocol schemas for MCP Tasks. +Requester- and receiver-side lifecycle support plus versioned protocol schemas for MCP Tasks. This package has no root entry point. Import the role or protocol generation needed by the application: ```ts -import { - createSessionPortFromClient, - withTasks, -} from "@modelcontextprotocol/ext-tasks/client"; +import { createTaskSessionFromClient } from "@modelcontextprotocol/ext-tasks/client"; +import { bindTaskReceiver } from "@modelcontextprotocol/ext-tasks/receiver"; ``` +## Receiver binding + +`bindTaskReceiver(client, options)` adds Tasks V1 lifecycle handling to an SDK `Client`. The binding owns task identifiers, timestamps, bounded retention, result promises, status transitions and notifications, `tasks/list|get|result|cancel`, cancellation signals, and cleanup. Consumers provide only enabled request methods and asynchronous sampling/elicitation callbacks. The SDK Client is the supported host because the package can then honestly preserve and restore displaced handlers; the private `_requestHandlers` compatibility shim remains encapsulated inside the package. + +```ts +const receiver = bindTaskReceiver(client, { + methods: { + "sampling/createMessage": true, + "elicitation/create": true, + }, + ttlMs: 60_000, + pollIntervalMs: 1_000, + pageSize: 100, + maxTasks: 1_000, + sampling: async (request, { signal }) => + runSampling(request.params, { signal }), + elicitation: async (request, { signal }) => + runElicitation(request.params, { signal }), + onError(error, context) { + reportReceiverError(error, context); + }, +}); + +// Merge this under the host's advertised Tasks capability. +const taskCapabilities = receiver.capabilities; + +try { + await serve(); +} finally { + receiver.close(); +} +``` + +Only methods set to `true` are installed and advertised; each enabled method requires its matching callback. Receiver-created task-augmented `sampling/createMessage` and `elicitation/create` tasks start as `input_required` because their callbacks represent outstanding client or user input; they transition to `completed` or `failed` when the callback settles. `ttlMs` is the total task lifetime measured from creation, not from settlement. Set it to a non-negative integer or `null`, or to a function returning one of those values; a function is sampled once separately for each task, and that sample is both reported on the task and used for its creation-relative expiry. A finite TTL is armed immediately; expiry aborts pending callback work, rejects the retained payload promise, and removes the task. `null` disables time expiry. `pollIntervalMs` accepts only a non-negative integer or `null`; `null` polling omits the wire hint. `pageSize` (default 100) and `maxTasks` (default 1,000) are positive integers. `tasks/list` uses stable insertion-order task-ID cursors and rejects unknown or expired cursors. At capacity, new task creation is rejected deterministically rather than evicting retained work. + +Cancellation wins once `tasks/cancel` accepts it: the task is marked cancelled before its callback is aborted, late success is discarded, and a late callback failure is reported through `onError` with `lateAfter: "cancel"`. Expiry and close similarly report late callback failures with their disposition, so callback outcomes are always observed. Cancelled records remain retained only until their original creation-based TTL. + +Status notifications are fire-and-forget and never delay lifecycle transitions. The binding passes the SDK notification input `{ method, params }` without `jsonrpc`; the Client owns the JSON-RPC envelope. Send failures are caught and reported through `onError` with the originating transition method. `close()` is idempotent, aborts pending callbacks, drops retained records, and restores handlers displaced when the binding was installed without overwriting handlers installed later. Every installed handler rejects after close, including a handler reference captured before restoration. + +### Receiver migration + +| Consumer-owned receiver concern | Package replacement | +| ------------------------------------------------------------------ | --------------------------------------------------------- | +| Task ID, timestamps, TTL map, payload promises | `bindTaskReceiver` internal lifecycle | +| Sampling/createMessage task wrapper | `methods["sampling/createMessage"]` + `sampling` callback | +| Elicitation/create task wrapper | `methods["elicitation/create"]` + `elicitation` callback | +| Status notification emission | Automatic `notifications/tasks/status` transitions | +| `tasks/list`, `tasks/get`, `tasks/result`, `tasks/cancel` handlers | Installed automatically | +| Cancellation controller lookup | Callback `AbortSignal` | +| Task-augmented create result | Automatic `{ task: TaskV1 }` result | +| Direct `_requestHandlers` access and restoration | Internal compatibility shim + `close()` | + ## Session setup and endpoint identity -`withTasks` accepts only a connected `ConnectedMcpSessionPort`. Every port has an explicit, stable `endpointId`; serialized task references use it to fail closed when resumed against another endpoint. +`createTaskSessionFromClient(client, options)` is the primary MCP SDK Client entry point. `options.endpointId` is an opaque, stable identity used only to scope serialized task references; the package never interprets it. The returned `TaskEnabledSession` owns its Client adapter, so `close()` restores the Client callbacks even when session cleanup reports another failure. Construction failures dispose the partially installed adapter before they are rethrown. The factory never closes the Client transport. -For an MCP SDK Client, create and own the port separately: +When the host does not already have a stable opaque identity, `createTaskSessionEndpointId(namespace, descriptor)` derives one from host-supplied connection semantics. It normalizes the descriptor as JSON, recursively sorts object keys, and returns a branded, versioned SHA-256 identity. Include only stable endpoint properties; the package deliberately does not inspect a transport or choose descriptor fields. ```ts -const port = createSessionPortFromClient(client, endpointId); -const session = withTasks(port, { onError }); +const session = createTaskSessionFromClient(client, { + endpointId, + onError, + onInputRequest, + // Required only when the connected session negotiates V2 Tasks: + rawDispatch: hostRequestCoordinator.dispatch, + v2RequestFraming: { + protocolVersion, + clientInfo, + clientCapabilities, + }, +}); try { const execution = await session.callTool("generate_report", { format: "pdf", }); - const result = await execution.result(); + const outcome = await execution.result(); } finally { - await session.close(); // releases ext-tasks resources; never closes the borrowed port - port[Symbol.dispose](); // restores SDK Client callbacks; never closes the Client transport + await session.close(); } ``` +For custom transports or advanced ownership, `withTasks(port, options)` still accepts any connected `ConnectedMcpSessionPort` and borrows it. `createSessionPortFromClient(client, endpointId, { rawDispatch, v2RequestFraming })` remains available as the low-level Client adapter; callers composing those APIs separately must dispose the port after closing the session. + The adapter support matrix is explicit: -| Connected session | `createSessionPortFromClient` requirement | Dispatch path | -| ------------------- | ----------------------------------------- | ---------------------------------------------------------- | -| V1 Tasks | `client`, `endpointId` | SDK `Client.request` | -| No Tasks capability | `client`, `endpointId` | SDK `Client.request` | -| V2 Tasks | `client`, `endpointId`, `{ rawDispatch }` | Host request coordinator for V2 `tools/call` and `tasks/*` | +| Connected session | Client adapter requirement | Dispatch path | +| ------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | +| V1 Tasks | `client`, opaque `endpointId` | SDK `Client.request` | +| No Tasks capability | `client`, opaque `endpointId` | SDK `Client.request` | +| V2 Tasks | `client`, opaque `endpointId`, `rawDispatch`, and `v2RequestFraming` when V2 is negotiated | Host request coordinator for V2 `tools/call` and `tasks/*` | -SDK Client 2.x has no public raw request coordinator that can safely share request IDs, authentication recovery, cancellation, and inbound-response ownership. Therefore V2 without `rawDispatch` throws while constructing the port, before a session or call exists. `rawDispatch` must be supplied explicitly; no property is discovered on the Client object. +SDK Client 2.x has no public raw request coordinator or public getters for its constructor-time client identity and capabilities. Therefore V2 without `rawDispatch` and `v2RequestFraming` throws while constructing the port, before a session or call exists. The adapter validates, copies, and deeply freezes framing at creation. It frames every raw V2 task request with the protocol version, client information, and client capabilities, forces the Tasks extension, and preserves unrelated caller `_meta`; the three package-reserved framing keys overwrite caller collisions. Consumers pass unframed task requests. Non-task requests continue through `Client.request` unchanged. ```ts const port = createSessionPortFromClient(client, endpointId, { rawDispatch: hostRequestCoordinator.dispatch, + v2RequestFraming: { protocolVersion, clientInfo, clientCapabilities }, }); ``` Do not implement `rawDispatch` with a standalone `client.transport.send`: that bypasses the SDK coordinator and competes for responses. -## Tool declaration providers +## Package boundary adapters + +Use `toJsonValue(value)` from `@modelcontextprotocol/ext-tasks/core` when arbitrary JavaScript data crosses into the package JSON model. Its semantics are explicitly `JSON.stringify(value)` followed by `JSON.parse(...)`: object `undefined` properties are omitted, array holes and `undefined` become `null`, `toJSON` is honored, and prototypes are removed. A top-level value that JSON cannot represent, serialization failure, or invalid normalized result throws `TypeError`. + +`runtimeCodecFromStandardSchema(schema)` adapts a canonical synchronous Standard Schema V1 validator to `RuntimeCodec`; validation failures retain copied readonly issues, including paths, in `ProtocolDecodeError.details.issues`, while thrown validators are retained as the error `cause`. `toolDeclarationFromMcpTool(tool)` from the client subpath converts an SDK `Tool`, requires an object-shaped `inputSchema`, maps `_meta` to neutral metadata, maps task support, preserves unknown top-level fields in `extensions`, and preserves unknown nested `execution` fields in `executionExtensions`. -A host-supplied provider returns an explicitly tagged generated declaration. Generation is never inferred from optional tool properties. +These adapters remove consumer-owned stringify/parse helpers, Standard Schema result translation, and MCP Tool projection code. + +## Tool declarations + +> `ToolDeclaration` is structural and generation-neutral. The session projects it to the negotiated wire protocol internally; applications never choose a V1/V2 declaration factory. ```ts import { - toolDeclarationV1, - toolDeclarationV2, + toolDeclaration, withTasks, } from "@modelcontextprotocol/ext-tasks/client"; +const declaration = toolDeclaration({ + name: "generate_report", + description: "Generate a report", + inputSchema: { type: "object" }, + taskSupport: "required", +}); + const session = withTasks(port, { tools: { currentTool(name) { - const rawTool = pool.currentRawTool(name); - if (rawTool === undefined) return undefined; - return pool.generation === "v1" - ? toolDeclarationV1(rawTool) - : toolDeclarationV2(rawTool); + return name === declaration.name ? declaration : undefined; }, }, }); ``` -Hosts that own tool discovery can retain each raw generated declaration and tag it without projecting or reparsing it. When no provider is supplied, the package performs generation-specific `tools/list` parsing itself. Duplicate names reject the refresh deterministically; no first/last winner is selected. +When no provider is supplied, the package parses `tools/list` using the negotiated protocol and projects every tool to the same neutral shape. Duplicate names reject deterministically. An execution-scoped `declaration` in `callTool` or `resumeTask` options wins over provider lookup and remains available as `execution.declaration`. ## Runtime codecs @@ -117,28 +189,74 @@ import { TaskV2Schema } from "@modelcontextprotocol/ext-tasks/core/v2"; const task = TaskV2Schema.parse(taskPayload); ``` +## Semantic outcomes and events + +`execution.result()` resolves exactly one cached `TaskOutcome`: `{ status: "completed", result, task? }`, `{ status: "failed", error, task? }`, or `{ status: "cancelled", task? }`. Protocol errors and cancellation are values in this semantic union rather than generation-specific result/rejection shapes. `resultFromTaskOutcome(outcome)` is the convenience boundary for code that prefers the traditional result-or-throw shape: it returns the completed result and throws `TaskFailedError` or `TaskCancelledError` otherwise. + +`execution.updates()` is single-acquire and yields normalized `{ type: "task", task: TaskView }` events followed by exactly one `{ type: "outcome", outcome }` event. `taskViewFromExecutionEvent(event)` returns the event's direct task or its outcome task when present. `TaskView` contains `taskId`, semantic `status`, optional `statusMessage` and timestamps, `retentionMs`, `suggestedPollIntervalMs`, plus `raw` and `extensions` for application UI use. The readonly `ttl` and `pollInterval` fields are compatibility aliases populated from those normalized primary names. It has no generation discriminator. + +`execution.settle({ onEvent, signal, close })` concurrently drains that stream and resolves `{ outcome, lastTask }`. It awaits synchronous or asynchronous `onEvent` callbacks. Observer failure or caller abort locally detaches immediately: polling stops and managed ownership is released without sending `tasks/cancel`. Settlement best-effort closes after natural completion by default; pass `close: false` to retain the execution. `detach()` is always local-only, while `close()` may request best-effort cooperative cancellation for an incomplete task. + ## Embedding hosts -Calls can preserve host-owned request metadata and transport routing while adding Tasks protocol fields: +Calls preserve host metadata and transport routing while accepting neutral task options: ```ts await session.callTool("generate_report", undefined, { metadata: { traceId }, headers: { "x-routing-key": routingKey }, - taskTtl: 60_000, // V1 task requests only + task: { preference: "prefer", retentionMs: 60_000 }, +}); +``` + +`preference` is `"allow" | "prefer" | "require" | "forbid"`. Requested retention is mapped only where the negotiated protocol supports it. Use `withRelatedTaskMetadata(existingMetadata, task)` to install `io.modelcontextprotocol/related-task` without mutating or dropping unknown metadata. Task-scoped peer requests and task-update inputs consistently expose related-task evidence through semantic input contexts. + +For modern ordinary `tools/call` results with `resultType: "input_required"`, `callTool` completes request-scoped continuation before deciding whether the eventual result is immediate or task-backed. It preserves the original call parameters, per-round opaque `requestState`, validated `inputResponses`, application context, headers, and effective cancellation signal for up to 10 continuation rounds. This is distinct from a task snapshot whose `status` is `"input_required"`, which continues through `tasks/update` during task execution. + +## Capabilities, input contexts, and manual tasks + +`session.capabilities` and `controller.capabilities` expose semantic `TaskCapabilities`: + +```ts +interface TaskCapabilities { + inventory: "server-list" | "known-handles" | "unsupported"; + execution: boolean; + cancellation: boolean; + inputResponses: boolean; + requestedRetention: boolean; +} +``` + +Input handlers receive `{ scope: "request" | "task", delivery: "peer-request" | "request-retry" | "task-update", inputId?, taskId?, applicationContext, signal? }`. `request-retry` identifies an ordinary result continuation; `task-update` identifies task-status input. No protocol lifetime names are exposed. `createApplicationInputHandler({ elicitation, sampling, roots })` provides exhaustive, result-preserving routing to kind-specific callbacks. Before a task-scoped callback runs, the package installs standard related-task metadata while preserving existing metadata; request-scoped inputs are unchanged. + +Use `session.task(taskId)` for non-owning manual operations. `snapshot()` returns `TaskView`; `result()` returns `TaskOutcome`; `cancel()` is semantic; `update()` and `updateJson()` are available when `capabilities.inputResponses` is true. + +```ts +import { taskId } from "@modelcontextprotocol/ext-tasks/core"; + +const controller = session.task(taskId(currentSessionTaskId), { + headers: { "x-routing-key": routingKey }, }); +const view = await controller.snapshot(); +const outcome = await controller.result(); + +const resumed = await session.resumeTask(persistedReference); ``` -`session.close()` and execution `close()` are idempotent and resolving. Teardown failures with a session owner are reported through `onError`. Closing a settled execution does not cancel it; closing an incomplete task starts one best-effort cooperative cancellation attempt without allowing a nonresponsive server to block teardown. +`execution.handle` is opaque and contains only `taskId` plus `operation`. Serialized references may retain endpoint and version information for persistence, but applications should store and pass them back without inspection. `resumeTask` is the owning cross-session recovery API. + +Observation and mutation dispatches retry once only when a `DispatchError` is explicitly marked `retryable`. `session.close()` and execution `close()` are idempotent and resolving. -## Initial-release API migration +## Generation-neutral migration -- Replace `withTasks(client, { endpointId, ...options })` with `const port = createSessionPortFromClient(client, endpointId, adapterOptions); const session = withTasks(port, options)`. -- For a V2 Client adapter, pass `{ rawDispatch }` explicitly when constructing the port. There is no Client property probe and no per-call fallback. -- Replace `resultSchema` with a library-neutral `resultCodec: RuntimeCodec`. -- Replace provider returns of raw `ToolV1 | ToolV2` with `toolDeclarationV1(rawTool)` or `toolDeclarationV2(rawTool)`. -- Remove generic arguments and `applicationContext` reads from `InputCorrelationError`; candidates contain only generation, tool name, and execution ID. -- Dispose a Client-backed port separately from closing the task-enabled session. +- Replace `toolDeclarationV1(rawTool)` / `toolDeclarationV2(rawTool)` with `toolDeclaration(neutralDeclaration)`. +- Replace `preferTask` and `taskTtl` with `task: { preference, retentionMs }`. +- Replace raw/rejecting execution results with `TaskOutcome`: inspect `outcome.status` and then `result` or `error`. +- Replace generation-tagged snapshots with `TaskView`, and snapshot callbacks with `onEvent`. +- Replace `taskGeneration` and controller `generation` reads with semantic `capabilities`. +- Replace `lifetime`, `inputKey`, and generation-bearing input context reads with `scope`, `delivery`, and `inputId`. +- Keep generated wire schemas and advanced generated types imported explicitly from `/core/v1` or `/core/v2`. +- Replace manual `createSessionPortFromClient` + `withTasks` composition with `createTaskSessionFromClient(client, { endpointId, ...options })` when the session should own adapter disposal; keep the low-level APIs only for custom ownership. The public package subpaths are: diff --git a/packages/ext-tasks/package.json b/packages/ext-tasks/package.json index 6219a1d..b9640ad 100644 --- a/packages/ext-tasks/package.json +++ b/packages/ext-tasks/package.json @@ -26,6 +26,10 @@ "./client": { "types": "./dist/client/index.d.ts", "import": "./dist/client/index.js" + }, + "./receiver": { + "types": "./dist/receiver/index.d.ts", + "import": "./dist/receiver/index.js" } }, "typesVersions": { @@ -41,6 +45,9 @@ ], "client": [ "dist/client/index.d.ts" + ], + "receiver": [ + "dist/receiver/index.d.ts" ] } }, diff --git a/packages/ext-tasks/scripts/check-exports.mjs b/packages/ext-tasks/scripts/check-exports.mjs index a20c2bc..ccb258b 100644 --- a/packages/ext-tasks/scripts/check-exports.mjs +++ b/packages/ext-tasks/scripts/check-exports.mjs @@ -17,9 +17,16 @@ const manifest = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ); const packageName = manifest.name; -const publicSubpaths = ["core", "core/v1", "core/v2", "client"]; +const publicSubpaths = ["core", "core/v1", "core/v2", "client", "receiver"]; const expectedRuntimeExports = { - core: ["JsonValueCodec", "ProtocolDecodeError", "isJsonValue", "taskId"], + core: [ + "JsonValueCodec", + "ProtocolDecodeError", + "isJsonValue", + "runtimeCodecFromStandardSchema", + "taskId", + "toJsonValue", + ], "core/v1": [ "CallToolAsTaskRequestV1Schema", "CallToolRequestV1Schema", @@ -74,6 +81,7 @@ const expectedRuntimeExports = { "GetTaskResultV2Schema", "InputRequestV2Schema", "InputRequestsV2Schema", + "InputRequiredCallToolResultV2Schema", "InputRequiredTaskV2Schema", "InputResponseV2Schema", "InputResponsesV2Schema", @@ -113,15 +121,32 @@ const expectedRuntimeExports = { "InputCorrelationError", "JsonRpcResponseError", "TaskCancellationUnsupportedError", + "TaskCancelledError", "TaskExecutionClosedError", + "TaskFailedError", + "TaskInputUpdateUnsupportedError", "TaskRecoveryOwnershipError", + "TaskRetentionUnsupportedError", "TaskUpdatesAlreadyAcquiredError", + "createApplicationInputHandler", + "createTaskSessionEndpointId", "createSessionPortFromClient", - "toolDeclarationV1", - "toolDeclarationV2", + "createTaskSessionFromClient", + "resultFromTaskOutcome", + "taskViewFromExecutionEvent", + "toolDeclaration", + "toolDeclarationFromMcpTool", + "withRelatedTaskMetadata", "withTasks", ], + receiver: ["bindTaskReceiver"], }; +const removedPrimaryClientNames = [ + "TaskGenerationMismatchError", + "TaskSnapshot", + "toolDeclarationV1", + "toolDeclarationV2", +]; const removedCoreNames = [ "DecodePath", "createRuntimeCodec", @@ -275,6 +300,14 @@ async function checkBuiltContract() { [...expectedRuntimeExports[subpath]].sort(), `Runtime export snapshot changed for ${packageName}/${subpath}`, ); + if (subpath === "client") { + for (const name of removedPrimaryClientNames) + assert.equal( + name in namespace, + false, + `Removed primary client export ${name} is still available`, + ); + } if (subpath === "core/v2") { for (const alias of removedRuntimeAliasesV2) assert.equal( @@ -311,6 +344,7 @@ async function checkPackedContract() { const packOutput = run(process.platform === "win32" ? "npm.cmd" : "npm", [ "pack", "--ignore-scripts", + "--dry-run=false", "--json", "--pack-destination", packDirectory, @@ -351,6 +385,7 @@ async function checkPackedContract() { "install", "--offline", "--ignore-scripts", + "--dry-run=false", "--no-audit", "--no-fund", "--no-package-lock", @@ -374,9 +409,9 @@ async function checkPackedContract() { .join("\n"); const positiveSource = `${positiveImports} import { withTasks } from "${packageName}/client"; -import type { ConnectedMcpSessionPort } from "${packageName}/client"; +import type { ConnectedMcpSessionPort, TaskEnabledSession, TaskOutcome, V2RequestFraming } from "${packageName}/client"; import { ProtocolDecodeError } from "${packageName}/core"; -import type { RuntimeCodec } from "${packageName}/core"; +import type { RuntimeCodec, SynchronousStandardSchema } from "${packageName}/core"; declare const port: ConnectedMcpSessionPort; const resultCodec: RuntimeCodec = { parse(value) { @@ -385,10 +420,20 @@ const resultCodec: RuntimeCodec = { return { success: false, error: new ProtocolDecodeError("Expected value") }; }, }; +const framing: V2RequestFraming = { protocolVersion: "v2", clientInfo: { name: "x" }, clientCapabilities: {} }; +void framing; +const standardSchema: SynchronousStandardSchema = { + "~standard": { version: 1, vendor: "consumer", validate: () => ({ issues: [{ message: "bad", path: ["value"] }] }) }, +}; +void standardSchema; +const decodeError = new ProtocolDecodeError("bad", { issues: [{ message: "bad", path: ["value"] }] }); +void decodeError.details.issues; const session = withTasks(port); const execution = await session.callTool("example", undefined, { resultCodec }); -const inferred: number = await execution.result(); +const inferred: TaskOutcome = await execution.result(); void inferred; +const taskSession: TaskEnabledSession = session; +void taskSession; `; await writeFile(join(consumerDirectory, "positive.ts"), positiveSource); const baseCompilerOptions = { @@ -435,6 +480,10 @@ import type { ConnectedMcpSessionPort } from "${packageName}/client"; declare const port: ConnectedMcpSessionPort; void withTasks(port).callTool("example", undefined, { resultCodec: {} });`, ], + ...removedPrimaryClientNames.map((name) => [ + `removed-client-${name}`, + `import { ${name} } from "${packageName}/client";`, + ]), ...removedCoreNames.map((name) => [ `removed-core-${name}`, `import { ${name} } from "${packageName}/core";`, diff --git a/packages/ext-tasks/src/client/api.ts b/packages/ext-tasks/src/client/api.ts index 14e0ba0..a674ae3 100644 --- a/packages/ext-tasks/src/client/api.ts +++ b/packages/ext-tasks/src/client/api.ts @@ -1,20 +1,14 @@ -import type { - JsonValue, - RuntimeCodec, - TaskGeneration, - TaskId, - TaskSnapshot, -} from "../core/index.js"; +import { toJsonValue } from "../core/index.js"; +import type { JsonValue, RuntimeCodec, TaskId } from "../core/index.js"; import type { CallToolResultV1, TaskEligibleMethodV1, - ToolV1, } from "../core/v1/index.js"; import type { CallToolResultV2, ErrorV2, + InputResponsesV2, TaskEligibleMethodV2, - ToolV2, } from "../core/v2/index.js"; export class JsonRpcResponseError extends Error { @@ -31,9 +25,40 @@ export class JsonRpcResponseError extends Error { } } +/** Generation-neutral terminal task failure, preserving protocol details when present. */ +export class TaskFailedError extends Error { + readonly code?: number; + readonly data?: JsonValue; + + constructor( + message: string, + details: { readonly code?: number; readonly data?: JsonValue } = {}, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "TaskFailedError"; + if (details.code !== undefined) this.code = details.code; + if (details.data !== undefined) this.data = details.data; + } +} + +/** Typed sentinel used when remote task execution terminates by cancellation. */ +export class TaskCancelledError extends Error { + constructor(options?: ErrorOptions) { + super("Task was cancelled", options); + this.name = "TaskCancelledError"; + } +} + +export class TaskRetentionUnsupportedError extends Error { + constructor() { + super("Requested task retention is not supported by this session"); + this.name = "TaskRetentionUnsupportedError"; + } +} + export class TaskRecoveryOwnershipError extends Error { constructor( - readonly generation: TaskGeneration, readonly taskId: TaskId, readonly originalOperation: string, readonly activeOriginalOperation: string, @@ -48,24 +73,72 @@ export class TaskRecoveryOwnershipError extends Error { } } -export type ToolDeclaration = - | { - readonly generation: "v1"; - readonly tool: ToolV1; - } - | { - readonly generation: "v2"; - readonly tool: ToolV2; +/** Structural tool declaration independent of a negotiated Tasks generation. */ +export interface ToolDeclaration { + readonly name: string; + readonly title?: string; + readonly description?: string; + readonly inputSchema: Readonly>; + readonly outputSchema?: Readonly>; + readonly annotations?: Readonly>; + readonly icons?: readonly Readonly>[]; + readonly metadata?: Readonly>; + readonly taskSupport?: "forbidden" | "optional" | "required"; + /** Unrecognized top-level declaration data retained for inspection and projection. */ + readonly extensions?: Readonly>; + /** Unrecognized fields nested under the MCP tool execution declaration. */ + readonly executionExtensions?: Readonly>; +} + +/** Creates a generation-neutral structural tool declaration. */ +export function toolDeclaration( + declaration: ToolDeclaration & { + readonly execution?: { + readonly taskSupport?: ToolDeclaration["taskSupport"]; + readonly extensions?: Readonly>; }; + }, +): ToolDeclaration { + const { execution, ...neutral } = declaration; + return { + ...neutral, + ...(neutral.taskSupport === undefined && + execution?.taskSupport !== undefined + ? { taskSupport: execution.taskSupport } + : {}), + ...(neutral.executionExtensions === undefined && + execution?.extensions !== undefined + ? { executionExtensions: execution.extensions } + : {}), + }; +} -/** Tags a generated V1 tool declaration for a host-supplied provider. */ -export function toolDeclarationV1(tool: ToolV1): ToolDeclaration { - return { generation: "v1", tool }; +/** Returns request metadata with standard related-task evidence installed. */ +export function withRelatedTaskMetadata( + metadata: Readonly> | undefined, + task: Pick, +): Readonly> { + return { + ...metadata, + "io.modelcontextprotocol/related-task": { taskId: task.taskId }, + }; } -/** Tags a generated V2 tool declaration for a host-supplied provider. */ -export function toolDeclarationV2(tool: ToolV2): ToolDeclaration { - return { generation: "v2", tool }; +function canonicalJson(value: JsonValue): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const entries = Object.entries(value).sort(([left], [right]) => { + const serializedLeft = JSON.stringify(left); + const serializedRight = JSON.stringify(right); + return serializedLeft < serializedRight + ? -1 + : serializedLeft > serializedRight + ? 1 + : 0; + }); + return `{${entries + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(",")}}`; } export interface ToolDeclarationProvider { @@ -112,26 +185,14 @@ export type ApplicationInputResult = ? ApplicationListRootsResult : never; -export type ResolvedInputExchangeContext = - | { - readonly lifetime: "basic"; - readonly executionId: string; - readonly applicationContext: TApplicationContext; - readonly signal?: AbortSignal; - } - | { - readonly lifetime: "task-v1"; - readonly taskId: string; - readonly applicationContext: TApplicationContext; - readonly signal?: AbortSignal; - } - | { - readonly lifetime: "task-v2"; - readonly taskId: string; - readonly inputKey: string; - readonly applicationContext: TApplicationContext; - readonly signal?: AbortSignal; - }; +export interface ResolvedInputExchangeContext { + readonly scope: "request" | "task"; + readonly delivery: "peer-request" | "request-retry" | "task-update"; + readonly taskId?: TaskId; + readonly inputId?: string; + readonly applicationContext: TApplicationContext; + readonly signal?: AbortSignal; +} export interface ApplicationInputHandler { handle( @@ -140,6 +201,21 @@ export interface ApplicationInputHandler { ): Promise>; } +export interface ApplicationInputCallbacks { + readonly elicitation: ( + request: Extract, + context: ResolvedInputExchangeContext, + ) => ApplicationElicitResult | Promise; + readonly sampling: ( + request: Extract, + context: ResolvedInputExchangeContext, + ) => ApplicationCreateMessageResult | Promise; + readonly roots: ( + request: Extract, + context: ResolvedInputExchangeContext, + ) => ApplicationListRootsResult | Promise; +} + export type InputCorrelationFailureReason = | "missing-evidence" | "invalid-evidence" @@ -147,14 +223,12 @@ export type InputCorrelationFailureReason = | "ambiguous-matches"; export interface InputCorrelationCandidate { - readonly generation: TaskGeneration; readonly toolName: string; readonly executionId: string; } export class InputCorrelationError extends Error { constructor( - readonly generation: TaskGeneration, readonly requestKind: ApplicationInputRequest["kind"], readonly candidates: readonly InputCorrelationCandidate[], readonly reason: InputCorrelationFailureReason, @@ -171,25 +245,117 @@ export interface WithTasksOptions { readonly signal?: AbortSignal; } -export type { TaskEligibleMethodV2 } from "../core/v2/index.js"; +/** Opaque current-session identity for a managed task. */ +export interface TaskHandle { + readonly taskId: TaskId; + readonly operation: string; +} + +export type TaskState = + "working" | "input_required" | "completed" | "failed" | "cancelled"; + +/** Generation-neutral task data suitable for application and UI use. */ +export interface TaskView { + readonly taskId: TaskId; + readonly status: TaskState; + readonly statusMessage?: string; + readonly createdAt?: string; + readonly lastUpdatedAt?: string; + readonly retentionMs: number | null; + readonly suggestedPollIntervalMs?: number; + /** Compatibility alias for retentionMs; prefer retentionMs in new code. */ + readonly ttl: number | null; + /** Compatibility alias for suggestedPollIntervalMs; prefer that primary name in new code. */ + readonly pollInterval?: number; + readonly raw: Readonly>; + readonly extensions: Readonly>; +} + +/** One page of server-owned task inventory. */ +export interface TaskListPage { + readonly tasks: readonly TaskView[]; + readonly nextCursor?: string; +} -export type TaskHandle = +export type TaskOutcome = | { - readonly generation: "v1"; - readonly taskId: TaskId; - readonly originalOperation: TaskEligibleMethodV1; + readonly status: "completed"; + readonly result: TResult; + readonly task?: TaskView; } | { - readonly generation: "v2"; - readonly taskId: TaskId; - readonly originalOperation: TaskEligibleMethodV2; - }; + readonly status: "failed"; + readonly error: TaskFailedError; + readonly task?: TaskView; + } + | { readonly status: "cancelled"; readonly task?: TaskView }; + +export type TaskExecutionEvent = + | { readonly type: "task"; readonly task: TaskView } + | { readonly type: "outcome"; readonly outcome: TaskOutcome }; + +/** Returns the task represented by an execution event, when one is available. */ +export function taskViewFromExecutionEvent( + event: TaskExecutionEvent, +): TaskView | undefined { + return event.type === "task" ? event.task : event.outcome.task; +} + +/** Unwraps a completed outcome or throws its typed failure/cancellation error. */ +export function resultFromTaskOutcome( + outcome: TaskOutcome, +): TResult { + if (outcome.status === "completed") return outcome.result; + if (outcome.status === "failed") throw outcome.error; + throw new TaskCancelledError(); +} + +export interface ToolExecutionSettleOptions { + /** Stops local waiting and observation without cancelling the remote task. */ + readonly signal?: AbortSignal; + readonly onEvent?: ( + event: TaskExecutionEvent, + ) => void | Promise; + /** Best-effort closes the execution after natural settlement. Defaults to true. */ + readonly close?: boolean; +} + +export interface ToolExecutionSettlement { + readonly outcome: TaskOutcome; + readonly lastTask: TaskView | undefined; +} + +export interface CallToolAndSettleOptions + extends + ToolCallOptions, + ToolExecutionSettleOptions {} + +/** Fully-owned tool-call lifecycle result. */ +export interface CallToolAndSettleResult< + TResult, +> extends ToolExecutionSettlement { + readonly handle: TaskHandle | undefined; +} export interface ToolExecutionCommon { readonly applicationContext: TApplicationContext; - updates(signal?: AbortSignal): AsyncIterable; - result(): Promise; + readonly declaration: ToolDeclaration | undefined; + /** + * Acquires the one-owner update stream. A second acquisition throws. + * Settlement observes independently and does not acquire or drain this stream. + */ + updates(signal?: AbortSignal): AsyncIterable>; + result(): Promise>; + /** + * Returns one cached settlement. The first call owns observation/cleanup options; + * later calls return that same promise regardless of their supplied options. + */ + settle( + options?: ToolExecutionSettleOptions, + ): Promise>; cancel(signal?: AbortSignal): Promise; + /** Stops local driving and releases ownership without cancelling the remote task. */ + detach(): Promise; close(): Promise; [Symbol.asyncDispose](): Promise; } @@ -226,33 +392,120 @@ export class TaskCancellationUnsupportedError extends Error { } } +export type TaskPreference = "allow" | "prefer" | "require" | "forbid"; +export type TaskRetentionPolicy = "best-effort" | "require-capability"; + +export interface TaskOptions { + readonly preference?: TaskPreference; + readonly retentionMs?: number; + /** Defaults to best-effort; strict mode rejects before dispatch if unsupported. */ + readonly retention?: TaskRetentionPolicy; +} + /** Options for one tool call, including host-owned wire context. */ export interface ToolCallOptions { readonly resultCodec?: RuntimeCodec; + /** Execution-scoped declaration. Takes precedence over the session provider. */ + readonly declaration?: ToolDeclaration; readonly applicationContext?: TApplicationContext; readonly signal?: AbortSignal; - readonly preferTask?: boolean; + readonly task?: TaskOptions; /** Arbitrary request metadata preserved alongside package-owned keys. */ readonly metadata?: Readonly>; /** Additional headers for the initiating call and task follow-up requests. */ readonly headers?: Readonly>; - /** Requested V1 task lifetime in milliseconds. Ignored when no V1 task is requested. */ - readonly taskTtl?: number; +} + +export interface TaskControllerOptions { + /** Additional headers preserved on every task request. */ + readonly headers?: Readonly>; +} + +export interface TaskResultOptions { + readonly resultCodec?: RuntimeCodec; + readonly signal?: AbortSignal; +} + +export interface TaskController { + readonly taskId: TaskId; + readonly capabilities: TaskCapabilities; + snapshot(signal?: AbortSignal): Promise; + result( + options?: TaskResultOptions, + ): Promise>; + cancel(signal?: AbortSignal): Promise; + update(inputResponses: InputResponsesV2, signal?: AbortSignal): Promise; + updateJson(inputResponses: unknown, signal?: AbortSignal): Promise; +} + +export interface TaskCapabilities { + readonly inventory: "server-list" | "known-handles" | "unsupported"; + readonly execution: boolean; + readonly cancellation: boolean; + readonly inputResponses: boolean; + readonly requestedRetention: boolean; +} + +export class TaskInputUpdateUnsupportedError extends Error { + constructor() { + super("Task input response updates require a V2 task session"); + this.name = "TaskInputUpdateUnsupportedError"; + } +} + +export type TaskSessionEndpointId = string & { + readonly __taskSessionEndpointId: unique symbol; +}; + +/** + * Creates a stable endpoint identity from host-owned connection semantics. + * Object keys in the descriptor are sorted recursively before a versioned SHA-256 digest. + */ +export async function createTaskSessionEndpointId( + namespace: string, + descriptor: unknown, +): Promise { + if (namespace.length === 0) + throw new TypeError("Endpoint namespace must not be empty"); + const payload = new TextEncoder().encode( + canonicalJson(toJsonValue(descriptor)), + ); + const digest = await globalThis.crypto.subtle.digest("SHA-256", payload); + const hex = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + return `${namespace}:v1:sha256:${hex}` as TaskSessionEndpointId; +} + +export interface TaskRecoveryOptions { + readonly resultCodec?: RuntimeCodec; + readonly applicationContext?: TApplicationContext; + readonly signal?: AbortSignal; + /** Execution-scoped declaration. Takes precedence over the session provider. */ + readonly declaration?: ToolDeclaration; } export interface TaskEnabledSession { + readonly endpointId: TaskSessionEndpointId; + readonly capabilities: TaskCapabilities; + task(taskId: TaskId, options?: TaskControllerOptions): TaskController; + /** Lists one page of server inventory. */ + listTasks(cursor?: string, signal?: AbortSignal): Promise; + /** Cancels a live owned execution or a detached task controller by identity. */ + cancelTask(taskId: TaskId, signal?: AbortSignal): Promise; callTool( name: string, params?: Readonly>, options?: ToolCallOptions, ): Promise>; + callToolAndSettle( + name: string, + params?: Readonly>, + options?: CallToolAndSettleOptions, + ): Promise>; resumeTask( reference: SerializedTaskReference, - options?: { - readonly resultCodec?: RuntimeCodec; - readonly applicationContext?: TApplicationContext; - readonly signal?: AbortSignal; - }, + options?: TaskRecoveryOptions, ): Promise>; close(): Promise; [Symbol.asyncDispose](): Promise; diff --git a/packages/ext-tasks/src/client/client-adapter.test.ts b/packages/ext-tasks/src/client/client-adapter.test.ts index 3c3f903..a031967 100644 --- a/packages/ext-tasks/src/client/client-adapter.test.ts +++ b/packages/ext-tasks/src/client/client-adapter.test.ts @@ -7,7 +7,14 @@ import { import type { ClientContext } from "@modelcontextprotocol/client"; import { describe, expect, it, vi } from "vitest"; import type { JsonValue } from "../core/index.js"; -import { createSessionPortFromClient, withTasks } from "./index.js"; +import type { ApplicationInputHandler } from "./index.js"; +import { + createSessionPortFromClient, + createTaskSessionFromClient, + toolDeclarationFromMcpTool, + withTasks, +} from "./index.js"; +import { ClientSessionPort } from "./sdk-client-adapter.js"; const client = () => new Client({ name: "test", version: "1" }); const context = { @@ -20,6 +27,14 @@ const context = { notify: vi.fn(), }, } satisfies ClientContext; +const v2RequestFraming = { + protocolVersion: "2026-07-28", + clientInfo: { name: "test-client", version: "1.2.3" }, + clientCapabilities: { + sampling: { tools: {} }, + extensions: { "example/other": { enabled: true } }, + }, +} as const; describe("Client adapter", () => { it("dispatches with an explicit schema and signal, preserving full protocol errors", async () => { @@ -62,6 +77,90 @@ describe("Client adapter", () => { }); }); + it("allows manual input-required results only for tools/call", async () => { + const sdk = client(); + const request = vi.spyOn(sdk, "request").mockResolvedValue({ ok: true }); + const port = createSessionPortFromClient(sdk, "manual-input-options"); + const controller = new AbortController(); + const options = { + signal: controller.signal, + context: { headers: { "x-trace": "trace-1" } }, + }; + + await port.dispatch( + { method: "tools/call", params: { name: "demo" } }, + options, + ); + await port.dispatch({ method: "custom/method" }, options); + + expect(request.mock.calls[0]?.[2]).toEqual({ + allowInputRequired: true, + signal: controller.signal, + headers: { "x-trace": "trace-1" }, + }); + expect(request.mock.calls[1]?.[2]).toEqual({ + signal: controller.signal, + headers: { "x-trace": "trace-1" }, + }); + port[Symbol.dispose](); + }); + + it("routes manual input-required results to the session handler and preserves cancellation", async () => { + const sdk = client(); + const request = vi.spyOn(sdk, "request").mockResolvedValue({ + content: [], + resultType: "input_required", + requestState: "manual-state", + inputRequests: { + prompt: { method: "elicitation/create", params: { message: "Choose" } }, + }, + }); + const controller = new AbortController(); + const cancellation = new Error("caller cancelled"); + let inputSignal: AbortSignal | undefined; + let markHandlerStarted: () => void = () => {}; + const handlerStarted = new Promise((resolve) => { + markHandlerStarted = resolve; + }); + const onInputRequest: ApplicationInputHandler["handle"] = async ( + input, + inputContext, + ) => { + expect(input).toMatchObject({ + kind: "elicitation", + params: { message: "Choose" }, + }); + inputSignal = inputContext.signal; + markHandlerStarted(); + return new Promise((_resolve, reject) => { + inputContext.signal?.addEventListener( + "abort", + () => { + reject(cancellation); + }, + { once: true }, + ); + }); + }; + const session = createTaskSessionFromClient(sdk, { + endpointId: "manual-input-session", + signal: controller.signal, + tools: { currentTool: () => undefined }, + onInputRequest, + }); + const pending = session.callTool("demo"); + + await handlerStarted; + expect(request.mock.calls[0]?.[2]).toMatchObject({ + allowInputRequired: true, + }); + expect(inputSignal).toBe(request.mock.calls[0]?.[2]?.signal); + controller.abort(cancellation); + await expect(pending).rejects.toBe(cancellation); + expect(inputSignal?.aborted).toBe(true); + await session.close(); + }); + it("routes V2 task traffic through raw dispatch before SDK validation", async () => { const sdk = client(); vi.spyOn(sdk, "getProtocolEra").mockReturnValue("modern"); @@ -73,21 +172,103 @@ describe("Client adapter", () => { kind: "result", result: { resultType: "task", taskId: "task-1" }, }); - const port = createSessionPortFromClient(sdk, "modern", { rawDispatch }); + const port = createSessionPortFromClient(sdk, "modern", { + rawDispatch, + v2RequestFraming, + }); const options = { context: { headers: { "x-trace": "trace-2" } } }; await expect( - port.dispatch({ method: "tools/call", params: { name: "x" } }, options), + port.dispatch( + { + method: "tools/call", + params: { + name: "x", + _meta: { + trace: "keep-me", + "io.modelcontextprotocol/protocolVersion": "spoofed", + "io.modelcontextprotocol/clientInfo": { name: "spoofed" }, + "io.modelcontextprotocol/clientCapabilities": { spoofed: true }, + }, + }, + }, + options, + ), ).resolves.toEqual({ kind: "result", result: { resultType: "task", taskId: "task-1" }, }); expect(rawDispatch).toHaveBeenCalledWith( - { method: "tools/call", params: { name: "x" } }, + { + method: "tools/call", + params: { + name: "x", + _meta: { + trace: "keep-me", + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + name: "test-client", + version: "1.2.3", + }, + "io.modelcontextprotocol/clientCapabilities": { + sampling: { tools: {} }, + extensions: { + "example/other": { enabled: true }, + "io.modelcontextprotocol/tasks": {}, + }, + }, + }, + }, + }, options, ); expect(request).not.toHaveBeenCalled(); }); + it("copies V2 framing at creation and rejects malformed framing", async () => { + const sdk = client(); + vi.spyOn(sdk, "getProtocolEra").mockReturnValue("modern"); + vi.spyOn(sdk, "getServerCapabilities").mockReturnValue({ + extensions: { "io.modelcontextprotocol/tasks": {} }, + }); + const mutable = { + protocolVersion: "2026-07-28", + clientInfo: { name: "before" }, + clientCapabilities: { nested: { enabled: true } }, + }; + const rawDispatch = vi + .fn() + .mockResolvedValue({ kind: "result", result: {} }); + const port = createSessionPortFromClient(sdk, "frozen", { + rawDispatch, + v2RequestFraming: mutable, + }); + mutable.clientInfo.name = "after"; + mutable.clientCapabilities.nested.enabled = false; + await port.dispatch({ method: "tasks/get", params: { taskId: "x" } }); + expect(rawDispatch.mock.calls[0]?.[0]).toMatchObject({ + params: { + _meta: { + "io.modelcontextprotocol/clientInfo": { name: "before" }, + "io.modelcontextprotocol/clientCapabilities": { + nested: { enabled: true }, + }, + }, + }, + }); + port[Symbol.dispose](); + const malformed = client(); + vi.spyOn(malformed, "getProtocolEra").mockReturnValue("modern"); + vi.spyOn(malformed, "getServerCapabilities").mockReturnValue({ + extensions: { "io.modelcontextprotocol/tasks": {} }, + }); + expect(() => + createSessionPortFromClient(malformed, "malformed", { + rawDispatch, + v2RequestFraming: { ...v2RequestFraming, protocolVersion: "" }, + }), + ).toThrow(/protocolVersion must be non-empty/); + }); + it("fails V2 port construction before send when no raw coordinator exists", () => { const sdk = client(); vi.spyOn(sdk, "getProtocolEra").mockReturnValue("modern"); @@ -96,7 +277,7 @@ describe("Client adapter", () => { }); const request = vi.spyOn(sdk, "request"); expect(() => createSessionPortFromClient(sdk, "modern")).toThrow( - "requires options.rawDispatch", + "requires options.rawDispatch and options.v2RequestFraming", ); expect(request).not.toHaveBeenCalled(); }); @@ -151,6 +332,7 @@ describe("Client adapter", () => { await Promise.resolve(); return { kind: "result", result: {} }; }, + v2RequestFraming, }); expect(modernPort.taskCapabilities).toEqual({ generation: "v2", @@ -305,7 +487,7 @@ describe("Client adapter", () => { tools: { currentTool: () => undefined }, }); const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toEqual({ content: [] }); + await expect(legacyResult(execution)).resolves.toEqual({ content: [] }); expect(foreign.request).toHaveBeenCalled(); await session.close(); port[Symbol.dispose](); @@ -321,7 +503,7 @@ describe("Client adapter", () => { tools: { currentTool: () => undefined }, }); const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toEqual({ content: [] }); + await expect(legacyResult(execution)).resolves.toEqual({ content: [] }); expect(request).toHaveBeenCalledWith( { method: "tools/call", params: { name: "x" } }, expect.any(Object), @@ -370,4 +552,131 @@ describe("Client adapter", () => { const replacement = createSessionPortFromClient(sdk, "close-failure"); replacement[Symbol.dispose](); }); + + it("creates an owned session and restores Client callbacks on close failure", async () => { + const sdk = client(); + const prior = vi.fn(() => Promise.resolve({ prior: true })); + sdk.fallbackRequestHandler = prior; + const controller = new AbortController(); + const sentinel = new Error("listener cleanup failed"); + vi.spyOn(controller.signal, "removeEventListener").mockImplementation( + () => { + throw sentinel; + }, + ); + const errors: Error[] = []; + const session = createTaskSessionFromClient(sdk, { + endpointId: "opaque:endpoint/value", + signal: controller.signal, + tools: { currentTool: () => undefined }, + onError: (error) => errors.push(error), + }); + expect(session.endpointId).toBe("opaque:endpoint/value"); + const closing = session.close(); + await expect(closing).resolves.toBeUndefined(); + expect(session.close()).toBe(closing); + expect(errors).toContain(sentinel); + expect(sdk.fallbackRequestHandler).toBe(prior); + const replacement = createSessionPortFromClient(sdk, "replacement"); + replacement[Symbol.dispose](); + }); + + it("forwards rawDispatch through the owned Client session", async () => { + const sdk = client(); + vi.spyOn(sdk, "getProtocolEra").mockReturnValue("modern"); + vi.spyOn(sdk, "getServerCapabilities").mockReturnValue({ + extensions: { "io.modelcontextprotocol/tasks": {} }, + }); + const request = vi.spyOn(sdk, "request"); + const rawDispatch = vi.fn().mockResolvedValue({ + kind: "result", + result: { resultType: "complete", content: [] }, + }); + const session = createTaskSessionFromClient(sdk, { + endpointId: "modern-owned", + rawDispatch, + v2RequestFraming, + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("x"); + await expect(legacyResult(execution)).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(rawDispatch.mock.calls[0]?.[0]).toEqual({ + method: "tools/call", + params: { + name: "x", + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + name: "test-client", + version: "1.2.3", + }, + "io.modelcontextprotocol/clientCapabilities": { + sampling: { tools: {} }, + extensions: { + "example/other": { enabled: true }, + "io.modelcontextprotocol/tasks": {}, + }, + }, + }, + }, + }); + expect(request).not.toHaveBeenCalled(); + await session.close(); + }); + + it("converts SDK tools with object schemas and preserved extensions", () => { + const extendedTool = { + name: "search", + inputSchema: { + type: "object" as const, + properties: { query: { type: "string" } }, + }, + _meta: { source: "server" }, + execution: { + taskSupport: "required" as const, + vendorExecution: { queue: "batch" }, + }, + vendorFlag: { enabled: true }, + }; + expect(toolDeclarationFromMcpTool(extendedTool)).toEqual({ + name: "search", + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + }, + metadata: { source: "server" }, + taskSupport: "required", + executionExtensions: { vendorExecution: { queue: "batch" } }, + extensions: { vendorFlag: { enabled: true } }, + }); + expect(() => + toolDeclarationFromMcpTool({ name: "bad", inputSchema: true } as never), + ).toThrow(/inputSchema must be a JSON object/); + }); + + it("disposes the Client adapter when owned session construction fails", () => { + const sdk = client(); + const prior = vi.fn(() => Promise.resolve({ prior: true })); + sdk.fallbackRequestHandler = prior; + const sentinel = new Error("session construction failed"); + const registration = vi + .spyOn(ClientSessionPort.prototype, "onServerRequest") + .mockImplementationOnce(() => { + throw sentinel; + }); + expect(() => + createTaskSessionFromClient(sdk, { + endpointId: "construction-failure", + tools: { currentTool: () => undefined }, + }), + ).toThrow(sentinel); + registration.mockRestore(); + expect(sdk.fallbackRequestHandler).toBe(prior); + const replacement = createSessionPortFromClient(sdk, "replacement"); + replacement[Symbol.dispose](); + }); }); +import { legacyResult } from "../../test-support/client/semantic.js"; diff --git a/packages/ext-tasks/src/client/declarations-capabilities.test.ts b/packages/ext-tasks/src/client/declarations-capabilities.test.ts index 8509577..b818923 100644 --- a/packages/ext-tasks/src/client/declarations-capabilities.test.ts +++ b/packages/ext-tasks/src/client/declarations-capabilities.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import type { ToolV1 } from "../core/v1/index.js"; -import { DispatchError, toolDeclarationV1, withTasks } from "./index.js"; +import { + DispatchError, + TaskRetentionUnsupportedError, + toolDeclaration, + withTasks, +} from "./index.js"; import type { JsonRpcResponse } from "./index.js"; import { FakePort, @@ -275,13 +279,14 @@ describe("declarations and capabilities", () => { expect(removeListener).toHaveBeenCalledTimes(1); }); - it("rejects generation-mismatched declarations without leaking call listeners", async () => { + it("projects one neutral declaration without leaking call listeners", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); - const v1Tool: ToolV1 = { + port.response = { kind: "result", result: { content: [] } }; + const declaration = toolDeclaration({ name: "x", inputSchema: { type: "object" }, - execution: { taskSupport: "required" }, - }; + taskSupport: "required", + }); const callController = new AbortController(); const addListener = vi.spyOn(callController.signal, "addEventListener"); const removeListener = vi.spyOn( @@ -289,18 +294,52 @@ describe("declarations and capabilities", () => { "removeEventListener", ); const session = withTasks(port, { - tools: { currentTool: () => toolDeclarationV1(v1Tool) }, + tools: { currentTool: () => declaration }, }); - for (let attempt = 0; attempt < 3; attempt += 1) { - await expect( - session.callTool("x", undefined, { signal: callController.signal }), - ).rejects.toThrow( - "V1 tool declaration is incompatible with the V2 session", - ); - } + await session.callTool("x", undefined, { signal: callController.signal }); + expect(port.requests).toHaveLength(1); + expect(declaration).not.toHaveProperty("generation"); + expect(addListener).toHaveBeenCalledTimes(1); + expect(removeListener).toHaveBeenCalledTimes(1); + await session.close(); + }); + + it("retains neutral task support while omitting the absent V2 wire field", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.response = { kind: "result", result: { content: [] } }; + const declaration = toolDeclaration({ + name: "x", + inputSchema: { type: "object" }, + taskSupport: "required", + }); + const session = withTasks(port, { + tools: { currentTool: () => declaration }, + }); + const execution = await session.callTool("x", undefined, { + task: { retentionMs: 5000, retention: "best-effort" }, + }); + const params = expectRecord(port.requests[0]).params; + expect(params).not.toHaveProperty("execution"); + for (const field of ["ttl", "ttlMs", "retentionMs"]) + expect(params).not.toHaveProperty(`task.${field}`); + expect(execution.declaration?.taskSupport).toBe("required"); + await session.close(); + }); + + it("rejects strict requested retention before unsupported dispatch", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + const session = withTasks(port, { + tools: { + currentTool: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + await expect( + session.callTool("x", undefined, { + task: { retentionMs: 5000, retention: "require-capability" }, + }), + ).rejects.toBeInstanceOf(TaskRetentionUnsupportedError); expect(port.requests).toEqual([]); - expect(addListener).toHaveBeenCalledTimes(3); - expect(removeListener).toHaveBeenCalledTimes(3); await session.close(); }); }); diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts index 4bd1340..f57b6e0 100644 --- a/packages/ext-tasks/src/client/execution.ts +++ b/packages/ext-tasks/src/client/execution.ts @@ -1,24 +1,30 @@ +import { ProtocolDecodeError } from "../core/index.js"; +import type { RuntimeCodec } from "../core/index.js"; +import { CallToolResultV1Schema } from "../core/v1/index.js"; +import type { CallToolResultV1, TaskV1 } from "../core/v1/index.js"; +import { CallToolResultV2Schema } from "../core/v2/index.js"; +import type { CallToolResultV2 } from "../core/v2/index.js"; import { - ProtocolDecodeError, - type RuntimeCodec, - type TaskSnapshot, -} from "../core/index.js"; -import { - CallToolResultV1Schema, - type CallToolResultV1, - type TaskV1, -} from "../core/v1/index.js"; -import { - CallToolResultV2Schema, - type CallToolResultV2, -} from "../core/v2/index.js"; -import { + JsonRpcResponseError, + TaskCancelledError, TaskExecutionClosedError, + TaskFailedError, TaskUpdatesAlreadyAcquiredError, - type SerializedTaskReference, - type TaskHandle, - type ToolExecutionCommon, } from "./api.js"; +import type { + SerializedTaskReference, + TaskExecutionEvent, + TaskHandle, + TaskOutcome, + TaskSessionEndpointId, + TaskView, + ToolDeclaration, + ToolExecutionCommon, + ToolExecutionSettleOptions, + ToolExecutionSettlement, +} from "./api.js"; +import { completedOutcome, projectTask, publicTaskHandle } from "./internal.js"; +import type { InternalTaskHandle, InternalTaskSnapshot } from "./internal.js"; import type { SessionTaskCapabilities } from "./port.js"; import { linkAbortSignals, withAbort } from "./port.js"; import { throwIfAborted } from "./input-routing.js"; @@ -39,6 +45,7 @@ function codecFromSchema(schema: { success: false, error: new ProtocolDecodeError( "Protocol value failed schema validation", + {}, { cause: decoded.error }, ), }; @@ -66,8 +73,39 @@ export function reasonAsError(reason: unknown): Error { export const DEFAULT_TASK_POLL_INTERVAL_MS = 10; +/** Applies the minimum polling cadence to server-suggested task intervals. */ +export function taskPollInterval( + ...suggestedIntervals: readonly (number | undefined)[] +): number { + return Math.max( + DEFAULT_TASK_POLL_INTERVAL_MS, + ...suggestedIntervals.filter( + (interval): interval is number => interval !== undefined, + ), + ); +} + +/** Waits for the next task poll while remaining abortable. */ +export async function waitForTaskPoll( + delayMs: number, + signal: AbortSignal, +): Promise { + let timeout: ReturnType | undefined; + try { + await withAbort( + new Promise((resolve) => { + timeout = setTimeout(resolve, Math.max(0, delayMs)); + }), + signal, + ); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + type TaskTurn = - { readonly sequence: number; readonly snapshot: TaskSnapshot } | undefined; + | { readonly sequence: number; readonly snapshot: InternalTaskSnapshot } + | undefined; function wakeAll(waiters: Set<() => void>): void { for (const wake of waiters) wake(); @@ -75,18 +113,15 @@ function wakeAll(waiters: Set<() => void>): void { } export interface TaskDriverContext { - readonly accept: (snapshot: TaskSnapshot) => TaskSnapshot; + readonly accept: (snapshot: InternalTaskSnapshot) => InternalTaskSnapshot; readonly nextObservation: ( afterSequence: number, delayMs: number | undefined, - observation: (signal: AbortSignal) => Promise, + observation: (signal: AbortSignal) => Promise, ) => Promise; readonly signal: AbortSignal; readonly inputSignal: AbortSignal; - readonly errors: { - readonly cancelled: Error; - readonly closed: Error; - }; + readonly errors: { readonly cancelled: Error; readonly closed: Error }; readonly isClosed: () => boolean; } @@ -96,9 +131,10 @@ export type TaskDriver = ( interface TaskExecutionOptions { readonly applicationContext: TApplicationContext; - readonly handle: TaskHandle; - readonly endpointId: string; - readonly initialSnapshot: TaskSnapshot; + readonly handle: InternalTaskHandle; + readonly declaration?: ToolDeclaration; + readonly endpointId: TaskSessionEndpointId; + readonly initialSnapshot: InternalTaskSnapshot; readonly driver: TaskDriver; readonly cancelTask: (signal?: AbortSignal) => Promise; readonly lifecycleSignal?: AbortSignal; @@ -110,36 +146,46 @@ export class TaskExecution< > implements ToolExecutionCommon { readonly kind = "task" as const; readonly applicationContext: TApplicationContext; + readonly declaration: ToolDeclaration | undefined; readonly handle: TaskHandle; - private readonly endpointId: string; + private readonly internalHandle: InternalTaskHandle; + private readonly endpointId: TaskSessionEndpointId; private readonly cancelTask: (signal?: AbortSignal) => Promise; private readonly controller = new AbortController(); private readonly inputController = new AbortController(); private readonly cancellationController = new AbortController(); private readonly resultPromise: Promise; - private readonly cancelledError = new Error("Task was cancelled"); + private readonly outcomePromise: Promise>; + private readonly cancelledError = new TaskCancelledError(); private readonly closedError = new TaskExecutionClosedError(); private readonly turnWaiters = new Set<() => void>(); private readonly updateWaiters = new Set<() => void>(); - private initialSnapshot: TaskSnapshot | undefined; - private pendingSnapshot: TaskSnapshot | undefined; - private terminalSnapshot: TaskSnapshot | undefined; - private authoritativeTerminalSnapshot: TaskSnapshot | undefined; + private readonly observationWaiters = new Set<() => void>(); + private readonly observedSnapshots: InternalTaskSnapshot[] = []; + private initialSnapshot: InternalTaskSnapshot | undefined; + private pendingSnapshot: InternalTaskSnapshot | undefined; + private terminalSnapshot: InternalTaskSnapshot | undefined; + private authoritativeTerminalSnapshot: InternalTaskSnapshot | undefined; private lastAcceptedBytes: string; private notificationSequence = 0; - private latestNotifiedSnapshot: TaskSnapshot | undefined; + private latestNotifiedSnapshot: InternalTaskSnapshot | undefined; private updatesAcquired = false; private cancelPromise: Promise | undefined; private closePromise: Promise | undefined; + private settlementPromise: + Promise> | undefined; private settled = false; private closed = false; constructor(options: TaskExecutionOptions) { this.applicationContext = options.applicationContext; - this.handle = options.handle; + this.declaration = options.declaration; + this.internalHandle = options.handle; + this.handle = publicTaskHandle(options.handle); this.endpointId = options.endpointId; this.cancelTask = options.cancelTask; this.initialSnapshot = options.initialSnapshot; + this.observedSnapshots.push(options.initialSnapshot); const initialBytes = deterministicJson(options.initialSnapshot); this.lastAcceptedBytes = initialBytes; if (terminalStatus(options.initialSnapshot.task.status)) { @@ -166,6 +212,46 @@ export class TaskExecution< }, isClosed: () => this.closed, }); + this.outcomePromise = this.resultPromise.then( + (result) => ({ + status: "completed" as const, + result, + ...(this.authoritativeTerminalSnapshot === undefined + ? {} + : { task: projectTask(this.authoritativeTerminalSnapshot) }), + }), + (error: unknown) => + error === this.cancelledError + ? { + status: "cancelled" as const, + ...(this.authoritativeTerminalSnapshot === undefined + ? {} + : { task: projectTask(this.authoritativeTerminalSnapshot) }), + } + : { + status: "failed" as const, + error: + error instanceof TaskFailedError + ? error + : error instanceof JsonRpcResponseError + ? new TaskFailedError( + error.message, + { code: error.code, data: error.data }, + { cause: error }, + ) + : new TaskFailedError( + error instanceof Error ? error.message : String(error), + {}, + error instanceof Error ? { cause: error } : undefined, + ), + ...(this.authoritativeTerminalSnapshot === undefined + ? {} + : { task: projectTask(this.authoritativeTerminalSnapshot) }), + }, + ); + void this.outcomePromise.catch(() => { + // The public outcome is cached even when callers detach without awaiting it. + }); void this.resultPromise.then( () => { this.settled = true; @@ -177,37 +263,41 @@ export class TaskExecution< } serializeReference(): SerializedTaskReference { - return { endpointId: this.endpointId, ...this.handle }; + return { endpointId: this.endpointId, ...this.internalHandle }; } - onNotification(snapshot: TaskSnapshot): void { - if (this.closed || snapshot.generation !== this.handle.generation) return; - if (snapshot.task.taskId !== this.handle.taskId) return; + onNotification(snapshot: InternalTaskSnapshot): void { + if (this.closed || snapshot.generation !== this.internalHandle.generation) + return; + if (snapshot.task.taskId !== this.internalHandle.taskId) return; this.transitionSnapshot(snapshot, "notification"); } - updates(signal?: AbortSignal): AsyncIterable { + updates(signal?: AbortSignal): AsyncIterable> { if (this.updatesAcquired) throw new TaskUpdatesAlreadyAcquiredError(); this.updatesAcquired = true; - return this.iterateUpdates(signal); + return this.iterateEvents(signal); } - private async *iterateUpdates( + private async *iterateEvents( signal?: AbortSignal, - ): AsyncIterable { + ): AsyncIterable> { for (;;) { throwIfAborted(signal); const snapshot = this.takeQueuedUpdate(); if (snapshot !== undefined) { - yield snapshot; + yield { type: "task", task: projectTask(snapshot) }; continue; } const settled = await this.waitForUpdateOrResult(signal); - if (!settled) return; + if (!settled) { + yield { type: "outcome", outcome: await this.result() }; + return; + } } } - private takeQueuedUpdate(): TaskSnapshot | undefined { + private takeQueuedUpdate(): InternalTaskSnapshot | undefined { if (this.initialSnapshot !== undefined) { const snapshot = this.initialSnapshot; this.initialSnapshot = undefined; @@ -223,15 +313,15 @@ export class TaskExecution< return snapshot; } - private acceptSnapshot(snapshot: TaskSnapshot): TaskSnapshot { + private acceptSnapshot(snapshot: InternalTaskSnapshot): InternalTaskSnapshot { if (this.closed) return snapshot; return this.transitionSnapshot(snapshot, "accepted"); } private transitionSnapshot( - snapshot: TaskSnapshot, + snapshot: InternalTaskSnapshot, source: "accepted" | "notification", - ): TaskSnapshot { + ): InternalTaskSnapshot { // The first terminal snapshot is authoritative across polling, notifications, // result driving, and the update stream. Nothing may advance after it. if (this.authoritativeTerminalSnapshot !== undefined) @@ -258,10 +348,61 @@ export class TaskExecution< this.notificationSequence += 1; wakeAll(this.turnWaiters); } + if (queuedUpdate) { + this.observedSnapshots.push(snapshot); + wakeAll(this.observationWaiters); + } if (queuedUpdate) wakeAll(this.updateWaiters); return snapshot; } + private async *observeEvents( + signal?: AbortSignal, + ): AsyncIterable> { + let index = 0; + for (;;) { + throwIfAborted(signal); + while (index < this.observedSnapshots.length) { + const snapshot = this.observedSnapshots[index]; + index += 1; + yield { type: "task", task: projectTask(snapshot) }; + } + const settled = await this.waitForObservationOrResult(index, signal); + if (!settled) { + yield { type: "outcome", outcome: await this.result() }; + return; + } + } + } + + private async waitForObservationOrResult( + index: number, + signal?: AbortSignal, + ): Promise { + if (index < this.observedSnapshots.length) return true; + let wake: (() => void) | undefined; + const observed = new Promise((resolve) => { + wake = () => { + resolve(true); + }; + this.observationWaiters.add(wake); + }); + try { + return await withAbort( + Promise.race([ + observed, + this.resultPromise.then( + () => false, + () => false, + ), + ]), + signal, + ); + } finally { + if (wake !== undefined) this.observationWaiters.delete(wake); + } + } + private async waitForUpdateOrResult(signal?: AbortSignal): Promise { if ( this.pendingSnapshot !== undefined || @@ -339,7 +480,7 @@ export class TaskExecution< private async nextObservation( afterSequence: number, delayMs: number | undefined, - observation: (signal: AbortSignal) => Promise, + observation: (signal: AbortSignal) => Promise, ): Promise { const turn = await this.waitForTurn(afterSequence, delayMs); if (turn !== undefined) return turn; @@ -380,8 +521,19 @@ export class TaskExecution< } } - result(): Promise { - return this.resultPromise; + result(): Promise> { + return this.outcomePromise; + } + + settle( + options: ToolExecutionSettleOptions = {}, + ): Promise> { + this.settlementPromise ??= settleExecution( + this, + this.observeEvents(options.signal), + options, + ); + return this.settlementPromise; } inputSignal(): AbortSignal { @@ -400,17 +552,22 @@ export class TaskExecution< : withAbort(this.cancelPromise, signal); } - close(): Promise { - if (this.closePromise !== undefined) return this.closePromise; - const shouldCancel = !this.settled; + detach(): Promise { + if (this.closed) return Promise.resolve(); this.closed = true; this.controller.abort(this.closedError); this.inputController.abort(this.closedError); + return Promise.resolve(); + } + + close(): Promise { + if (this.closePromise !== undefined) return this.closePromise; + const shouldCancel = !this.settled; + this.closePromise = this.detach(); if (shouldCancel) void this.cancel().catch(() => { // Cooperative cancellation is best effort during close. }); - this.closePromise = Promise.resolve(); return this.closePromise; } @@ -447,32 +604,93 @@ export function terminalStatus(status: TaskV1["status"]): boolean { ); } +async function settleExecution( + execution: ToolExecutionCommon, + events: AsyncIterable>, + options: ToolExecutionSettleOptions, +): Promise> { + let lastTask: TaskView | undefined; + const observation = (async () => { + for await (const event of events) { + if (event.type === "task") lastTask = event.task; + await options.onEvent?.(event); + } + })(); + const outcome = withAbort(execution.result(), options.signal); + const first = await Promise.race([ + outcome.then( + (value) => ({ branch: "outcome" as const, value }), + (error: unknown) => ({ branch: "outcome-error" as const, error }), + ), + observation.then( + () => ({ branch: "observation" as const }), + (error: unknown) => ({ branch: "observation-error" as const, error }), + ), + ]); + if (first.branch === "observation-error") { + await execution.detach(); + throw first.error; + } + if (first.branch === "outcome-error") { + await execution.detach(); + await observation.catch(() => {}); + throw first.error; + } + const outcomeValue = first.branch === "outcome" ? first.value : await outcome; + await observation; + if (options.close !== false) { + try { + await execution.close(); + } catch { + // Settlement cleanup is best effort and never masks execution outcomes. + } + } + return { outcome: outcomeValue, lastTask }; +} + export class ImmediateExecution< TResult, TApplicationContext, > implements ToolExecutionCommon { readonly kind = "immediate" as const; readonly handle = undefined; + readonly declaration: ToolDeclaration | undefined; + private readonly outcomePromise: Promise>; + private settlementPromise: + Promise> | undefined; constructor( readonly applicationContext: TApplicationContext, private readonly resultPromise: Promise, - ) {} + declaration?: ToolDeclaration, + ) { + this.declaration = declaration; + this.outcomePromise = completedOutcome(this.resultPromise); + } - updates(signal?: AbortSignal): AsyncIterable { + updates(signal?: AbortSignal): AsyncIterable> { throwIfAborted(signal); + const outcome = this.result(); return { - [Symbol.asyncIterator]() { - return { - next: () => - Promise.resolve({ done: true as const, value: undefined }), - }; + async *[Symbol.asyncIterator]() { + yield { type: "outcome" as const, outcome: await outcome }; }, }; } - result(): Promise { - return this.resultPromise; + result(): Promise> { + return this.outcomePromise; + } + + settle( + options: ToolExecutionSettleOptions = {}, + ): Promise> { + this.settlementPromise ??= settleExecution( + this, + this.updates(options.signal), + options, + ); + return this.settlementPromise; } cancel(signal?: AbortSignal): Promise { @@ -480,6 +698,10 @@ export class ImmediateExecution< return Promise.resolve(); } + detach(): Promise { + return Promise.resolve(); + } + close(): Promise { return Promise.resolve(); } diff --git a/packages/ext-tasks/src/client/immediate-session-basics.test.ts b/packages/ext-tasks/src/client/immediate-session-basics.test.ts index fda2831..b7ada3a 100644 --- a/packages/ext-tasks/src/client/immediate-session-basics.test.ts +++ b/packages/ext-tasks/src/client/immediate-session-basics.test.ts @@ -1,17 +1,30 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; +import { taskId } from "../core/index.js"; import { ProtocolDecodeError, type JsonValue, type RuntimeCodec, } from "../core/index.js"; import { + createApplicationInputHandler, + createTaskSessionEndpointId, DispatchError, JsonRpcResponseError, - toolDeclarationV1, + resultFromTaskOutcome, + taskViewFromExecutionEvent, + TaskCancelledError, + TaskFailedError, + toolDeclaration, + withRelatedTaskMetadata, withTasks, } from "./index.js"; +import { + legacyResult, + legacyUpdates, +} from "../../test-support/client/semantic.js"; import { FakePort, asJson } from "../../test-support/client/fake-port.js"; +import { projectTask } from "./internal.js"; describe("immediate and session basics", () => { it("dispatches a non-task call and caches the decoded result", async () => { @@ -40,9 +53,13 @@ describe("immediate and session basics", () => { const first = execution.result(); const second = execution.result(); expect(first).toBe(second); - await expect(first).resolves.toEqual(result); + await expect(first).resolves.toEqual({ + status: "completed", + result, + }); const updates: unknown[] = []; - for await (const update of execution.updates()) updates.push(update); + for await (const update of legacyUpdates(execution)) + updates.push(update); expect(updates).toEqual([]); await execution.cancel(); expect(port.requests).toHaveLength(1); @@ -52,6 +69,36 @@ describe("immediate and session basics", () => { ); }); + it("settles immediate results and exposes immutable related-task metadata", async () => { + const port = new FakePort(); + port.response = { kind: "result", result: { content: [] } }; + const declaration = toolDeclaration({ + name: "x", + inputSchema: { type: "object" }, + }); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("x", undefined, { declaration }); + await expect(execution.settle()).resolves.toEqual({ + outcome: { status: "completed", result: { content: [] } }, + lastTask: undefined, + }); + expect(execution.declaration).toBe(declaration); + const original = { trace: "one", unknown: { nested: true } } as const; + const metadata = withRelatedTaskMetadata(original, { + taskId: taskId("related"), + }); + expect(metadata).toEqual({ + ...original, + "io.modelcontextprotocol/related-task": { taskId: "related" }, + }); + expect(original).toEqual({ trace: "one", unknown: { nested: true } }); + expect(session.endpointId).toBe(port.endpointId); + expect(session.capabilities.inventory).toBe("unsupported"); + await session.close(); + }); + it("preserves call metadata and transport headers", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); const session = withTasks(port, { @@ -101,7 +148,7 @@ describe("immediate and session basics", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV1({ + toolDeclaration({ name: "x", inputSchema: { type: "object" }, execution: { taskSupport: "required" }, @@ -109,7 +156,7 @@ describe("immediate and session basics", () => { }, }); const execution = await session.callTool("x", undefined, { - taskTtl: 5000, + task: { retentionMs: 5000 }, headers: { "x-routing-key": "route-task" }, }); expect(port.requests[0]).toEqual({ @@ -165,7 +212,7 @@ describe("immediate and session basics", () => { applicationContext: "ctx", }); expect(execution.applicationContext).toBe("ctx"); - await expect(execution.result()).resolves.toBe("42"); + await expect(legacyResult(execution)).resolves.toBe("42"); port.response = { kind: "result", result: { answer: "invalid" } }; await expect( @@ -276,10 +323,158 @@ describe("immediate and session basics", () => { tools: { currentTool: () => undefined }, }); const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toEqual({ + await expect(legacyResult(execution)).resolves.toEqual({ content: [], task: "application-data", }); await session.close(); }); + + it("creates canonical endpoint identities from host descriptors", async () => { + const left = await createTaskSessionEndpointId("transport", { + z: 1, + a: { d: 4, c: 3 }, + }); + const right = await createTaskSessionEndpointId("transport", { + a: { c: 3, d: 4 }, + z: 1, + }); + expect(left).toBe( + "transport:v1:sha256:9609398a798ffd5d25bf2ad53bb05d312094237c7c818dd99951e600396fcc64", + ); + expect(right).toBe(left); + await expect(createTaskSessionEndpointId("", {})).rejects.toThrow( + "namespace", + ); + }); + + it("orders canonical endpoint keys by their serialized spelling", async () => { + const serializedQuote = JSON.stringify('"'); + const canonical = `{${serializedQuote}:1,"a":2}`; + const digest = await globalThis.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(canonical), + ); + const expected = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + await expect( + createTaskSessionEndpointId("escaped", { a: 2, '"': 1 }), + ).resolves.toBe(`escaped:v1:sha256:${expected}`); + }); + + it("projects task aliases and unwraps semantic outcomes", () => { + const v1 = projectTask({ + generation: "v1", + task: { + taskId: "v1", + status: "working", + createdAt: "now", + lastUpdatedAt: "now", + ttl: 42, + pollInterval: 7, + }, + }); + const v2 = projectTask({ + generation: "v2", + task: { + taskId: "v2", + status: "working", + createdAt: "now", + lastUpdatedAt: "now", + ttlMs: null, + pollIntervalMs: 9, + }, + }); + expect(v1).toMatchObject({ + retentionMs: 42, + ttl: 42, + suggestedPollIntervalMs: 7, + pollInterval: 7, + }); + expect(v2).toMatchObject({ + retentionMs: null, + ttl: null, + suggestedPollIntervalMs: 9, + pollInterval: 9, + }); + expect(taskViewFromExecutionEvent({ type: "task", task: v1 })).toBe(v1); + expect( + taskViewFromExecutionEvent({ + type: "outcome", + outcome: { status: "completed", result: 3, task: v2 }, + }), + ).toBe(v2); + expect(resultFromTaskOutcome({ status: "completed", result: 3 })).toBe(3); + const failure = new TaskFailedError("failed", { + code: 7, + data: { why: true }, + }); + expect(() => + resultFromTaskOutcome({ status: "failed", error: failure }), + ).toThrow(failure); + expect(() => resultFromTaskOutcome({ status: "cancelled" })).toThrow( + TaskCancelledError, + ); + }); + + it("routes typed application callbacks and adds task metadata", async () => { + const seen: unknown[] = []; + const handler = createApplicationInputHandler<{ trace: string }>({ + elicitation: (request, context) => { + seen.push({ request, context }); + return { action: "accept", content: { ok: true } }; + }, + sampling: () => ({ + model: "test", + role: "assistant", + content: { type: "text", text: "sampled" }, + }), + roots: () => ({ roots: [{ uri: "file:///tmp" }] }), + }); + const result = await handler( + { + kind: "elicitation", + params: { _meta: { trace: "kept" }, message: "continue?" }, + }, + { + scope: "task", + delivery: "task-update", + taskId: taskId("task-input"), + applicationContext: { trace: "ctx" }, + }, + ); + expect(result).toEqual({ action: "accept", content: { ok: true } }); + expect(seen).toEqual([ + { + request: { + kind: "elicitation", + params: { + message: "continue?", + _meta: { + trace: "kept", + "io.modelcontextprotocol/related-task": { taskId: "task-input" }, + }, + }, + }, + context: { + scope: "task", + delivery: "task-update", + taskId: "task-input", + applicationContext: { trace: "ctx" }, + }, + }, + ]); + await expect( + handler( + { kind: "roots" }, + { + scope: "request", + delivery: "peer-request", + inputId: "roots-1", + applicationContext: { trace: "ctx" }, + }, + ), + ).resolves.toEqual({ roots: [{ uri: "file:///tmp" }] }); + }); }); diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts index a406e16..c4ae842 100644 --- a/packages/ext-tasks/src/client/index.ts +++ b/packages/ext-tasks/src/client/index.ts @@ -3,17 +3,29 @@ export { InputCorrelationError, JsonRpcResponseError, + TaskCancelledError, + TaskFailedError, TaskCancellationUnsupportedError, TaskExecutionClosedError, + toolDeclaration, TaskRecoveryOwnershipError, TaskUpdatesAlreadyAcquiredError, - toolDeclarationV1, - toolDeclarationV2, + TaskRetentionUnsupportedError, + TaskInputUpdateUnsupportedError, } from "./api.js"; +export { + createTaskSessionEndpointId, + resultFromTaskOutcome, + taskViewFromExecutionEvent, +} from "./api.js"; +export { withRelatedTaskMetadata } from "./api.js"; export type { + CallToolAndSettleOptions, + CallToolAndSettleResult, ApplicationCreateMessageResult, ApplicationElicitResult, ApplicationInputHandler, + ApplicationInputCallbacks, ApplicationInputRequest, ApplicationInputResult, ApplicationListRootsResult, @@ -21,15 +33,32 @@ export type { InputCorrelationFailureReason, ResolvedInputExchangeContext, SerializedTaskReference, + TaskController, + TaskControllerOptions, TaskEnabledSession, TaskHandle, + TaskListPage, + TaskCapabilities, + TaskExecutionEvent, + TaskOptions, + TaskOutcome, + TaskPreference, + TaskRetentionPolicy, + TaskState, + TaskView, + TaskResultOptions, + TaskRecoveryOptions, + TaskSessionEndpointId, ToolCallOptions, ToolDeclaration, ToolDeclarationProvider, ToolExecution, ToolExecutionCommon, WithTasksOptions, + ToolExecutionSettleOptions, + ToolExecutionSettlement, } from "./api.js"; +export { createApplicationInputHandler } from "./input-routing.js"; export { DispatchError } from "./port.js"; export type { ConnectedMcpSessionPort, @@ -39,9 +68,15 @@ export type { JsonRpcResponse, SessionTaskCapabilities, } from "./port.js"; -export { createSessionPortFromClient } from "./sdk-client-adapter.js"; +export { + createSessionPortFromClient, + createTaskSessionFromClient, + toolDeclarationFromMcpTool, +} from "./sdk-client-adapter.js"; export type { ClientSessionPortOptions, + CreateTaskSessionFromClientOptions, + V2RequestFraming, RawClientDispatch, } from "./sdk-client-adapter.js"; export { withTasks } from "./session.js"; diff --git a/packages/ext-tasks/src/client/input-routing.ts b/packages/ext-tasks/src/client/input-routing.ts index 4934c60..7fc45ac 100644 --- a/packages/ext-tasks/src/client/input-routing.ts +++ b/packages/ext-tasks/src/client/input-routing.ts @@ -1,6 +1,10 @@ import type { JsonValue, TaskGeneration, TaskId } from "../core/index.js"; +import { withRelatedTaskMetadata } from "./api.js"; import type { + ApplicationInputCallbacks, + ApplicationInputHandler, ApplicationInputRequest, + ApplicationInputResult, InputCorrelationFailureReason, ResolvedInputExchangeContext, } from "./api.js"; @@ -50,7 +54,6 @@ export type RelatedTaskEvidence = | { readonly kind: "task-id"; readonly taskId: string }; export interface InputCandidateProjection { - readonly generation: TaskGeneration; readonly toolName: string; readonly executionId: string; } @@ -66,6 +69,72 @@ export type InputCandidateResolution = readonly candidates: readonly InputCandidateProjection[]; }; +function existingMetadata( + request: ApplicationInputRequest, +): Readonly> | undefined { + const metadata = request.params?._meta; + if ( + metadata === null || + Array.isArray(metadata) || + typeof metadata !== "object" + ) + return undefined; + return metadata as Readonly>; +} + +function withContextRelatedTask( + request: TRequest, + context: ResolvedInputExchangeContext, +): TRequest { + if (context.scope !== "task" || context.taskId === undefined) return request; + return { + ...request, + params: { + ...request.params, + _meta: withRelatedTaskMetadata(existingMetadata(request), { + taskId: context.taskId, + }), + }, + }; +} + +function unreachableInputRequest(request: never): never { + throw new TypeError( + `Unsupported application input request: ${String(request)}`, + ); +} + +/** Creates an exhaustive application input handler from kind-specific callbacks. */ +export function createApplicationInputHandler( + callbacks: ApplicationInputCallbacks, +): ApplicationInputHandler["handle"] { + return async ( + originalRequest: TRequest, + context: ResolvedInputExchangeContext, + ): Promise> => { + const request = withContextRelatedTask(originalRequest, context); + switch (request.kind) { + case "elicitation": + return (await callbacks.elicitation( + request, + context, + )) as ApplicationInputResult; + case "sampling": + return (await callbacks.sampling( + request, + context, + )) as ApplicationInputResult; + case "roots": + return (await callbacks.roots( + request, + context, + )) as ApplicationInputResult; + default: + return unreachableInputRequest(request); + } + }; +} + /** Projects a supported wire request into the application input request shape. */ export function projectApplicationInputRequest( incoming: IncomingServerRequest, @@ -153,7 +222,6 @@ export function resolveInputCandidate( kind: "failed", reason, candidates: matches.map((candidate) => ({ - generation: candidate.generation, toolName: candidate.toolName, executionId: candidate.executionId, })), @@ -168,15 +236,17 @@ export function buildResolvedInputContext( ): ResolvedInputExchangeContext { if (candidate.lifetime === "task-v1") { return { - lifetime: "task-v1", + scope: "task", + delivery: "peer-request", taskId: candidate.taskId, applicationContext: candidate.applicationContext, ...(candidate.signal === undefined ? {} : { signal: candidate.signal }), }; } return { - lifetime: "basic", - executionId: candidate.executionId, + scope: "request", + delivery: "peer-request", + inputId: candidate.executionId, applicationContext: candidate.applicationContext, ...(candidate.signal === undefined ? {} : { signal: candidate.signal }), }; diff --git a/packages/ext-tasks/src/client/internal.ts b/packages/ext-tasks/src/client/internal.ts new file mode 100644 index 0000000..0cfeb2b --- /dev/null +++ b/packages/ext-tasks/src/client/internal.ts @@ -0,0 +1,283 @@ +/** Internal generation-aware records and neutral client projections. */ + +import type { JsonValue, TaskGeneration, TaskId } from "../core/index.js"; +import type { + CallToolResultV1, + ServerTaskCapabilitiesV1, + TaskV1, + ToolV1, +} from "../core/v1/index.js"; +import type { + CallToolResultV2, + DetailedTaskV2, + TaskV2, + ToolV2, +} from "../core/v2/index.js"; +import { + JsonRpcResponseError, + TaskCancelledError, + TaskFailedError, +} from "./api.js"; +import type { + TaskCapabilities, + TaskHandle, + TaskOutcome, + TaskView, + ToolDeclaration, +} from "./api.js"; +import type { SessionTaskCapabilities } from "./port.js"; + +export type InternalTaskHandle = + | { + readonly generation: "v1"; + readonly taskId: TaskId; + readonly originalOperation: "tools/call"; + } + | { + readonly generation: "v2"; + readonly taskId: TaskId; + readonly originalOperation: "tools/call"; + }; + +export type InternalTaskSnapshot = + | { readonly generation: "v1"; readonly task: TaskV1 } + | { readonly generation: "v2"; readonly task: TaskV2 | DetailedTaskV2 }; + +/** Projects an internal handle to the opaque primary handle. */ +export function publicTaskHandle(handle: InternalTaskHandle): TaskHandle { + return { taskId: handle.taskId, operation: handle.originalOperation }; +} + +function jsonRecord(value: object): Readonly> { + return value as Readonly>; +} + +function cloneJson(value: JsonValue): JsonValue { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(cloneJson); + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, cloneJson(entry)]), + ); +} + +/** Projects a generation-specific task snapshot to the primary task view. */ +export function projectTask(snapshot: InternalTaskSnapshot): TaskView { + const task = snapshot.task; + const raw = cloneJson(jsonRecord(task)) as Readonly< + Record + >; + const known = new Set([ + "taskId", + "status", + "statusMessage", + "createdAt", + "lastUpdatedAt", + "ttl", + "ttlMs", + "pollInterval", + "pollIntervalMs", + ]); + const extensions = Object.fromEntries( + Object.entries(raw).filter(([key]) => !known.has(key)), + ); + const protocolFields = + snapshot.generation === "v1" + ? { + retentionMs: snapshot.task.ttl, + ttl: snapshot.task.ttl, + ...(snapshot.task.pollInterval === undefined + ? {} + : { + suggestedPollIntervalMs: snapshot.task.pollInterval, + pollInterval: snapshot.task.pollInterval, + }), + } + : { + retentionMs: snapshot.task.ttlMs, + ttl: snapshot.task.ttlMs, + ...(snapshot.task.pollIntervalMs === undefined + ? {} + : { + suggestedPollIntervalMs: snapshot.task.pollIntervalMs, + pollInterval: snapshot.task.pollIntervalMs, + }), + }; + return { + taskId: task.taskId as TaskId, + status: task.status, + ...(task.statusMessage === undefined + ? {} + : { statusMessage: task.statusMessage }), + createdAt: task.createdAt, + lastUpdatedAt: task.lastUpdatedAt, + ...protocolFields, + raw, + extensions, + }; +} + +/** Projects a generated tool declaration to the neutral declaration shape. */ +export function projectTool(tool: ToolV1 | ToolV2): ToolDeclaration { + const raw = jsonRecord(tool); + const taskSupport = + "execution" in tool && + tool.execution !== undefined && + tool.execution !== null && + typeof tool.execution === "object" && + "taskSupport" in tool.execution && + (tool.execution.taskSupport === "forbidden" || + tool.execution.taskSupport === "optional" || + tool.execution.taskSupport === "required") + ? tool.execution.taskSupport + : undefined; + const known = new Set([ + "name", + "title", + "description", + "inputSchema", + "outputSchema", + "annotations", + "icons", + "_meta", + "execution", + ]); + return { + name: tool.name, + ...(tool.title === undefined ? {} : { title: tool.title }), + ...(tool.description === undefined + ? {} + : { description: tool.description }), + inputSchema: tool.inputSchema, + ...(tool.outputSchema === undefined + ? {} + : { outputSchema: tool.outputSchema }), + ...(tool.annotations === undefined + ? {} + : { annotations: tool.annotations }), + ...(tool.icons === undefined ? {} : { icons: tool.icons }), + ...(tool._meta === undefined ? {} : { metadata: tool._meta }), + ...(taskSupport === undefined ? {} : { taskSupport }), + extensions: Object.fromEntries( + Object.entries(raw).filter(([key]) => !known.has(key)), + ), + }; +} + +/** Projects a neutral declaration to the negotiated generated tool shape. */ +export function projectToolForGeneration( + declaration: ToolDeclaration, + generation: "v1", +): ToolV1; +export function projectToolForGeneration( + declaration: ToolDeclaration, + generation: "v2", +): ToolV2; +export function projectToolForGeneration( + declaration: ToolDeclaration, + generation: TaskGeneration, +): ToolV1 | ToolV2 { + const inputSchema = { type: "object" as const, ...declaration.inputSchema }; + const outputSchema = + declaration.outputSchema === undefined + ? undefined + : { type: "object" as const, ...declaration.outputSchema }; + const common = { + ...declaration.extensions, + name: declaration.name, + ...(declaration.title === undefined ? {} : { title: declaration.title }), + ...(declaration.description === undefined + ? {} + : { description: declaration.description }), + inputSchema, + ...(outputSchema === undefined ? {} : { outputSchema }), + ...(declaration.annotations === undefined + ? {} + : { annotations: declaration.annotations }), + ...(declaration.icons === undefined + ? {} + : { icons: [...declaration.icons] }), + ...(declaration.metadata === undefined + ? {} + : { _meta: declaration.metadata }), + }; + return generation === "v1" + ? { + ...common, + ...(declaration.taskSupport === undefined + ? {} + : { execution: { taskSupport: declaration.taskSupport } }), + } + : common; +} + +/** Derives primary semantic capabilities from negotiated wire capabilities. */ +export function semanticCapabilities( + negotiated: SessionTaskCapabilities, +): TaskCapabilities { + if (negotiated.generation === "none") { + return { + inventory: "unsupported", + execution: false, + cancellation: false, + inputResponses: false, + requestedRetention: false, + }; + } + if (negotiated.generation === "v1") { + const capabilities: ServerTaskCapabilitiesV1 = negotiated.capabilities; + return { + inventory: + capabilities.list === undefined ? "known-handles" : "server-list", + execution: capabilities.requests?.tools?.call !== undefined, + cancellation: capabilities.cancel !== undefined, + inputResponses: false, + requestedRetention: true, + }; + } + return { + inventory: "known-handles", + execution: true, + cancellation: true, + inputResponses: true, + requestedRetention: false, + }; +} + +/** Converts a result promise into a uniformly resolving semantic outcome. */ +export async function completedOutcome( + result: Promise, + task?: TaskView, +): Promise> { + try { + return { + status: "completed", + result: await result, + ...(task === undefined ? {} : { task }), + }; + } catch (error) { + if (error instanceof TaskCancelledError) { + return { status: "cancelled", ...(task === undefined ? {} : { task }) }; + } + const failure = + error instanceof TaskFailedError + ? error + : error instanceof JsonRpcResponseError + ? new TaskFailedError( + error.message, + { code: error.code, data: error.data }, + { cause: error }, + ) + : new TaskFailedError( + error instanceof Error ? error.message : String(error), + {}, + error instanceof Error ? { cause: error } : undefined, + ); + return { + status: "failed", + error: failure, + ...(task === undefined ? {} : { task }), + }; + } +} + +export type DefaultCallToolResult = CallToolResultV1 | CallToolResultV2; diff --git a/packages/ext-tasks/src/client/port.ts b/packages/ext-tasks/src/client/port.ts index 9c3ce69..0b93dbc 100644 --- a/packages/ext-tasks/src/client/port.ts +++ b/packages/ext-tasks/src/client/port.ts @@ -3,8 +3,22 @@ import { type JsonValue, type RuntimeCodec, } from "../core/index.js"; -import type { ServerTaskCapabilitiesV1 } from "../core/v1/index.js"; -import type { ErrorV2, TasksExtensionCapabilityV2 } from "../core/v2/index.js"; +import { + CancelTaskResultV1Schema, + GetTaskResultV1Schema, + type ServerTaskCapabilitiesV1, + type TaskV1, +} from "../core/v1/index.js"; +import { + CancelTaskResultV2Schema, + GetTaskResultV2Schema, + UpdateTaskResultV2Schema, + withTaskCapabilityV2, + type DetailedTaskV2, + type ErrorV2, + type InputResponseV2, + type TasksExtensionCapabilityV2, +} from "../core/v2/index.js"; import { JsonRpcResponseError } from "./api.js"; import { throwIfAborted } from "./input-routing.js"; @@ -130,7 +144,6 @@ export async function dispatchWithRetry( port: ConnectedMcpSessionPort, request: JsonValue, dispatchOptions: DispatchOptions | AbortSignal | undefined, - retry: "observe" | "mutate", ): Promise { const options = dispatchOptions instanceof AbortSignal @@ -141,10 +154,7 @@ export async function dispatchWithRetry( return await port.dispatch(request, options); } catch (error) { throwIfAborted(signal); - if ( - !(error instanceof DispatchError) || - (retry === "mutate" && !error.retryable) - ) { + if (!(error instanceof DispatchError) || !error.retryable) { throw error; } return port.dispatch(request, options); @@ -167,9 +177,11 @@ export function parseResult( if ("safeParse" in codec) { const decoded = codec.safeParse(value); if (decoded.success) return decoded.data; - throw new ProtocolDecodeError("Protocol value failed schema validation", { - cause: decoded.error, - }); + throw new ProtocolDecodeError( + "Protocol value failed schema validation", + {}, + { cause: decoded.error }, + ); } const decoded = codec.parse(value); if (decoded.success) return decoded.value; @@ -181,3 +193,116 @@ export function responseResult(response: JsonRpcResponse): JsonValue { if (response.kind === "error") throw new JsonRpcResponseError(response.error); return response.result; } + +interface TaskRpcOptions { + readonly port: ConnectedMcpSessionPort; + readonly taskId: string; + readonly context?: DispatchContext; +} + +export interface TaskRpcV1 { + readonly generation: "v1"; + readonly get: (signal?: AbortSignal) => Promise; + readonly result: ( + codec: RuntimeCodec, + signal?: AbortSignal, + ) => Promise; + readonly cancel: (signal?: AbortSignal) => Promise; +} + +export interface TaskRpcV2 { + readonly generation: "v2"; + readonly get: (signal?: AbortSignal) => Promise; + readonly cancel: (signal?: AbortSignal) => Promise; + readonly update: ( + inputResponses: Readonly>, + signal?: AbortSignal, + ) => Promise; +} + +async function dispatchTaskRpc( + options: TaskRpcOptions, + request: JsonValue, + schema: InternalSchema | RuntimeCodec, + signal: AbortSignal | undefined, +): Promise { + const response = await dispatchWithRetry(options.port, request, { + signal, + context: options.context, + }); + return parseResult(schema, responseResult(response)); +} + +/** Creates a task-bound RPC service that owns generation-specific wire details. */ +export function createTaskRpc( + generation: "v1", + options: TaskRpcOptions, +): TaskRpcV1; +export function createTaskRpc( + generation: "v2", + options: TaskRpcOptions, +): TaskRpcV2; +export function createTaskRpc( + generation: "v1" | "v2", + options: TaskRpcOptions, +): TaskRpcV1 | TaskRpcV2 { + if (generation === "v1") { + return { + generation, + get: (signal) => + dispatchTaskRpc( + options, + { method: "tasks/get", params: { taskId: options.taskId } }, + GetTaskResultV1Schema, + signal, + ), + result: (codec, signal) => + dispatchTaskRpc( + options, + { method: "tasks/result", params: { taskId: options.taskId } }, + codec, + signal, + ), + cancel: async (signal) => { + await dispatchTaskRpc( + options, + { method: "tasks/cancel", params: { taskId: options.taskId } }, + CancelTaskResultV1Schema, + signal, + ); + }, + }; + } + + const params = >>(value: T) => + withTaskCapabilityV2(value); + return { + generation, + get: (signal) => + dispatchTaskRpc( + options, + { method: "tasks/get", params: params({ taskId: options.taskId }) }, + GetTaskResultV2Schema, + signal, + ), + cancel: async (signal) => { + await dispatchTaskRpc( + options, + { method: "tasks/cancel", params: params({ taskId: options.taskId }) }, + CancelTaskResultV2Schema, + signal, + ); + }, + update: async (inputResponses, signal) => { + await dispatchTaskRpc( + options, + { + method: "tasks/update", + params: params({ taskId: options.taskId, inputResponses }), + }, + UpdateTaskResultV2Schema, + signal, + ); + }, + }; +} diff --git a/packages/ext-tasks/src/client/protocol-matrix.test.ts b/packages/ext-tasks/src/client/protocol-matrix.test.ts new file mode 100644 index 0000000..26a7cff --- /dev/null +++ b/packages/ext-tasks/src/client/protocol-matrix.test.ts @@ -0,0 +1,181 @@ +/** Protocol matrix proving generation-neutral primary client semantics across V1 and V2. */ + +import { describe, expect, it } from "vitest"; +import type { JsonValue } from "../core/index.js"; +import { toolDeclaration, withTasks } from "./index.js"; +import type { TaskExecutionEvent } from "./index.js"; +import { FakePort, expectRecord } from "../../test-support/client/fake-port.js"; + +const declaration = toolDeclaration({ + name: "matrix", + inputSchema: { type: "object" }, + taskSupport: "required", + extensions: { extensionFlag: true }, +}); + +interface MatrixCase { + readonly generation: "v1" | "v2"; + readonly capabilities: ConstructorParameters[0]; + readonly created: JsonValue; + readonly terminal: JsonValue; +} + +const cases: readonly MatrixCase[] = [ + { + generation: "v1", + capabilities: { + generation: "v1", + capabilities: { list: {}, cancel: {}, requests: { tools: { call: {} } } }, + }, + created: { + task: { + taskId: "matrix-v1", + status: "working", + statusMessage: "started", + createdAt: "a", + lastUpdatedAt: "a", + ttl: 4000, + pollInterval: 1, + }, + }, + terminal: { + taskId: "matrix-v1", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttl: 4000, + pollInterval: 1, + }, + }, + { + generation: "v2", + capabilities: { generation: "v2", capabilities: {} }, + created: { + resultType: "task", + taskId: "matrix-v2", + status: "working", + statusMessage: "started", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: 4000, + pollIntervalMs: 1, + }, + terminal: { + resultType: "complete", + taskId: "matrix-v2", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: 4000, + pollIntervalMs: 1, + result: { content: [{ type: "text", text: "done" }] }, + }, + }, +]; + +describe.each(cases)("neutral $generation protocol matrix", (matrix) => { + it("normalizes state, one terminal outcome event, projection, retention, and capabilities", async () => { + const port = new FakePort(matrix.capabilities); + port.dispatchHandler = (request) => { + const method = expectRecord(request).method; + if (method === "tools/call") + return Promise.resolve({ + kind: "result" as const, + result: matrix.created, + }); + if (method === "tasks/get") + return Promise.resolve({ + kind: "result" as const, + result: matrix.terminal, + }); + if (method === "tasks/result") { + return Promise.resolve({ + kind: "result" as const, + result: { content: [{ type: "text", text: "done" }] }, + }); + } + return Promise.reject(new Error(`Unexpected method ${String(method)}`)); + }; + const session = withTasks(port, { + tools: { currentTool: () => declaration }, + }); + const execution = await session.callTool("matrix", undefined, { + task: { preference: "require", retentionMs: 4000 }, + }); + expect(execution.declaration).toBe(declaration); + expect(execution.handle).toEqual({ + taskId: `matrix-${matrix.generation}`, + operation: "tools/call", + }); + expect(session).not.toHaveProperty("taskGeneration"); + expect(session.capabilities).toEqual( + matrix.generation === "v1" + ? { + inventory: "server-list", + execution: true, + cancellation: true, + inputResponses: false, + requestedRetention: true, + } + : { + inventory: "known-handles", + execution: true, + cancellation: true, + inputResponses: true, + requestedRetention: false, + }, + ); + if (matrix.generation === "v1") { + expect(port.requests[0]).toMatchObject({ + params: { task: { ttl: 4000 } }, + }); + } else { + const request = expectRecord(port.requests[0]); + const params = expectRecord(request.params); + expect(params._meta).toBeDefined(); + } + + const events: TaskExecutionEvent[] = []; + for await (const event of execution.updates()) events.push(event); + const taskEvents = events.filter((event) => event.type === "task"); + const outcomeEvents = events.filter((event) => event.type === "outcome"); + expect(taskEvents.map((event) => event.task.status)).toEqual([ + "working", + "completed", + ]); + expect(taskEvents[0]).toMatchObject({ + task: { + statusMessage: "started", + createdAt: "a", + lastUpdatedAt: "a", + retentionMs: 4000, + suggestedPollIntervalMs: 1, + }, + }); + expect(taskEvents[0]?.task).not.toHaveProperty("generation"); + const createdRecord = expectRecord(matrix.created); + const wireTask = + matrix.generation === "v1" + ? expectRecord(createdRecord.task) + : createdRecord; + expect(taskEvents[0]?.task.raw).not.toBe(wireTask); + expect(taskEvents[0]?.task.raw).toEqual(wireTask); + expect(outcomeEvents).toHaveLength(1); + expect(outcomeEvents[0]).toMatchObject({ + outcome: { + status: "completed", + result: { content: [{ type: "text", text: "done" }] }, + }, + }); + expect(await execution.result()).toEqual(outcomeEvents[0]?.outcome); + const settlementPromise = execution.settle({ close: false }); + expect(execution.settle({ close: true })).toBe(settlementPromise); + const settlement = await settlementPromise; + expect(settlement.outcome).toEqual(outcomeEvents[0]?.outcome); + expect(settlement.lastTask).toMatchObject({ status: "completed" }); + expect(execution.declaration?.taskSupport).toBe("required"); + + await execution.detach(); + await session.close(); + }); +}); diff --git a/packages/ext-tasks/src/client/request-input-continuation.test.ts b/packages/ext-tasks/src/client/request-input-continuation.test.ts new file mode 100644 index 0000000..6a6189d --- /dev/null +++ b/packages/ext-tasks/src/client/request-input-continuation.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; +import type { ApplicationInputHandler, JsonRpcResponse } from "./index.js"; +import { withTasks } from "./index.js"; +import { FakePort, expectRecord } from "../../test-support/client/fake-port.js"; + +const tools = { + currentTool: () => undefined, +}; + +describe("request-scoped input-required continuation", () => { + it("preserves call params, request state, context, headers, and signal", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + const abort = new AbortController(); + let round = 0; + port.dispatchHandler = async (): Promise => { + await Promise.resolve(); + round += 1; + if (round === 1) + return { + kind: "result", + result: { + resultType: "input_required", + requestState: "opaque-1", + inputRequests: { + choice: { + method: "elicitation/create", + params: { message: "pick" }, + }, + }, + }, + }; + if (round === 2) + return { + kind: "result", + result: { + resultType: "input_required", + requestState: "opaque-2", + inputRequests: { roots: { method: "roots/list" } }, + }, + }; + return { + kind: "result", + result: { resultType: "complete", content: [] }, + }; + }; + const inputIds: (string | undefined)[] = []; + const inputSignals: (AbortSignal | undefined)[] = []; + const onInputRequest: ApplicationInputHandler<{ + trace: string; + }>["handle"] = async (request, context) => { + await Promise.resolve(); + inputIds.push(context.inputId); + expect(context).toMatchObject({ + scope: "request", + delivery: "request-retry", + applicationContext: { trace: "app" }, + }); + inputSignals.push(context.signal); + return ( + request.kind === "elicitation" + ? { action: "accept" as const } + : { roots: [{ uri: "file:///root" }] } + ) as never; + }; + const session = withTasks<{ trace: string }>(port, { + tools, + onInputRequest, + }); + const execution = await session.callTool( + "demo", + { original: true }, + { + metadata: { source: "test" }, + applicationContext: { trace: "app" }, + headers: { authorization: "secret" }, + signal: abort.signal, + }, + ); + expect(execution.kind).toBe("immediate"); + await expect(execution.result()).resolves.toEqual({ + status: "completed", + result: { resultType: "complete", content: [] }, + }); + expect(inputIds).toEqual(["choice", "roots"]); + expect(port.requests).toHaveLength(3); + const firstParams = expectRecord(expectRecord(port.requests[0]).params); + const secondParams = expectRecord(expectRecord(port.requests[1]).params); + const thirdParams = expectRecord(expectRecord(port.requests[2]).params); + expect(firstParams).toMatchObject({ + name: "demo", + arguments: { original: true }, + _meta: { source: "test" }, + }); + expect(secondParams).toMatchObject({ + ...firstParams, + requestState: "opaque-1", + inputResponses: { choice: { action: "accept" } }, + }); + expect(thirdParams).toMatchObject({ + ...firstParams, + requestState: "opaque-2", + inputResponses: { roots: { roots: [{ uri: "file:///root" }] } }, + }); + expect(port.dispatchOptions).toHaveLength(3); + const effectiveSignal = port.dispatchOptions[0]?.signal; + expect(effectiveSignal).toBeDefined(); + expect(inputSignals).toEqual([effectiveSignal, effectiveSignal]); + for (const dispatchOptions of port.dispatchOptions) { + expect(dispatchOptions?.signal).toBe(effectiveSignal); + expect(dispatchOptions?.context?.headers).toEqual({ + authorization: "secret", + }); + } + await session.close(); + }); + + it("continues before classifying a task result", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let round = 0; + port.dispatchHandler = async (): Promise => { + await Promise.resolve(); + round += 1; + return round === 1 + ? { + kind: "result", + result: { resultType: "input_required", requestState: "state" }, + } + : { + kind: "result", + result: { + resultType: "task", + taskId: "continued-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }, + }; + }; + const session = withTasks(port, { tools }); + const execution = await session.callTool("demo"); + expect(execution.kind).toBe("task"); + expect(execution.handle).toMatchObject({ taskId: "continued-task" }); + expect(expectRecord(expectRecord(port.requests[1]).params)).toMatchObject({ + name: "demo", + requestState: "state", + }); + await session.close(); + }); + + it("fails without a handler when input requests are present", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.response = { + kind: "result", + result: { + resultType: "input_required", + inputRequests: { + prompt: { method: "elicitation/create", params: {} }, + }, + }, + }; + const session = withTasks(port, { tools }); + await expect(session.callTool("demo")).rejects.toThrow( + "no onInputRequest handler", + ); + await session.close(); + }); + + it("fails immediately on repeated non-advancing requestState-only input", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.response = { + kind: "result", + result: { resultType: "input_required", requestState: "stalled" }, + }; + const session = withTasks(port, { tools }); + await expect(session.callTool("demo")).rejects.toThrow( + "repeated non-advancing requestState-only input_required", + ); + expect(port.requests).toHaveLength(2); + await session.close(); + }); + + it("limits advancing input continuation to ten rounds", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let round = 0; + port.dispatchHandler = async (): Promise => { + await Promise.resolve(); + round += 1; + return { + kind: "result", + result: { + resultType: "input_required", + requestState: `state-${String(round)}`, + inputRequests: { roots: { method: "roots/list" } }, + }, + }; + }; + const onInputRequest: ApplicationInputHandler["handle"] = async ( + request, + ) => { + await Promise.resolve(); + expect(request.kind).toBe("roots"); + return { roots: [] } as never; + }; + const session = withTasks(port, { tools, onInputRequest }); + await expect(session.callTool("demo")).rejects.toThrow( + "exceeded 10 input-required rounds", + ); + expect(port.requests).toHaveLength(11); + await session.close(); + }); + + it("does not retain requestState when a later round omits it", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let round = 0; + port.dispatchHandler = async (): Promise => { + await Promise.resolve(); + round += 1; + if (round === 1) + return { + kind: "result", + result: { + resultType: "input_required", + requestState: "per-round", + inputRequests: { roots: { method: "roots/list" } }, + }, + }; + if (round === 2) + return { + kind: "result", + result: { + resultType: "input_required", + inputRequests: { roots: { method: "roots/list" } }, + }, + }; + return { + kind: "result", + result: { resultType: "complete", content: [] }, + }; + }; + const onInputRequest: ApplicationInputHandler["handle"] = async ( + request, + ) => { + await Promise.resolve(); + expect(request.kind).toBe("roots"); + return { roots: [] } as never; + }; + const session = withTasks(port, { tools, onInputRequest }); + const execution = await session.callTool("demo"); + await expect(execution.result()).resolves.toMatchObject({ + status: "completed", + }); + const secondParams = expectRecord(expectRecord(port.requests[1]).params); + const thirdParams = expectRecord(expectRecord(port.requests[2]).params); + expect(secondParams.requestState).toBe("per-round"); + expect(thirdParams).not.toHaveProperty("requestState"); + await session.close(); + }); +}); diff --git a/packages/ext-tasks/src/client/sdk-client-adapter.ts b/packages/ext-tasks/src/client/sdk-client-adapter.ts index 31ee8ec..f144d14 100644 --- a/packages/ext-tasks/src/client/sdk-client-adapter.ts +++ b/packages/ext-tasks/src/client/sdk-client-adapter.ts @@ -1,7 +1,10 @@ import { Client, ProtocolError } from "@modelcontextprotocol/client"; -import type { StandardSchemaV1 } from "@modelcontextprotocol/client"; -import { isJsonValue } from "../core/index.js"; +import type { StandardSchemaV1, Tool } from "@modelcontextprotocol/client"; +import { isJsonValue, toJsonValue } from "../core/index.js"; import type { JsonValue } from "../core/index.js"; +import { toolDeclaration } from "./api.js"; +import type { TaskEnabledSession, WithTasksOptions } from "./api.js"; +import { withOwnedTasks } from "./session.js"; import type { DispatchOptions, SessionTaskCapabilities } from "./port.js"; import { DispatchError } from "./port.js"; import type { @@ -33,6 +36,10 @@ function isJsonRecord( ); } +function allowsInputRequired(request: JsonValue): boolean { + return isJsonRecord(request) && request.method === "tools/call"; +} + function clientTaskCapabilities( client: ClientPublicSurface, ): SessionTaskCapabilities { @@ -84,9 +91,25 @@ export type RawClientDispatch = ( options?: DispatchOptions, ) => Promise; +/** Exact V2 request metadata framing unavailable from the SDK Client public API. */ +export interface V2RequestFraming { + readonly protocolVersion: string; + readonly clientInfo: Readonly>; + readonly clientCapabilities: Readonly>; +} + /** Options for adapting an SDK Client. */ export interface ClientSessionPortOptions { readonly rawDispatch?: RawClientDispatch; + /** Required with rawDispatch for V2; copied and deeply frozen at creation. */ + readonly v2RequestFraming?: V2RequestFraming; +} + +/** Options for creating an owned task-enabled session from an MCP SDK Client. */ +export interface CreateTaskSessionFromClientOptions + extends WithTasksOptions, ClientSessionPortOptions { + /** Opaque stable identity used to scope serialized task references. */ + readonly endpointId: string; } function requiresRawDispatch( @@ -98,6 +121,132 @@ function requiresRawDispatch( return method === "tools/call" || method.startsWith("tasks/"); } +const TASKS_EXTENSION_ID = "io.modelcontextprotocol/tasks"; +const PROTOCOL_VERSION_META = "io.modelcontextprotocol/protocolVersion"; +const CLIENT_INFO_META = "io.modelcontextprotocol/clientInfo"; +const CLIENT_CAPABILITIES_META = "io.modelcontextprotocol/clientCapabilities"; + +function deepFreezeJson(value: T): T { + if (value !== null && typeof value === "object") { + for (const nested of Object.values(value)) deepFreezeJson(nested); + Object.freeze(value); + } + return value; +} + +function normalizeV2RequestFraming( + framing: V2RequestFraming, +): V2RequestFraming { + if (framing.protocolVersion.trim().length === 0) + throw new TypeError("v2RequestFraming.protocolVersion must be non-empty"); + const clientInfo = toJsonValue(framing.clientInfo); + const clientCapabilities = toJsonValue(framing.clientCapabilities); + if (!isJsonRecord(clientInfo)) + throw new TypeError("v2RequestFraming.clientInfo must be a JSON object"); + if (!isJsonRecord(clientCapabilities)) + throw new TypeError( + "v2RequestFraming.clientCapabilities must be a JSON object", + ); + return Object.freeze({ + protocolVersion: framing.protocolVersion, + clientInfo: deepFreezeJson(structuredClone(clientInfo)), + clientCapabilities: deepFreezeJson(structuredClone(clientCapabilities)), + }); +} + +/** + * Frames V2 task metadata. Package-reserved framing keys overwrite caller collisions. + */ +function frameV2TaskRequest( + request: JsonValue, + framing: V2RequestFraming, +): JsonValue { + if (!isJsonRecord(request)) + throw new DispatchError("MCP request must be a JSON object"); + const envelope = asClientRequest(request); + const params = envelope.params ?? {}; + const callerMeta = isJsonRecord(params._meta) ? params._meta : {}; + const clientCapabilities = framing.clientCapabilities; + const extensions = isJsonRecord(clientCapabilities.extensions) + ? clientCapabilities.extensions + : {}; + return { + ...request, + params: { + ...params, + _meta: { + ...callerMeta, + [PROTOCOL_VERSION_META]: framing.protocolVersion, + [CLIENT_INFO_META]: framing.clientInfo, + [CLIENT_CAPABILITIES_META]: { + ...clientCapabilities, + extensions: { ...extensions, [TASKS_EXTENSION_ID]: {} }, + }, + }, + }, + }; +} + +/** Converts an MCP SDK Tool into the package's neutral declaration. */ +export function toolDeclarationFromMcpTool( + tool: Tool, +): import("./api.js").ToolDeclaration { + const normalized = toJsonValue(tool); + if (!isJsonRecord(normalized)) + throw new TypeError("MCP tool must serialize to a JSON object"); + const inputSchema = normalized.inputSchema; + if (!isJsonRecord(inputSchema)) + throw new TypeError("MCP tool inputSchema must be a JSON object"); + const known = new Set([ + "name", + "title", + "description", + "inputSchema", + "outputSchema", + "annotations", + "icons", + "_meta", + "execution", + ]); + const execution = isJsonRecord(normalized.execution) + ? normalized.execution + : undefined; + const taskSupport = execution?.taskSupport; + const executionExtensions = + execution === undefined + ? undefined + : Object.fromEntries( + Object.entries(execution).filter(([key]) => key !== "taskSupport"), + ); + return toolDeclaration({ + name: tool.name, + ...(tool.title === undefined ? {} : { title: tool.title }), + ...(tool.description === undefined + ? {} + : { description: tool.description }), + inputSchema, + ...(isJsonRecord(normalized.outputSchema) + ? { outputSchema: normalized.outputSchema } + : {}), + ...(isJsonRecord(normalized.annotations) + ? { annotations: normalized.annotations } + : {}), + ...(Array.isArray(normalized.icons) && normalized.icons.every(isJsonRecord) + ? { icons: normalized.icons } + : {}), + ...(isJsonRecord(normalized._meta) ? { metadata: normalized._meta } : {}), + ...(taskSupport === "forbidden" || + taskSupport === "optional" || + taskSupport === "required" + ? { taskSupport } + : {}), + ...(executionExtensions === undefined ? {} : { executionExtensions }), + extensions: Object.fromEntries( + Object.entries(normalized).filter(([key]) => !known.has(key)), + ), + }); +} + type ClientPublicSurface = Pick< Client, | "request" @@ -110,36 +259,6 @@ type ClientPublicSurface = Pick< const adaptedClients = new WeakSet(); -/** Returns whether a value implements the connected MCP session port contract. */ -export function isConnectedMcpSessionPort( - value: unknown, -): value is ConnectedMcpSessionPort { - if (value === null || typeof value !== "object") return false; - const candidate = value as Partial; - return ( - typeof candidate.endpointId === "string" && - candidate.taskCapabilities !== undefined && - typeof candidate.dispatch === "function" && - typeof candidate.onServerRequest === "function" && - typeof candidate.onNotification === "function" && - typeof candidate.onInvalidated === "function" && - typeof candidate.invalidated === "boolean" - ); -} - -/** Returns whether a value exposes the MCP SDK client methods required by this adapter. */ -export function isClientPublicSurface( - value: unknown, -): value is ClientPublicSurface { - if (value === null || typeof value !== "object") return false; - const candidate = value as Partial; - return ( - typeof candidate.request === "function" && - typeof candidate.getProtocolEra === "function" && - typeof candidate.getServerCapabilities === "function" - ); -} - export class ClientSessionPort implements ConnectedMcpSessionPort { readonly taskCapabilities: SessionTaskCapabilities; private readonly serverRequestListeners = new Set< @@ -152,6 +271,7 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { private readonly previousFallbackRequestHandler: ClientPublicSurface["fallbackRequestHandler"]; private readonly previousFallbackNotificationHandler: ClientPublicSurface["fallbackNotificationHandler"]; private readonly previousOnclose: ClientPublicSurface["onclose"]; + private readonly v2RequestFraming: V2RequestFraming | undefined; private disposed = false; private isInvalidated = false; @@ -207,15 +327,23 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { private readonly client: ClientPublicSurface, readonly endpointId: string, private readonly rawDispatch?: RawClientDispatch, + v2RequestFraming?: V2RequestFraming, ) { if (adaptedClients.has(client)) throw new TypeError( "An ext-tasks adapter is already active for this Client", ); + this.v2RequestFraming = + v2RequestFraming === undefined + ? undefined + : normalizeV2RequestFraming(v2RequestFraming); this.taskCapabilities = clientTaskCapabilities(client); - if (this.taskCapabilities.generation === "v2" && rawDispatch === undefined) + if ( + this.taskCapabilities.generation === "v2" && + (rawDispatch === undefined || this.v2RequestFraming === undefined) + ) throw new TypeError( - "SDK Client cannot safely coordinate V2 task wire shapes; createSessionPortFromClient requires options.rawDispatch for a V2 session", + "A V2 task session requires options.rawDispatch and options.v2RequestFraming to coordinate task wire shapes", ); this.previousFallbackRequestHandler = client.fallbackRequestHandler; this.previousFallbackNotificationHandler = @@ -237,16 +365,23 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { ): Promise { try { if (requiresRawDispatch(this.taskCapabilities, request)) { - if (this.rawDispatch === undefined) + if ( + this.rawDispatch === undefined || + this.v2RequestFraming === undefined + ) throw new DispatchError( - "SDK Client cannot dispatch V2 task wire shapes; provide rawDispatch", + "SDK Client cannot dispatch V2 task wire shapes without rawDispatch and v2RequestFraming", ); - return await this.rawDispatch(request, options); + return await this.rawDispatch( + frameV2TaskRequest(request, this.v2RequestFraming), + options, + ); } const result = await this.client.request( asClientRequest(request), jsonValueSchema, { + ...(allowsInputRequired(request) ? { allowInputRequired: true } : {}), ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.context?.headers === undefined ? {} @@ -322,5 +457,31 @@ export function createSessionPortFromClient( endpointId: string, options: ClientSessionPortOptions = {}, ): ConnectedMcpSessionPort & Disposable { - return new ClientSessionPort(client, endpointId, options.rawDispatch); + return new ClientSessionPort( + client, + endpointId, + options.rawDispatch, + options.v2RequestFraming, + ); +} + +/** Creates a task-enabled session that owns and disposes its Client adapter. */ +export function createTaskSessionFromClient( + client: Client, + options: CreateTaskSessionFromClientOptions, +): TaskEnabledSession { + const { endpointId, rawDispatch, v2RequestFraming, ...sessionOptions } = + options; + const port = createSessionPortFromClient(client, endpointId, { + rawDispatch, + v2RequestFraming, + }); + try { + return withOwnedTasks(port, sessionOptions, () => { + port[Symbol.dispose](); + }); + } catch (error) { + port[Symbol.dispose](); + throw error; + } } diff --git a/packages/ext-tasks/src/client/session-facade.test.ts b/packages/ext-tasks/src/client/session-facade.test.ts new file mode 100644 index 0000000..d0414cf --- /dev/null +++ b/packages/ext-tasks/src/client/session-facade.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from "vitest"; +import { taskId } from "../core/index.js"; +import { withTasks } from "./index.js"; +import { FakePort, asJson } from "../../test-support/client/fake-port.js"; + +const task = (id: string, status = "working") => ({ + taskId: id, + status, + createdAt: "2026-01-01T00:00:00Z", + lastUpdatedAt: "2026-01-01T00:00:00Z", + ttl: 1_000, +}); + +describe("task session facade", () => { + it("owns immediate call registration and settlement", async () => { + const port = new FakePort(); + port.response = { kind: "result", result: { content: [] } }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + + await expect(session.callToolAndSettle("echo")).resolves.toEqual({ + outcome: { status: "completed", result: { content: [] } }, + lastTask: undefined, + handle: undefined, + }); + await session.close(); + }); + + it("lists V1 server inventory", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { list: {}, requests: { tools: { call: {} } } }, + }); + port.response = { + kind: "result", + result: { tasks: [task("listed")], nextCursor: "next" }, + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + + const page = await session.listTasks("start"); + expect(port.requests).toEqual([ + { method: "tasks/list", params: { cursor: "start" } }, + ]); + expect(page.nextCursor).toBe("next"); + expect(page.tasks[0]?.taskId).toBe("listed"); + await session.close(); + }); + + it("settles an owned V2 task", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let reads = 0; + port.dispatchHandler = (request) => { + const method = (request as { method?: string }).method; + if (method === "tools/call") + return Promise.resolve({ + kind: "result", + result: { + resultType: "task", + taskId: "owned", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: 1_000, + }, + }); + reads += 1; + return Promise.resolve({ + kind: "result", + result: { + resultType: "complete", + taskId: "owned", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: 1_000, + result: { content: [] }, + }, + }); + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + + const settled = await session.callToolAndSettle("echo"); + expect(settled.handle).toEqual({ + taskId: "owned", + operation: "tools/call", + }); + expect(settled.outcome.status).toBe("completed"); + expect(reads).toBeGreaterThan(0); + await session.close(); + }); + + it("routes cancellation to a live execution and aborts owned input", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async (request) => { + const method = (request as { method?: string }).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "live", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: 1_000, + }), + }; + if (method === "tasks/cancel") + return { kind: "result", result: asJson({}) }; + return new Promise(() => {}); + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("echo"); + const inputSignal = ( + execution as unknown as { inputSignal(): AbortSignal } + ).inputSignal(); + + await session.cancelTask(taskId("live")); + expect(inputSignal.aborted).toBe(true); + expect( + port.requests.filter( + (request) => (request as { method?: string }).method === "tasks/cancel", + ), + ).toHaveLength(1); + await session.close(); + }); + + it("reports detached task cancellation failure", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = () => { + throw new Error("cancel failed"); + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + await expect(session.cancelTask(taskId("failed-cancel"))).rejects.toThrow( + "cancel failed", + ); + await session.close(); + }); + + it("uses a detached controller and tolerates concurrent close/cancel", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let releaseCancellation: () => void = () => {}; + const releasePromise = new Promise((resolve) => { + releaseCancellation = resolve; + }); + const cancelSeen = vi.fn(); + port.dispatchHandler = async (request) => { + if ((request as { method?: string }).method === "tasks/cancel") { + cancelSeen(); + await releasePromise; + return { kind: "result", result: {} }; + } + return { kind: "result", result: {} }; + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const cancellation = session.cancelTask(taskId("detached")); + await vi.waitFor(() => { + expect(cancelSeen).toHaveBeenCalledOnce(); + }); + const closing = session.close(); + releaseCancellation(); + await expect(Promise.all([cancellation, closing])).resolves.toEqual([ + undefined, + undefined, + ]); + }); +}); diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index 0057810..987a98e 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -1,30 +1,39 @@ import { isJsonValue } from "../core/index.js"; -import type { - JsonValue, - RuntimeCodec, - TaskId, - TaskSnapshot, -} from "../core/index.js"; +import type { JsonValue, RuntimeCodec, TaskId } from "../core/index.js"; import { CreateTaskResultV1Schema, - GetTaskResultV1Schema, + ListTasksResultV1Schema, TaskStatusNotificationV1Schema, shouldCallToolAsTaskV1, } from "../core/v1/index.js"; import type { CallToolResultV1, TaskV1 } from "../core/v1/index.js"; import { CreateTaskResultV2Schema, - GetTaskResultV2Schema, + DetailedTaskV2Schema, + InputRequiredCallToolResultV2Schema, TaskStatusNotificationV2Schema, isCreateTaskResultV2, withTaskCapabilityV2, } from "../core/v2/index.js"; -import type { CallToolResultV2, DetailedTaskV2 } from "../core/v2/index.js"; -import { InputCorrelationError, TaskRecoveryOwnershipError } from "./api.js"; import type { + CallToolResultV2, + DetailedTaskV2, + InputRequestV2, + InputResponsesV2, +} from "../core/v2/index.js"; +import { + InputCorrelationError, + TaskRecoveryOwnershipError, + TaskRetentionUnsupportedError, +} from "./api.js"; +import type { TaskController, TaskControllerOptions } from "./api.js"; +import type { TaskRecoveryOptions, TaskSessionEndpointId } from "./api.js"; +import type { + CallToolAndSettleOptions, + CallToolAndSettleResult, SerializedTaskReference, TaskEnabledSession, - TaskHandle, + TaskListPage, ToolCallOptions, ToolDeclarationProvider, ToolExecution, @@ -36,6 +45,12 @@ import { defaultResultCodec, reasonAsError, } from "./execution.js"; +import { + projectTask, + projectToolForGeneration, + semanticCapabilities, +} from "./internal.js"; +import type { InternalTaskHandle, InternalTaskSnapshot } from "./internal.js"; import { buildResolvedInputContext, defaultServerRequestResponse, @@ -50,6 +65,7 @@ import type { V1TaskInputCandidate, } from "./input-routing.js"; import { + createTaskRpc, parseResult, dispatchWithRetry, linkAbortSignals, @@ -62,15 +78,22 @@ import type { JsonRpcResponse, SessionTaskCapabilities, } from "./port.js"; +import { createTaskController } from "./task-controller.js"; import { ManagedToolDeclarations } from "./tool-declarations.js"; import { createTaskExecutionV1 } from "./task-protocol-v1.js"; -import { createTaskExecutionV2 } from "./task-protocol-v2.js"; +import { + createTaskExecutionV2, + projectInputRequest, + responseSchemaForInputRequest, +} from "./task-protocol-v2.js"; type TaskIdentityOwner = { readonly originalOperation: string; readonly token: symbol; }; +const MAX_REQUEST_INPUT_ROUNDS = 10; + function taskIdentityKey(reference: { readonly generation: "v1" | "v2"; readonly taskId: TaskId; @@ -103,6 +126,8 @@ function selectResultCodec( class PortTaskEnabledSession< TApplicationContext, > implements TaskEnabledSession { + readonly endpointId: TaskSessionEndpointId; + readonly capabilities; private closed = false; private closePromise: Promise | undefined; private readonly lifecycleController = new AbortController(); @@ -122,12 +147,18 @@ class PortTaskEnabledSession< TaskExecution >(); private readonly taskIdentityOwners = new Map(); + private readonly activeTaskExecutionsById = new Map< + TaskId, + TaskExecution + >(); constructor( private readonly port: ConnectedMcpSessionPort, private readonly options: WithTasksOptions, disposePort?: () => void, ) { + this.endpointId = port.endpointId as TaskSessionEndpointId; + this.capabilities = semanticCapabilities(port.taskCapabilities); const reportError = (error: Error): void => { try { this.options.onError?.(error); @@ -178,6 +209,82 @@ class PortTaskEnabledSession< } } + task(taskId: TaskId, options: TaskControllerOptions = {}): TaskController { + this.assertUsable(); + return createTaskController( + this.port, + taskId, + options, + this.lifecycleController.signal, + () => { + this.assertUsable(); + }, + ); + } + + async listTasks( + cursor?: string, + signal?: AbortSignal, + ): Promise { + this.assertUsable(); + if (this.capabilities.inventory !== "server-list") + throw new Error("Server task inventory is not supported by this session"); + const response = await dispatchWithRetry( + this.port, + { + method: "tasks/list", + ...(cursor === undefined ? {} : { params: { cursor } }), + }, + { signal }, + ); + const result = parseResult( + ListTasksResultV1Schema, + responseResult(response), + ); + const tasks = result.tasks.map((task) => + projectTask({ generation: "v1", task }), + ); + return { + tasks, + ...(result.nextCursor === undefined + ? {} + : { nextCursor: result.nextCursor }), + }; + } + + async cancelTask(taskId: TaskId, signal?: AbortSignal): Promise { + this.assertUsable(); + const execution = this.activeTaskExecutionsById.get(taskId); + if (execution !== undefined) { + execution.endInputLifetime(); + await execution.cancel(signal); + return; + } + const operationLifecycle = new AbortController(); + await createTaskController( + this.port, + taskId, + {}, + operationLifecycle.signal, + () => {}, + ).cancel(signal); + } + + async callToolAndSettle( + name: string, + params?: Readonly>, + options: CallToolAndSettleOptions = {}, + ): Promise> { + const { onEvent, close, ...callOptions } = options; + const execution = await this.callTool(name, params, callOptions); + const settlement = await execution.settle({ + signal: options.signal, + close, + onEvent, + }); + return { ...settlement, handle: execution.handle }; + } + async callTool( name: string, params?: Readonly>, @@ -189,41 +296,47 @@ class PortTaskEnabledSession< options.signal, ); const callSignal = callLifecycle.signal; - let declaration: ReturnType; + let declaration = options.declaration; try { throwIfAborted(callSignal); await this.managedDeclarations?.ensureReady(callSignal); this.assertUsable(); - declaration = this.declarations.currentTool(name); + declaration ??= this.declarations.currentTool(name); } catch (error) { callLifecycle.dispose(); throw error; } - if ( - declaration !== undefined && - declaration.generation !== this.port.taskCapabilities.generation && - this.port.taskCapabilities.generation !== "none" - ) { - callLifecycle.dispose(); - throw new Error( - `${declaration.generation.toUpperCase()} tool declaration is incompatible with the ${this.port.taskCapabilities.generation.toUpperCase()} session`, - ); - } const requestParams: Record = { name }; if (params !== undefined) requestParams.arguments = params; if (options.metadata !== undefined) requestParams._meta = options.metadata; const generation = this.port.taskCapabilities.generation; + const preference = options.task?.preference ?? "allow"; + if ( + options.task?.retentionMs !== undefined && + options.task.retention === "require-capability" && + !this.capabilities.requestedRetention + ) { + callLifecycle.dispose(); + throw new TaskRetentionUnsupportedError(); + } const callAsTaskV1 = generation === "v1" && - declaration?.generation === "v1" && + declaration !== undefined && shouldCallToolAsTaskV1( this.port.taskCapabilities.capabilities, - declaration.tool, - options.preferTask, - ); + projectToolForGeneration(declaration, "v1"), + preference === "prefer" || preference === "require", + ) && + preference !== "forbid"; + if (preference === "require" && generation !== "v2" && !callAsTaskV1) { + callLifecycle.dispose(); + throw new Error("Task execution was required but is unavailable"); + } if (callAsTaskV1) requestParams.task = - options.taskTtl === undefined ? {} : { ttl: options.taskTtl }; + options.task?.retentionMs === undefined + ? {} + : { ttl: options.task.retentionMs }; const dispatchContext = options.headers === undefined ? undefined : { headers: options.headers }; const executionId = nextExecutionIdentifier(); @@ -235,46 +348,92 @@ class PortTaskEnabledSession< applicationContext: options.applicationContext as TApplicationContext, signal: callSignal, }); - const dispatchPromise = dispatchWithRetry( - this.port, - { - method: "tools/call", - params: - generation === "v2" - ? withTaskCapabilityV2(requestParams) - : requestParams, - }, - { signal: callSignal, context: dispatchContext }, - "mutate", - ); - let response: JsonRpcResponse; + let continuedRequestParams: Record = requestParams; + let response: JsonRpcResponse | undefined; try { - response = await withAbort(dispatchPromise, callSignal); + let inputRound = 0; + let previousRequestStateOnly: string | undefined; + for (;;) { + const dispatchPromise = dispatchWithRetry( + this.port, + { + method: "tools/call", + params: + generation === "v2" + ? withTaskCapabilityV2(continuedRequestParams) + : continuedRequestParams, + }, + { signal: callSignal, context: dispatchContext }, + ); + try { + response = await withAbort(dispatchPromise, callSignal); + } catch (error) { + void dispatchPromise.then( + (lateResponse) => { + this.cleanupLateTaskCreation( + lateResponse, + generation, + callAsTaskV1, + ); + }, + () => {}, + ); + throw error; + } + this.assertUsable(); + throwIfAborted(callSignal); + const roundResult = responseResult(response); + if (!this.isRequestInputRequired(roundResult)) break; + if (inputRound >= MAX_REQUEST_INPUT_ROUNDS) + throw new Error( + `Tool call exceeded ${String(MAX_REQUEST_INPUT_ROUNDS)} input-required rounds`, + ); + const inputRequired = parseResult( + InputRequiredCallToolResultV2Schema, + roundResult, + ); + const hasInputRequests = inputRequired.inputRequests !== undefined; + if ( + !hasInputRequests && + inputRequired.requestState === previousRequestStateOnly + ) + throw new Error( + "Tool call returned repeated non-advancing requestState-only input_required", + ); + previousRequestStateOnly = hasInputRequests + ? undefined + : inputRequired.requestState; + const inputResponses = await this.resolveRequestInputResponses( + inputRequired.inputRequests ?? {}, + executionId, + options.applicationContext as TApplicationContext, + callSignal, + ); + continuedRequestParams = { + ...requestParams, + ...(Object.keys(inputResponses).length === 0 + ? {} + : { inputResponses }), + ...(inputRequired.requestState === undefined + ? {} + : { requestState: inputRequired.requestState }), + }; + inputRound += 1; + } } catch (error) { - void dispatchPromise.then( - (lateResponse) => { - this.cleanupLateTaskCreation(lateResponse, generation, callAsTaskV1); - }, - () => {}, - ); + if (response !== undefined) + this.cleanupLateTaskCreation(response, generation, callAsTaskV1); throw error; } finally { this.ordinaryInputCandidates.delete(executionId); callLifecycle.dispose(); } - try { - this.assertUsable(); - throwIfAborted(callSignal); - } catch (error) { - this.cleanupLateTaskCreation(response, generation, callAsTaskV1); - throw error; - } const wireResult = responseResult(response); const codec = selectResultCodec(generation, options.resultCodec); if (generation === "v1" && callAsTaskV1) { const created = parseResult(CreateTaskResultV1Schema, wireResult); - const handle: TaskHandle & { readonly generation: "v1" } = { + const handle: InternalTaskHandle & { readonly generation: "v1" } = { generation: "v1", taskId: created.task.taskId as TaskId, originalOperation: "tools/call", @@ -283,6 +442,7 @@ class PortTaskEnabledSession< const execution = createTaskExecutionV1({ applicationContext: options.applicationContext as TApplicationContext, handle, + declaration, initialTask: created.task, resultCodec: codec, port: this.port, @@ -306,7 +466,7 @@ class PortTaskEnabledSession< if (generation === "v2" && isCreateTaskResultV2(wireResult)) { const created = parseResult(CreateTaskResultV2Schema, wireResult); - const handle: TaskHandle & { readonly generation: "v2" } = { + const handle: InternalTaskHandle & { readonly generation: "v2" } = { generation: "v2", taskId: created.taskId as TaskId, originalOperation: "tools/call", @@ -315,6 +475,7 @@ class PortTaskEnabledSession< return this.trackTaskExecution( createTaskExecutionV2({ applicationContext: options.applicationContext as TApplicationContext, + declaration, handle, initialTask: created, resultCodec: codec, @@ -335,16 +496,14 @@ class PortTaskEnabledSession< return new ImmediateExecution( options.applicationContext as TApplicationContext, resultPromise, + declaration, ); } async resumeTask( reference: SerializedTaskReference, - options: { - readonly resultCodec?: RuntimeCodec; - readonly applicationContext?: TApplicationContext; - readonly signal?: AbortSignal; - } = {}, + options: TaskRecoveryOptions = {}, + initialSnapshot?: InternalTaskSnapshot, ): Promise> { this.assertUsable(); const capabilities = this.port.taskCapabilities; @@ -357,7 +516,6 @@ class PortTaskEnabledSession< ); if (activeTaskIdentity !== undefined) { throw new TaskRecoveryOwnershipError( - reference.generation, reference.taskId, reference.originalOperation, activeTaskIdentity.originalOperation, @@ -377,28 +535,22 @@ class PortTaskEnabledSession< const codec = selectResultCodec(reference.generation, options.resultCodec); try { throwIfAborted(resumeSignal); - const response = await dispatchWithRetry( - this.port, - { - method: "tasks/get", - params: - reference.generation === "v2" - ? withTaskCapabilityV2({ taskId: reference.taskId }) - : { taskId: reference.taskId }, - }, - resumeSignal, - "observe", - ); this.assertUsable(); throwIfAborted(resumeSignal); if (reference.generation === "v1") { - const task = parseResult( - GetTaskResultV1Schema, - responseResult(response), - ); + const task = + initialSnapshot?.generation === "v1" + ? initialSnapshot.task + : await createTaskRpc(reference.generation, { + port: this.port, + taskId: reference.taskId, + }).get(resumeSignal); + this.assertUsable(); + throwIfAborted(resumeSignal); const execution = createTaskExecutionV1({ applicationContext: options.applicationContext as TApplicationContext, + declaration: options.declaration, handle: reference, initialTask: task, resultCodec: codec, @@ -423,12 +575,32 @@ class PortTaskEnabledSession< return tracked; } - const task = parseResult(GetTaskResultV2Schema, responseResult(response)); + const seededTask = + initialSnapshot?.generation === "v2" ? initialSnapshot.task : undefined; + const seededDetailed = + seededTask === undefined + ? undefined + : DetailedTaskV2Schema.safeParse(seededTask); + const seededDetailedTask = + seededDetailed?.success === true ? seededDetailed.data : undefined; + const detailedTask = + seededTask === undefined + ? await createTaskRpc(reference.generation, { + port: this.port, + taskId: reference.taskId, + }).get(resumeSignal) + : seededDetailedTask; + const task = seededTask ?? detailedTask; + if (task === undefined) + throw new Error("Task recovery produced no initial task"); + this.assertUsable(); + throwIfAborted(resumeSignal); const execution = createTaskExecutionV2({ applicationContext: options.applicationContext as TApplicationContext, + declaration: options.declaration, handle: reference, initialTask: task, - initialDetailedTask: task, + initialDetailedTask: detailedTask, resultCodec: codec, port: this.port, lifecycleSignal: this.lifecycleController.signal, @@ -482,7 +654,6 @@ class PortTaskEnabledSession< this.port, { method: "tasks/cancel", params }, undefined, - "mutate", ).catch(() => { // A task returned after call abort is cleaned up on a best-effort basis. }); @@ -521,13 +692,12 @@ class PortTaskEnabledSession< } private acquireTaskIdentity( - reference: SerializedTaskReference | TaskHandle, + reference: SerializedTaskReference | InternalTaskHandle, ): () => void { const key = taskIdentityKey(reference); const active = this.taskIdentityOwners.get(key); if (active !== undefined) { throw new TaskRecoveryOwnershipError( - reference.generation, reference.taskId, reference.originalOperation, active.originalOperation, @@ -552,6 +722,7 @@ class PortTaskEnabledSession< ): TaskExecution { const tracked = execution as TaskExecution; this.activeTaskExecutions.add(tracked); + this.activeTaskExecutionsById.set(execution.handle.taskId, tracked); if ( v1InputCandidate !== undefined && v1InputCandidate.signal?.aborted !== true @@ -568,16 +739,17 @@ class PortTaskEnabledSession< { once: true }, ); } - void execution - .result() - .catch(() => {}) - .finally(() => { - execution.endInputLifetime(); - this.activeTaskExecutions.delete(tracked); - if (v1InputCandidate !== undefined) - this.v1TaskInputCandidates.delete(v1InputCandidate.executionId); - releaseTaskIdentity?.(); - }); + void execution.result().finally(() => { + execution.endInputLifetime(); + this.activeTaskExecutions.delete(tracked); + if ( + this.activeTaskExecutionsById.get(execution.handle.taskId) === tracked + ) + this.activeTaskExecutionsById.delete(execution.handle.taskId); + if (v1InputCandidate !== undefined) + this.v1TaskInputCandidates.delete(v1InputCandidate.executionId); + releaseTaskIdentity?.(); + }); return execution; } @@ -602,7 +774,7 @@ class PortTaskEnabledSession< this.reportBackgroundError(parsed.error); return; } - const snapshot: TaskSnapshot = + const snapshot: InternalTaskSnapshot = generation === "v1" ? { generation: "v1", task: parsed.data.params as TaskV1 } : { generation: "v2", task: parsed.data.params as DetailedTaskV2 }; @@ -611,6 +783,55 @@ class PortTaskEnabledSession< } } + private isRequestInputRequired(value: JsonValue): value is Readonly< + Record + > & { + readonly resultType: "input_required"; + } { + return ( + value !== null && + !Array.isArray(value) && + typeof value === "object" && + (value as Readonly>).resultType === + "input_required" + ); + } + + private async resolveRequestInputResponses( + inputRequests: Readonly>, + executionId: string, + applicationContext: TApplicationContext, + signal: AbortSignal, + ): Promise { + if (Object.keys(inputRequests).length === 0) return {}; + if (this.options.onInputRequest === undefined) + throw new Error( + "Tool call requires input, but no onInputRequest handler is configured", + ); + const inputResponses: Record = {}; + for (const [inputId, inputRequest] of Object.entries(inputRequests)) { + throwIfAborted(signal); + const result = await this.options.onInputRequest( + projectInputRequest(inputRequest), + { + scope: "request", + delivery: "request-retry", + inputId, + applicationContext, + signal, + }, + ); + throwIfAborted(signal); + inputResponses[inputId] = parseResult( + responseSchemaForInputRequest(inputRequest), + result as JsonValue, + ); + } + if (this.ordinaryInputCandidates.get(executionId) === undefined) + throw new Error("Tool call input lifetime ended before retry"); + return inputResponses; + } + private async handleServerRequest( incoming: IncomingServerRequest, ): Promise { @@ -625,9 +846,6 @@ class PortTaskEnabledSession< if (resolution.kind === "failed") { this.reportBackgroundError( new InputCorrelationError( - this.port.taskCapabilities.generation === "none" - ? "v1" - : this.port.taskCapabilities.generation, request.kind, resolution.candidates, resolution.reason, @@ -671,3 +889,12 @@ export function withTasks( ): TaskEnabledSession { return new PortTaskEnabledSession(session, options); } + +/** @internal Creates a task session that owns disposal of its connected port. */ +export function withOwnedTasks( + session: ConnectedMcpSessionPort, + options: WithTasksOptions, + disposePort: () => void, +): TaskEnabledSession { + return new PortTaskEnabledSession(session, options, disposePort); +} diff --git a/packages/ext-tasks/src/client/task-controller.test.ts b/packages/ext-tasks/src/client/task-controller.test.ts new file mode 100644 index 0000000..370b7d8 --- /dev/null +++ b/packages/ext-tasks/src/client/task-controller.test.ts @@ -0,0 +1,500 @@ +import { describe, expect, it } from "vitest"; +import { ProtocolDecodeError, taskId } from "../core/index.js"; +import { + TaskCancellationUnsupportedError, + TaskCancelledError, + TaskFailedError, + TaskInputUpdateUnsupportedError, + withTasks, +} from "./index.js"; +import { + FakePort, + asJson, + expectRecord, +} from "../../test-support/client/fake-port.js"; + +const v1Task = { + taskId: "manual-v1", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttl: null, +} as const; + +const v2CompletedTask = { + resultType: "complete", + taskId: "manual-v2", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { resultType: "complete", content: [] }, +} as const; + +const tools = { currentTool: () => undefined }; + +function methods(port: FakePort): unknown[] { + return port.requests.map((request) => expectRecord(request).method); +} + +describe("manual task controller", () => { + it("uses V1 get, result, and cancel requests and preserves headers", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } }, cancel: {} }, + }); + port.dispatchHandler = (request) => { + const method = expectRecord(request).method; + if (method === "tasks/result") + return Promise.resolve({ kind: "result", result: { content: [] } }); + return Promise.resolve({ kind: "result", result: asJson(v1Task) }); + }; + const session = withTasks(port, { tools }); + const controller = session.task(taskId("manual-v1"), { + headers: { authorization: "Bearer test" }, + }); + + await expect(controller.snapshot()).resolves.toMatchObject({ + taskId: "manual-v1", + status: "completed", + retentionMs: null, + createdAt: "a", + lastUpdatedAt: "b", + }); + await expect(legacyResult(controller)).resolves.toEqual({ content: [] }); + await expect(controller.cancel()).resolves.toBeUndefined(); + expect(methods(port)).toEqual([ + "tasks/get", + "tasks/result", + "tasks/cancel", + ]); + for (const dispatchOptions of port.dispatchOptions) { + expect(dispatchOptions?.signal).toBeDefined(); + expect(dispatchOptions?.signal?.aborted).toBe(false); + expect(dispatchOptions?.context).toEqual({ + headers: { authorization: "Bearer test" }, + }); + } + await session.close(); + }); + + it("uses V2 envelopes for get, decoded result, cancel, and input update", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = (request) => { + const method = expectRecord(request).method; + return Promise.resolve( + method === "tasks/get" + ? { kind: "result", result: asJson(v2CompletedTask) } + : { kind: "result", result: { resultType: "complete" } }, + ); + }; + const session = withTasks(port, { tools }); + const controller = session.task(taskId("manual-v2")); + + await expect(controller.snapshot()).resolves.toMatchObject({ + taskId: "manual-v2", + status: "completed", + retentionMs: null, + createdAt: "a", + lastUpdatedAt: "b", + }); + await expect(legacyResult(controller)).resolves.toEqual({ + resultType: "complete", + content: [], + }); + await expect(controller.cancel()).resolves.toBeUndefined(); + await expect( + controller.update({ prompt: { action: "cancel" } }), + ).resolves.toBeUndefined(); + expect(methods(port)).toEqual([ + "tasks/get", + "tasks/get", + "tasks/cancel", + "tasks/update", + ]); + for (const request of port.requests) { + const params = expectRecord(expectRecord(request).params); + expect(params).toMatchObject({ + taskId: "manual-v2", + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }); + } + expect( + expectRecord(expectRecord(port.requests[3]).params).inputResponses, + ).toEqual({ prompt: { action: "cancel" } }); + await session.close(); + }); + + it("normalizes unknown JSON input responses before dispatch", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.response = { kind: "result", result: { resultType: "complete" } }; + const session = withTasks(port, { tools }); + const controller = session.task(taskId("json-update")); + await expect( + controller.updateJson({ prompt: { action: "cancel" } }), + ).resolves.toBeUndefined(); + expect( + expectRecord(expectRecord(port.requests[0]).params).inputResponses, + ).toEqual({ prompt: { action: "cancel" } }); + await expect( + controller.updateJson({ prompt: { action: "not-valid" } }), + ).rejects.toBeInstanceOf(ProtocolDecodeError); + expect(port.requests).toHaveLength(1); + await session.close(); + }); + + it("preserves unknown-task JSON-RPC errors without retrying", async () => { + const error = { + code: -32001, + message: "Unknown task", + data: { taskId: "missing" }, + }; + for (const generation of ["v1", "v2"] as const) { + const port = new FakePort( + generation === "v1" + ? { generation, capabilities: { requests: { tools: { call: {} } } } } + : { generation, capabilities: {} }, + ); + port.response = { kind: "error", error }; + const session = withTasks(port, { tools }); + + await expect( + session.task(taskId("missing")).snapshot(), + ).rejects.toMatchObject({ + name: "JsonRpcResponseError", + ...error, + }); + expect(methods(port)).toEqual(["tasks/get"]); + await session.close(); + } + }); + + it("checks generation and operation usability before decoding JSON updates", async () => { + const v1Port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } } }, + }); + const v1Session = withTasks(v1Port, { tools }); + await expect( + v1Session.task(taskId("v1-json-update")).updateJson({ invalid: true }), + ).rejects.toBeInstanceOf(TaskInputUpdateUnsupportedError); + await v1Session.close(); + + const abortedPort = new FakePort({ generation: "v2", capabilities: {} }); + const abortedSession = withTasks(abortedPort, { tools }); + const caller = new AbortController(); + const reason = new Error("caller stopped"); + caller.abort(reason); + await expect( + abortedSession + .task(taskId("aborted-json-update")) + .updateJson({ invalid: true }, caller.signal), + ).rejects.toBe(reason); + expect(abortedPort.requests).toHaveLength(0); + await abortedSession.close(); + + const closedPort = new FakePort({ generation: "v2", capabilities: {} }); + const closedSession = withTasks(closedPort, { tools }); + const controller = closedSession.task(taskId("closed-json-update")); + await closedSession.close(); + await expect(controller.updateJson({ invalid: true })).rejects.toThrow( + "closed", + ); + expect(closedPort.requests).toHaveLength(0); + }); + + it("decodes V1 results with a custom result codec", async () => { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } } }, + }); + port.response = { kind: "result", result: { custom: "v1" } }; + const session = withTasks(port, { tools }); + + await expect( + session.task(taskId("custom-v1")).result({ + resultCodec: { + parse: (value) => ({ + success: true, + value: expectRecord(value).custom, + }), + }, + }), + ).resolves.toEqual({ status: "completed", result: "v1" }); + await session.close(); + }); + + it("decodes V2 terminal results with a custom result codec", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.response = { + kind: "result", + result: asJson({ + ...v2CompletedTask, + taskId: "custom-v2", + result: { custom: "v2" }, + }), + }; + const session = withTasks(port, { tools }); + + await expect( + session.task(taskId("custom-v2")).result({ + resultCodec: { + parse: (value) => ({ + success: true, + value: expectRecord(value).custom, + }), + }, + }), + ).resolves.toMatchObject({ status: "completed", result: "v2" }); + await session.close(); + }); + + it("propagates custom result codec errors for V1 and V2", async () => { + for (const generation of ["v1", "v2"] as const) { + const port = + generation === "v1" + ? new FakePort({ + generation, + capabilities: { requests: { tools: { call: {} } } }, + }) + : new FakePort({ generation, capabilities: {} }); + port.response = + generation === "v1" + ? { kind: "result", result: { custom: generation } } + : { + kind: "result", + result: asJson({ + ...v2CompletedTask, + taskId: `failing-${generation}`, + result: { custom: generation }, + }), + }; + const codecError = new ProtocolDecodeError(`${generation} codec failed`); + const session = withTasks(port, { tools }); + + const outcome = await session + .task(taskId(`failing-${generation}`)) + .result({ + resultCodec: { + parse: () => ({ success: false, error: codecError }), + }, + }); + expect(outcome).toMatchObject({ + status: "failed", + error: { + name: "TaskFailedError", + message: `${generation} codec failed`, + }, + }); + if (outcome.status === "failed") + expect(outcome.error.cause).toBe(codecError); + await session.close(); + } + }); + + it("reports unsupported session, V1 update, and V1 cancellation explicitly", async () => { + const plain = withTasks(new FakePort(), { tools }); + expect(() => plain.task(taskId("none"))).toThrow( + "Task management is not supported by this session", + ); + await plain.close(); + + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } } }, + }); + const session = withTasks(port, { tools }); + const controller = session.task(taskId("v1")); + await expect(controller.update({})).rejects.toBeInstanceOf( + TaskInputUpdateUnsupportedError, + ); + await expect(controller.cancel()).rejects.toBeInstanceOf( + TaskCancellationUnsupportedError, + ); + expect(port.requests).toHaveLength(0); + await session.close(); + }); + + it("polls through input-required and working V2 states without handling input", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + const statuses = ["input_required", "working", "completed"] as const; + let getCalls = 0; + port.dispatchHandler = () => { + const status = statuses[getCalls++] ?? "completed"; + return Promise.resolve({ + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "polling", + status, + createdAt: "a", + lastUpdatedAt: String(getCalls), + ttlMs: null, + pollIntervalMs: 0, + ...(status === "input_required" + ? { inputRequests: { prompt: { method: "roots/list" } } } + : {}), + ...(status === "completed" + ? { result: { resultType: "complete", content: [] } } + : {}), + }), + }); + }; + const session = withTasks(port, { tools }); + + await expect( + legacyResult(session.task(taskId("polling"))), + ).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(methods(port)).toEqual(["tasks/get", "tasks/get", "tasks/get"]); + await session.close(); + }); + + it("surfaces neutral failed and cancelled V2 terminal outcomes", async () => { + for (const terminal of [ + { + status: "failed", + error: { code: -32000, message: "task failed", data: { retry: false } }, + }, + { status: "cancelled" }, + ] as const) { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.response = { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: terminal.status, + status: terminal.status, + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + ...(terminal.status === "failed" ? { error: terminal.error } : {}), + }), + }; + const session = withTasks(port, { tools }); + const outcome = await session.task(taskId(terminal.status)).result(); + if (terminal.status === "failed") { + expect(outcome).toMatchObject({ + status: "failed", + error: { + name: "TaskFailedError", + message: "task failed", + code: -32000, + data: { retry: false }, + }, + }); + if (outcome.status === "failed") + expect(outcome.error).toBeInstanceOf(TaskFailedError); + } else { + expect(outcome.status).toBe("cancelled"); + const legacy = legacyResult(session.task(taskId(terminal.status))); + await expect(legacy).rejects.toBeInstanceOf(TaskCancelledError); + } + await session.close(); + } + }); + + it("propagates caller aborts before and during dispatch", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + const session = withTasks(port, { tools }); + const controller = session.task(taskId("abort")); + const before = new AbortController(); + before.abort(new Error("before dispatch")); + await expect(controller.snapshot(before.signal)).rejects.toThrow( + "before dispatch", + ); + expect(port.requests).toHaveLength(0); + + const during = new AbortController(); + let dispatchedSignal: AbortSignal | undefined; + port.dispatchHandler = (_request, options) => { + dispatchedSignal = options?.signal; + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => { + reject( + options.signal?.reason instanceof Error + ? options.signal.reason + : new Error("dispatch aborted"), + ); + }, + { once: true }, + ); + }); + }; + const pending = controller.snapshot(during.signal); + await Promise.resolve(); + expect(dispatchedSignal).toBeDefined(); + expect(dispatchedSignal).not.toBe(during.signal); + during.abort(new Error("during dispatch")); + await expect(pending).rejects.toThrow("during dispatch"); + await session.close(); + }); + + it("aborts in-flight operations and rejects late responses when the session closes", async () => { + for (const settleAfterClose of [false, true]) { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let resolveDispatch: ((value: typeof port.response) => void) | undefined; + port.dispatchHandler = (_request, options) => + new Promise((resolve, reject) => { + resolveDispatch = resolve; + if (!settleAfterClose) + options?.signal?.addEventListener( + "abort", + () => { + reject( + options.signal?.reason instanceof Error + ? options.signal.reason + : new Error("dispatch aborted"), + ); + }, + { once: true }, + ); + }); + const session = withTasks(port, { tools }); + const pending = session.task(taskId("close")).snapshot(); + await Promise.resolve(); + await session.close(); + resolveDispatch?.({ + kind: "result", + result: asJson(v2CompletedTask), + }); + await expect(pending).rejects.toThrow("Task-enabled session is closed"); + } + }); + + it("does not acquire or weaken resumeTask recovery ownership", async () => { + const port = new FakePort( + { generation: "v2", capabilities: {} }, + "manual-endpoint", + ); + port.response = { kind: "result", result: asJson(v2CompletedTask) }; + const session = withTasks(port, { tools }); + const controller = session.task(taskId("manual-v2")); + expect(controller.capabilities).toMatchObject({ + inventory: "known-handles", + inputResponses: true, + }); + + const execution = await session.resumeTask({ + endpointId: "manual-endpoint", + generation: "v2", + taskId: taskId("manual-v2"), + originalOperation: "tools/call", + }); + await expect(legacyResult(execution)).resolves.toEqual({ + resultType: "complete", + content: [], + }); + await execution.close(); + await session.close(); + }); +}); +import { legacyResult } from "../../test-support/client/semantic.js"; diff --git a/packages/ext-tasks/src/client/task-controller.ts b/packages/ext-tasks/src/client/task-controller.ts new file mode 100644 index 0000000..562d1e8 --- /dev/null +++ b/packages/ext-tasks/src/client/task-controller.ts @@ -0,0 +1,164 @@ +/** Non-owning manual access to an existing task. */ + +import { ProtocolDecodeError } from "../core/index.js"; +import type { RuntimeCodec, TaskId } from "../core/index.js"; +import type { CallToolResultV1 } from "../core/v1/index.js"; +import { InputResponsesV2Schema } from "../core/v2/index.js"; +import type { CallToolResultV2, DetailedTaskV2 } from "../core/v2/index.js"; +import { + TaskCancellationUnsupportedError, + TaskCancelledError, + TaskInputUpdateUnsupportedError, +} from "./api.js"; +import type { + TaskController, + TaskControllerOptions, + TaskResultOptions, +} from "./api.js"; +import { + defaultResultCodec, + taskPollInterval, + terminalStatus, + waitForTaskPoll, +} from "./execution.js"; +import { + completedOutcome, + projectTask, + semanticCapabilities, +} from "./internal.js"; +import { throwIfAborted } from "./input-routing.js"; +import { createTaskRpc, linkAbortSignals } from "./port.js"; +import type { + ConnectedMcpSessionPort, + DispatchContext, + TaskRpcV1, + TaskRpcV2, +} from "./port.js"; +import { resolveTerminalTaskResult } from "./task-protocol-v2.js"; + +function selectResultCodec( + generation: "v1" | "v2", + codec: RuntimeCodec | undefined, +): RuntimeCodec { + if (codec !== undefined) return codec; + const fallback = defaultResultCodec(generation); + return { + parse(value) { + const decoded = fallback.parse(value); + if (!decoded.success) return decoded; + return { success: true, value: decoded.value as TResult }; + }, + }; +} + +/** Creates a generation-aware, non-owning controller for explicit task operations. */ +export function createTaskController( + port: ConnectedMcpSessionPort, + taskId: TaskId, + options: TaskControllerOptions, + lifecycleSignal: AbortSignal, + assertUsable: () => void, +): TaskController { + const capabilities = port.taskCapabilities; + if (capabilities.generation === "none") + throw new Error("Task management is not supported by this session"); + const generation = capabilities.generation; + const context: DispatchContext | undefined = + options.headers === undefined ? undefined : { headers: options.headers }; + const rpc: TaskRpcV1 | TaskRpcV2 = + generation === "v1" + ? createTaskRpc(generation, { port, taskId, context }) + : createTaskRpc(generation, { port, taskId, context }); + + const runOperation = async ( + signal: AbortSignal | undefined, + operation: (operationSignal: AbortSignal) => Promise, + ): Promise => { + const linked = linkAbortSignals(lifecycleSignal, signal); + try { + assertUsable(); + throwIfAborted(linked.signal); + const result = await operation(linked.signal); + assertUsable(); + throwIfAborted(linked.signal); + return result; + } finally { + linked.dispose(); + } + }; + + return { + taskId, + capabilities: semanticCapabilities(capabilities), + async snapshot(signal) { + return runOperation(signal, async (operationSignal) => + projectTask( + rpc.generation === "v1" + ? { generation: "v1", task: await rpc.get(operationSignal) } + : { generation: "v2", task: await rpc.get(operationSignal) }, + ), + ); + }, + async result( + resultOptions: TaskResultOptions = {}, + ) { + const codec = selectResultCodec(generation, resultOptions.resultCodec); + return runOperation(resultOptions.signal, async (operationSignal) => { + if (rpc.generation === "v1") + return completedOutcome(rpc.result(codec, operationSignal)); + + let task: DetailedTaskV2 = await rpc.get(operationSignal); + while (!terminalStatus(task.status)) { + await waitForTaskPoll( + taskPollInterval(task.pollIntervalMs), + operationSignal, + ); + task = await rpc.get(operationSignal); + } + const view = projectTask({ generation: "v2", task }); + return completedOutcome( + Promise.resolve().then(() => + resolveTerminalTaskResult({ + task, + resultCodec: codec, + cancelledError: new TaskCancelledError(), + }), + ), + view, + ); + }); + }, + async cancel(signal) { + await runOperation(signal, async (operationSignal) => { + if ( + generation === "v1" && + capabilities.capabilities.cancel === undefined + ) + throw new TaskCancellationUnsupportedError(); + await rpc.cancel(operationSignal); + }); + }, + async update(inputResponses, signal) { + await runOperation(signal, async (operationSignal) => { + if (rpc.generation === "v1") + throw new TaskInputUpdateUnsupportedError(); + await rpc.update(inputResponses, operationSignal); + }); + }, + async updateJson(inputResponses, signal) { + await runOperation(signal, async (operationSignal) => { + if (rpc.generation === "v1") + throw new TaskInputUpdateUnsupportedError(); + const decoded = InputResponsesV2Schema.safeParse(inputResponses); + if (!decoded.success) { + throw new ProtocolDecodeError( + "Task input responses failed schema validation", + {}, + { cause: decoded.error }, + ); + } + await rpc.update(decoded.data, operationSignal); + }); + }, + }; +} diff --git a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts index 47a8608..1ed514b 100644 --- a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -4,8 +4,9 @@ import { DispatchError, JsonRpcResponseError, TaskExecutionClosedError, + TaskFailedError, TaskUpdatesAlreadyAcquiredError, - toolDeclarationV2, + toolDeclaration, withTasks, } from "./index.js"; import { deterministicJson } from "./execution.js"; @@ -58,7 +59,7 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -82,7 +83,7 @@ describe("task lifecycle and races", () => { }, }); await execution.close(); - await expect(execution.result()).rejects.toBeInstanceOf( + await expect(legacyResult(execution)).rejects.toBeInstanceOf( TaskExecutionClosedError, ); await session.close(); @@ -121,12 +122,12 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); const result = execution.result(); - const updates = execution.updates()[Symbol.asyncIterator](); + const updates = legacyUpdates(execution)[Symbol.asyncIterator](); await expect(updates.next()).resolves.toMatchObject({ value: { task: { status: "working" } }, }); @@ -140,7 +141,12 @@ describe("task lifecycle and races", () => { await execution.close(); await new Promise((resolve) => setImmediate(resolve)); expect(unhandled).toEqual([]); - await expect(result).rejects.toBeInstanceOf(TaskExecutionClosedError); + const outcome = await result; + expect(outcome.status).toBe("failed"); + if (outcome.status !== "failed") + throw new Error("Expected failed outcome"); + expect(outcome.error).toBeInstanceOf(TaskFailedError); + expect(outcome.error.cause).toBeInstanceOf(TaskExecutionClosedError); } finally { process.off("unhandledRejection", onUnhandledRejection); } @@ -183,18 +189,18 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); await session.close(); expect(cancelCalls).toBe(1); - await expect(execution.result()).rejects.toBeInstanceOf( + await expect(legacyResult(execution)).rejects.toBeInstanceOf( TaskExecutionClosedError, ); }); - it("retries task observations once after any DispatchError", async () => { + it("retries task observations only for retryable DispatchError", async () => { await fc.assert( fc.asyncProperty(fc.boolean(), async (retryable) => { const port = new FakePort({ generation: "v2", capabilities: {} }); @@ -236,15 +242,20 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toEqual({ - resultType: "complete", - content: [], - }); - expect(getCalls).toBe(2); + if (retryable) + await expect(legacyResult(execution)).resolves.toEqual({ + resultType: "complete", + content: [], + }); + else + await expect(legacyResult(execution)).rejects.toBeInstanceOf( + DispatchError, + ); + expect(getCalls).toBe(retryable ? 2 : 1); await session.close(); }), { numRuns: 10 }, @@ -291,7 +302,7 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -300,7 +311,7 @@ describe("task lifecycle and races", () => { else await expect(execution.cancel()).rejects.toThrow("cancel failed"); expect(cancelCalls).toBe(retryable ? 2 : 1); await execution.close(); - await expect(execution.result()).rejects.toBeInstanceOf( + await expect(legacyResult(execution)).rejects.toBeInstanceOf( TaskExecutionClosedError, ); await session.close(); @@ -334,11 +345,11 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); - const iterator = execution.updates()[Symbol.asyncIterator](); + const iterator = legacyUpdates(execution)[Symbol.asyncIterator](); await expect(iterator.next()).resolves.toMatchObject({ value: { task: { statusMessage: "initial" } }, }); @@ -430,11 +441,11 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); - const iterator = execution.updates()[Symbol.asyncIterator](); + const iterator = legacyUpdates(execution)[Symbol.asyncIterator](); await expect(iterator.next()).resolves.toMatchObject({ value: { task: { status: "working" } }, }); @@ -480,7 +491,7 @@ describe("task lifecycle and races", () => { done: true, value: undefined, }); - await expect(execution.result()).resolves.toEqual({ + await expect(legacyResult(execution)).resolves.toEqual({ resultType: "complete", content: [{ type: "text", text: "first" }], }); @@ -514,11 +525,11 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); - await expect(execution.result()).rejects.toBeInstanceOf( + await expect(legacyResult(execution)).rejects.toBeInstanceOf( JsonRpcResponseError, ); expect(getCalls).toBe(1); @@ -557,7 +568,7 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -565,7 +576,7 @@ describe("task lifecycle and races", () => { const iterator = execution.updates(observer.signal)[Symbol.asyncIterator](); await expect(iterator.next()).resolves.toMatchObject({ value: { - generation: "v2", + type: "task", task: { taskId: "notify", status: "working" }, }, }); @@ -604,7 +615,7 @@ describe("task lifecycle and races", () => { }, }), ); - await expect(execution.result()).resolves.toEqual({ + await expect(legacyResult(execution)).resolves.toEqual({ resultType: "complete", content: [], }); @@ -645,12 +656,12 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); await expect(execution.close()).resolves.toBeUndefined(); - await expect(execution.result()).rejects.toBeInstanceOf( + await expect(legacyResult(execution)).rejects.toBeInstanceOf( TaskExecutionClosedError, ); await expect(session.close()).resolves.toBeUndefined(); @@ -689,12 +700,12 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); port.invalidate(new Error("session replaced")); - await expect(execution.result()).rejects.toThrow("session replaced"); + await expect(legacyResult(execution)).rejects.toThrow("session replaced"); await session.close(); }); @@ -745,7 +756,7 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -765,7 +776,7 @@ describe("task lifecycle and races", () => { }, }), ); - await expect(execution.result()).resolves.toEqual({ + await expect(legacyResult(execution)).resolves.toEqual({ resultType: "complete", content: [], }); @@ -829,11 +840,11 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toEqual({ + await expect(legacyResult(execution)).resolves.toEqual({ resultType: "complete", content: [], }); @@ -881,7 +892,7 @@ describe("task lifecycle and races", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, }); const execution = await session.callTool("x"); @@ -896,4 +907,231 @@ describe("task lifecycle and races", () => { await execution.close(); await session.close(); }); + + it("settles task result and async snapshot observation concurrently", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + let cancelCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "settle", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (method === "tasks/get") { + getCalls += 1; + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "settle", + status: "completed", + createdAt: "a", + lastUpdatedAt: "b", + ttlMs: null, + result: { content: [] }, + }), + }; + } + if (method === "tasks/cancel") { + cancelCalls += 1; + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const declaration = toolDeclaration({ + name: "x", + inputSchema: { type: "object" }, + }); + const session = withTasks(port, { + tools: { currentTool: () => declaration }, + }); + const observed: string[] = []; + const execution = await session.callTool("x"); + const settlementPromise = execution.settle({ + onEvent: async (event) => { + await Promise.resolve(); + if (event.type === "task") observed.push(event.task.status); + }, + }); + expect(execution.settle()).toBe(settlementPromise); + const settlement = await settlementPromise; + expect(settlement.outcome).toMatchObject({ + status: "completed", + result: { resultType: "complete", content: [] }, + }); + expect(settlement.lastTask?.status).toBe("completed"); + expect(observed).toEqual(["working", "completed"]); + expect(getCalls).toBe(1); + expect(cancelCalls).toBe(0); + expect(execution.declaration).toBe(declaration); + const publicEvents: string[] = []; + for await (const event of execution.updates()) { + publicEvents.push( + event.type === "task" ? event.task.status : event.outcome.status, + ); + } + expect(publicEvents).toEqual(["working", "completed", "completed"]); + await session.close(); + }); + + it("preserves an observation failure when it precedes result failure", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "dual-error", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (method === "tasks/get") + return { + kind: "error", + error: { code: -32000, message: "result failed" }, + }; + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const observationError = new Error("observation failed"); + let caught: unknown; + try { + await execution.settle({ + close: false, + onEvent: () => { + throw observationError; + }, + }); + } catch (error) { + caught = error; + } + expect(caught).toBe(observationError); + await session.close(); + }); + + it("stops a nonterminating result driver when snapshot observation fails", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + port.dispatchHandler = async (request, options) => { + const method = expectRecord(request).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "observer-failure-hang", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (method === "tasks/get") + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => { + reject(asError(options.signal?.reason)); + }, + { once: true }, + ), + ); + if (method === "tasks/cancel") { + cancelCalls += 1; + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const observationError = new Error("observer stopped"); + await expect( + execution.settle({ + onEvent: () => { + throw observationError; + }, + }), + ).rejects.toBe(observationError); + expect(cancelCalls).toBe(0); + await session.close(); + }); + it("caller-aborted settle does not implicitly cancel the remote task", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let cancelCalls = 0; + const createdTask = { + resultType: "task", + taskId: "abort-settle", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + } as const; + port.dispatchHandler = async (request, options) => { + const method = expectRecord(request).method; + if (method === "tools/call") + return { kind: "result", result: asJson(createdTask) }; + if (method === "tasks/get") + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => { + reject(asError(options.signal?.reason)); + }, + { once: true }, + ), + ); + if (method === "tasks/cancel") { + cancelCalls += 1; + return { kind: "result", result: { resultType: "complete" } }; + } + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const caller = new AbortController(); + const settlement = execution.settle({ signal: caller.signal }); + await Promise.resolve(); + caller.abort(new Error("stop waiting")); + await expect(settlement).rejects.toThrow("stop waiting"); + expect(cancelCalls).toBe(0); + expect(execution.kind).toBe("task"); + if (execution.kind !== "task") throw new Error("Expected task execution"); + await session.close(); + }); }); +import { + legacyResult, + legacyUpdates, +} from "../../test-support/client/semantic.js"; diff --git a/packages/ext-tasks/src/client/task-protocol-v1.ts b/packages/ext-tasks/src/client/task-protocol-v1.ts index 953716f..aa41218 100644 --- a/packages/ext-tasks/src/client/task-protocol-v1.ts +++ b/packages/ext-tasks/src/client/task-protocol-v1.ts @@ -1,26 +1,23 @@ /** Generation-specific requester-side V1 task execution. */ import type { RuntimeCodec } from "../core/index.js"; -import { - CancelTaskResultV1Schema, - GetTaskResultV1Schema, - TaskResultV1Schema, -} from "../core/v1/index.js"; import type { TaskV1 } from "../core/v1/index.js"; import { TaskCancellationUnsupportedError } from "./api.js"; -import type { TaskHandle } from "./api.js"; +import type { TaskSessionEndpointId, ToolDeclaration } from "./api.js"; +import type { InternalTaskHandle } from "./internal.js"; import { DEFAULT_TASK_POLL_INTERVAL_MS, TaskExecution, terminalStatus, } from "./execution.js"; -import { parseResult, dispatchWithRetry, responseResult } from "./port.js"; +import { createTaskRpc } from "./port.js"; import type { ConnectedMcpSessionPort, DispatchContext } from "./port.js"; /** Creates an execution controller for an existing V1 task. */ export function createTaskExecutionV1(options: { readonly applicationContext: TApplicationContext; - readonly handle: TaskHandle & { readonly generation: "v1" }; + readonly handle: InternalTaskHandle & { readonly generation: "v1" }; + readonly declaration?: ToolDeclaration; readonly initialTask: TaskV1; readonly resultCodec: RuntimeCodec; readonly port: ConnectedMcpSessionPort; @@ -35,10 +32,16 @@ export function createTaskExecutionV1(options: { resultCodec, port, } = options; + const rpc = createTaskRpc("v1", { + port, + taskId: handle.taskId, + context: dispatchContext, + }); return new TaskExecution({ applicationContext, + declaration: options.declaration, handle, - endpointId: port.endpointId, + endpointId: port.endpointId as TaskSessionEndpointId, initialSnapshot: { generation: "v1", task: initialTask }, driver: async (context) => { let current = initialTask; @@ -50,19 +53,10 @@ export function createTaskExecutionV1(options: { DEFAULT_TASK_POLL_INTERVAL_MS, current.pollInterval ?? DEFAULT_TASK_POLL_INTERVAL_MS, ), - (observationSignal) => - dispatchWithRetry( - port, - { method: "tasks/get", params: { taskId: handle.taskId } }, - { signal: observationSignal, context: dispatchContext }, - "observe", - ).then((response) => ({ - generation: "v1" as const, - task: parseResult( - GetTaskResultV1Schema, - responseResult(response), - ), - })), + async (observationSignal) => ({ + generation: "v1" as const, + task: await rpc.get(observationSignal), + }), ); if (observed === undefined) continue; if (observed.snapshot.generation !== "v1") @@ -80,16 +74,7 @@ export function createTaskExecutionV1(options: { if (current.status === "cancelled") throw context.errors.cancelled; if (current.status === "failed") throw new Error(current.statusMessage ?? "Task failed"); - const taskResult = responseResult( - await dispatchWithRetry( - port, - { method: "tasks/result", params: { taskId: handle.taskId } }, - { signal: context.signal, context: dispatchContext }, - "observe", - ), - ); - parseResult(TaskResultV1Schema, taskResult); - return parseResult(resultCodec, taskResult); + return rpc.result(resultCodec, context.signal); }, cancelTask: async (signal) => { const capabilities = port.taskCapabilities; @@ -98,20 +83,7 @@ export function createTaskExecutionV1(options: { capabilities.capabilities.cancel === undefined ) throw new TaskCancellationUnsupportedError(); - parseResult( - CancelTaskResultV1Schema, - responseResult( - await dispatchWithRetry( - port, - { - method: "tasks/cancel", - params: { taskId: handle.taskId }, - }, - { signal, context: dispatchContext }, - "mutate", - ), - ), - ); + await rpc.cancel(signal); }, lifecycleSignal: options.lifecycleSignal, }); diff --git a/packages/ext-tasks/src/client/task-protocol-v2.ts b/packages/ext-tasks/src/client/task-protocol-v2.ts index 5cf260a..9f2362a 100644 --- a/packages/ext-tasks/src/client/task-protocol-v2.ts +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -3,42 +3,40 @@ import type { JsonValue, RuntimeCodec } from "../core/index.js"; import type { z } from "zod/v4"; import { - CancelTaskResultV2Schema, CreateMessageResultV2Schema, ElicitResultV2Schema, - GetTaskResultV2Schema, ListRootsResultV2Schema, - UpdateTaskResultV2Schema, - withTaskCapabilityV2, type DetailedTaskV2, type InputRequestV2, type InputResponseV2, type TaskV2, } from "../core/v2/index.js"; -import { - JsonRpcResponseError, - type ApplicationInputHandler, - type ApplicationInputRequest, - type TaskHandle, +import { JsonRpcResponseError } from "./api.js"; +import type { + ApplicationInputHandler, + ApplicationInputRequest, + TaskSessionEndpointId, + ToolDeclaration, } from "./api.js"; +import type { InternalTaskHandle } from "./internal.js"; import { - DEFAULT_TASK_POLL_INTERVAL_MS, TaskExecution, deterministicJson, + taskPollInterval, terminalStatus, - type TaskDriverContext, } from "./execution.js"; -import { - parseResult, - dispatchWithRetry, - responseResult, - type ConnectedMcpSessionPort, - type DispatchContext, +import type { TaskDriverContext } from "./execution.js"; +import { createTaskRpc, parseResult } from "./port.js"; +import type { + ConnectedMcpSessionPort, + DispatchContext, + TaskRpcV2, } from "./port.js"; interface TaskExecutionV2Options { readonly applicationContext: TApplicationContext; - readonly handle: TaskHandle & { readonly generation: "v2" }; + readonly handle: InternalTaskHandle & { readonly generation: "v2" }; + readonly declaration?: ToolDeclaration; readonly initialTask: TaskV2; readonly initialDetailedTask?: DetailedTaskV2; readonly resultCodec: RuntimeCodec; @@ -50,9 +48,8 @@ interface TaskExecutionV2Options { } interface V2TaskRpcContext { - readonly port: ConnectedMcpSessionPort; - readonly dispatchContext?: DispatchContext; - readonly handle: TaskHandle & { readonly generation: "v2" }; + readonly rpc: TaskRpcV2; + readonly handle: InternalTaskHandle & { readonly generation: "v2" }; } interface V2InputContext extends V2TaskRpcContext { @@ -108,18 +105,22 @@ export function createTaskExecutionV2( options: TaskExecutionV2Options, ): TaskExecution { const rpcContext: V2TaskRpcContext = { - port: options.port, - dispatchContext: options.dispatchContext, + rpc: createTaskRpc("v2", { + port: options.port, + taskId: options.handle.taskId, + context: options.dispatchContext, + }), handle: options.handle, }; return new TaskExecution({ applicationContext: options.applicationContext, + declaration: options.declaration, handle: options.handle, - endpointId: options.port.endpointId, + endpointId: options.port.endpointId as TaskSessionEndpointId, initialSnapshot: { generation: "v2", task: options.initialTask }, driver: (driverContext) => driveTaskExecutionV2({ options, rpcContext, driverContext }), - cancelTask: (signal) => cancelTask({ rpcContext, signal }), + cancelTask: (signal) => rpcContext.rpc.cancel(signal), lifecycleSignal: options.lifecycleSignal, }); } @@ -151,18 +152,16 @@ async function driveTaskExecutionV2(args: { }); while (!terminalStatus(knownStatus)) { - const delayMs = Math.max( - DEFAULT_TASK_POLL_INTERVAL_MS, - latestDetailedTask?.pollIntervalMs ?? - options.initialTask.pollIntervalMs ?? - DEFAULT_TASK_POLL_INTERVAL_MS, + const delayMs = taskPollInterval( + latestDetailedTask?.pollIntervalMs, + options.initialTask.pollIntervalMs, ); const observed = await driverContext.nextObservation( lastNotificationSequence, delayMs, async (signal) => ({ generation: "v2", - task: await fetchDetailedTask({ rpcContext, signal }), + task: await rpcContext.rpc.get(signal), }), ); if (observed === undefined) continue; @@ -185,10 +184,7 @@ async function driveTaskExecutionV2(args: { if (driverContext.isClosed()) throw driverContext.errors.closed; if (latestDetailedTask === undefined) - latestDetailedTask = await fetchDetailedTask({ - rpcContext, - signal: driverContext.signal, - }); + latestDetailedTask = await rpcContext.rpc.get(driverContext.signal); return resolveTerminalTaskResult({ task: latestDetailedTask, resultCodec: options.resultCodec, @@ -196,28 +192,8 @@ async function driveTaskExecutionV2(args: { }); } -async function fetchDetailedTask(args: { - readonly rpcContext: V2TaskRpcContext; - readonly signal: AbortSignal; -}): Promise { - const { rpcContext, signal } = args; - return parseResult( - GetTaskResultV2Schema, - responseResult( - await dispatchWithRetry( - rpcContext.port, - { - method: "tasks/get", - params: withTaskCapabilityV2({ taskId: rpcContext.handle.taskId }), - }, - { signal, context: rpcContext.dispatchContext }, - "observe", - ), - ), - ); -} - -function resolveTerminalTaskResult(args: { +/** Resolves a terminal V2 task with the same result and error semantics everywhere. */ +export function resolveTerminalTaskResult(args: { readonly task: DetailedTaskV2; readonly resultCodec: RuntimeCodec; readonly cancelledError: Error; @@ -235,33 +211,15 @@ function resolveTerminalTaskResult(args: { } } -async function cancelTask(args: { - readonly rpcContext: V2TaskRpcContext; - readonly signal?: AbortSignal; -}): Promise { - const { rpcContext, signal } = args; - parseResult( - CancelTaskResultV2Schema, - responseResult( - await dispatchWithRetry( - rpcContext.port, - { - method: "tasks/cancel", - params: withTaskCapabilityV2({ taskId: rpcContext.handle.taskId }), - }, - { signal, context: rpcContext.dispatchContext }, - "mutate", - ), - ), - ); -} - type InputResolution = { readonly inputKey: string; readonly response: InputResponseV2; }; -function projectInputRequest(request: InputRequestV2): ApplicationInputRequest { +/** Projects a V2 wire input request to the generation-neutral application shape. */ +export function projectInputRequest( + request: InputRequestV2, +): ApplicationInputRequest { if (request.method === "sampling/createMessage") return { kind: "sampling", params: request.params }; if (request.method === "roots/list") @@ -272,7 +230,8 @@ function projectInputRequest(request: InputRequestV2): ApplicationInputRequest { return { kind: "elicitation", params: request.params }; } -function responseSchemaForInputRequest( +/** Selects the response validator for a V2 input request. */ +export function responseSchemaForInputRequest( request: InputRequestV2, ): z.ZodType { if (request.method === "sampling/createMessage") @@ -299,9 +258,10 @@ async function invokeInputHandler(args: { return { kind: "result", value: await inputContext.onInputRequest(projectInputRequest(request), { - lifetime: "task-v2", + scope: "task", + delivery: "task-update", taskId: inputContext.handle.taskId, - inputKey, + inputId: inputKey, applicationContext: inputContext.applicationContext, signal: inputContext.inputSignal, }), @@ -385,26 +345,7 @@ async function resolveAndSubmitInputRequests(args: { ) return; try { - parseResult( - UpdateTaskResultV2Schema, - responseResult( - await dispatchWithRetry( - inputContext.port, - { - method: "tasks/update", - params: withTaskCapabilityV2({ - taskId: inputContext.handle.taskId, - inputResponses, - }), - }, - { - signal: inputContext.signal, - context: inputContext.dispatchContext, - }, - "mutate", - ), - ), - ); + await inputContext.rpc.update(inputResponses, inputContext.signal); for (const inputKey of Object.keys(inputResponses)) inputContext.acquiredRequestLedger.commit(inputKey); } catch (error) { diff --git a/packages/ext-tasks/src/client/task-resumption.test.ts b/packages/ext-tasks/src/client/task-resumption.test.ts index c00dad7..962c2f1 100644 --- a/packages/ext-tasks/src/client/task-resumption.test.ts +++ b/packages/ext-tasks/src/client/task-resumption.test.ts @@ -4,8 +4,7 @@ import type { TaskId } from "../core/index.js"; import { DispatchError, TaskRecoveryOwnershipError, - toolDeclarationV1, - toolDeclarationV2, + toolDeclaration, withTasks, } from "./index.js"; import type { @@ -121,7 +120,7 @@ describe("task reference resumption", () => { tools: { currentTool: (name) => name === "ordinary" - ? toolDeclarationV1({ + ? toolDeclaration({ name, inputSchema: { type: "object" }, }) @@ -148,7 +147,8 @@ describe("task reference resumption", () => { expect(errors).toEqual([]); expect(contexts).toHaveLength(1); expect(contexts[0]).toMatchObject({ - lifetime: "basic", + scope: "request", + delivery: "peer-request", applicationContext: undefined, }); finishOrdinary({ kind: "result", result: { content: [] } }); @@ -220,12 +220,12 @@ describe("task reference resumption", () => { tools: { currentTool: () => generation === "v1" - ? toolDeclarationV1({ + ? toolDeclaration({ name: "roundtrip", inputSchema: { type: "object" }, execution: { taskSupport: "required" }, }) - : toolDeclarationV2({ + : toolDeclaration({ name: "roundtrip", inputSchema: { type: "object" }, }), @@ -308,7 +308,7 @@ describe("task reference resumption", () => { if (resumed.kind !== "task") throw new Error("expected resumed task"); expect(resumed.applicationContext).toBe(applicationContext); expect(resumed.serializeReference()).toEqual(reference); - await expect(resumed.result()).resolves.toEqual( + await expect(legacyResult(resumed)).resolves.toEqual( generation === "v1" ? { content: [{ type: "text", text: taskSuffix }] } : { resultType: "complete", content: [] }, @@ -337,7 +337,7 @@ describe("task reference resumption", () => { ); }); - it("retries the initial resumed observation once for any DispatchError", async () => { + it("retries the initial resumed observation only for retryable DispatchError", async () => { await fc.assert( fc.asyncProperty(fc.boolean(), async (retryable) => { const port = new FakePort({ generation: "v2", capabilities: {} }); @@ -363,16 +363,19 @@ describe("task reference resumption", () => { const session = withTasks(port, { tools: { currentTool: () => undefined }, }); - const execution = await session.resumeTask({ + const resume = session.resumeTask({ endpointId: port.endpointId, generation: "v2", taskId: "retry-resume" as TaskId, originalOperation: "tools/call", }); - await expect(execution.result()).resolves.toMatchObject({ - content: [], - }); - expect(calls).toBe(2); + if (retryable) { + const execution = await resume; + await expect(legacyResult(execution)).resolves.toMatchObject({ + content: [], + }); + } else await expect(resume).rejects.toBeInstanceOf(DispatchError); + expect(calls).toBe(retryable ? 2 : 1); await session.close(); }), { numRuns: 10 }, @@ -442,7 +445,9 @@ describe("task reference resumption", () => { }), }); const execution = await first; - await expect(execution.result()).resolves.toMatchObject({ content: [] }); + await expect(legacyResult(execution)).resolves.toMatchObject({ + content: [], + }); expect(handlerCalls).toBe(1); expect( port.requests.filter( @@ -515,7 +520,9 @@ describe("task reference resumption", () => { "resume failed", ); const execution = await session.resumeTask(reference); - await expect(execution.result()).resolves.toMatchObject({ content: [] }); + await expect(legacyResult(execution)).resolves.toMatchObject({ + content: [], + }); expect(calls).toBe(2); await session.close(); }); @@ -547,11 +554,12 @@ describe("task reference resumption", () => { originalOperation: "tools/call", } as const; const first = await session.resumeTask(reference); - await first.result(); + await legacyResult(first); await Promise.resolve(); const second = await session.resumeTask(reference); - await expect(second.result()).resolves.toMatchObject({ content: [] }); + await expect(legacyResult(second)).resolves.toMatchObject({ content: [] }); expect(port.requests).toHaveLength(2); await session.close(); }); }); +import { legacyResult } from "../../test-support/client/semantic.js"; diff --git a/packages/ext-tasks/src/client/tool-declarations.ts b/packages/ext-tasks/src/client/tool-declarations.ts index 718d9b6..dbb5cfd 100644 --- a/packages/ext-tasks/src/client/tool-declarations.ts +++ b/packages/ext-tasks/src/client/tool-declarations.ts @@ -6,6 +6,7 @@ import { type ToolDeclaration, type ToolDeclarationProvider, } from "./api.js"; +import { projectTool } from "./internal.js"; import type { ConnectedMcpSessionPort } from "./port.js"; import { throwIfAborted } from "./input-routing.js"; @@ -127,16 +128,15 @@ export class ManagedToolDeclarations implements ToolDeclarationProvider { if (generation === "v1") { const parsed = ToolV1Schema.safeParse(value); if (!parsed.success) throw parsed.error; - declaration = { generation: "v1", tool: parsed.data }; + declaration = projectTool(parsed.data); } else { const parsed = ToolV2Schema.safeParse(value); if (!parsed.success) throw parsed.error; - declaration = { generation: "v2", tool: parsed.data }; + declaration = projectTool(parsed.data); } - const toolName = declaration.tool.name; - if (decoded.has(toolName)) - throw new Error(`Duplicate tool declaration: ${toolName}`); - decoded.set(toolName, declaration); + if (decoded.has(declaration.name)) + throw new Error(`Duplicate tool declaration: ${declaration.name}`); + decoded.set(declaration.name, declaration); } cursor = typeof result.nextCursor === "string" ? result.nextCursor : undefined; diff --git a/packages/ext-tasks/src/client/v1-input-task.test.ts b/packages/ext-tasks/src/client/v1-input-task.test.ts index 7d5c918..6f967ad 100644 --- a/packages/ext-tasks/src/client/v1-input-task.test.ts +++ b/packages/ext-tasks/src/client/v1-input-task.test.ts @@ -6,7 +6,7 @@ import { InputCorrelationError, TaskCancellationUnsupportedError, TaskExecutionClosedError, - toolDeclarationV1, + toolDeclaration, withTasks, } from "./index.js"; import type { JsonRpcResponse } from "./index.js"; @@ -86,11 +86,14 @@ describe("V1 input and task behavior", () => { }); expect(observed[0]).toMatchObject({ request: { params: { prompt: "p" } }, - context: { lifetime: "basic", applicationContext: { marker: "ctx" } }, + context: { + scope: "request", + delivery: "peer-request", + applicationContext: { marker: "ctx" }, + }, }); expect( - (observed[0] as { context: { executionId: string } }).context - .executionId, + (observed[0] as { context: { inputId: string } }).context.inputId, ).toMatch(/^execution-/); expect(observed[1]).toEqual({ kind: "result", result: input.result }); await session.close(); @@ -232,7 +235,7 @@ describe("V1 input and task behavior", () => { const session = withTasks(port, { tools: { currentTool: (name) => - toolDeclarationV1({ + toolDeclaration({ name, inputSchema: { type: "object" }, execution: { taskSupport: "required" }, @@ -286,7 +289,8 @@ describe("V1 input and task behavior", () => { if (succeeds) { const entry = expectRecord(asJson(observed[0])); expect(entry.context).toMatchObject({ - lifetime: "task-v1", + scope: "task", + delivery: "peer-request", taskId: "task-0", applicationContext: "context-0", }); @@ -425,7 +429,7 @@ describe("V1 input and task behavior", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV1({ + toolDeclaration({ name: "x", inputSchema: { type: "object" }, execution: { taskSupport: "required" }, @@ -449,7 +453,7 @@ describe("V1 input and task behavior", () => { }); expect(handlerSignal?.aborted).toBe(false); await execution.close(); - await expect(execution.result()).rejects.toBeInstanceOf( + await expect(legacyResult(execution)).rejects.toBeInstanceOf( TaskExecutionClosedError, ); expect(handlerSignal?.aborted).toBe(true); @@ -514,7 +518,7 @@ describe("V1 input and task behavior", () => { tools: { currentTool: () => { lookups += 1; - return toolDeclarationV1(tool); + return toolDeclaration(tool); }, }, }); @@ -523,7 +527,7 @@ describe("V1 input and task behavior", () => { (taskSupport === "required" || (taskSupport === "optional" && preferTask)); const execution = await session.callTool("x", undefined, { - preferTask, + task: { preference: preferTask ? "prefer" : "allow" }, }); if (taskSelected) { expect(execution.kind).toBe("task"); @@ -604,17 +608,17 @@ describe("V1 input and task behavior", () => { throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { - tools: { currentTool: () => toolDeclarationV1(tool) }, + tools: { currentTool: () => toolDeclaration(tool) }, }); const execution = await session.callTool("long"); expect(execution.kind).toBe("task"); expect(execution.handle).toEqual({ - generation: "v1", taskId: "v1-task", - originalOperation: "tools/call", + operation: "tools/call", }); const snapshots: unknown[] = []; - for await (const snapshot of execution.updates()) snapshots.push(snapshot); + for await (const snapshot of legacyUpdates(execution)) + snapshots.push(snapshot); expect(snapshots).toEqual([ { generation: "v1", @@ -639,8 +643,9 @@ describe("V1 input and task behavior", () => { ]); const first = execution.result(); expect(execution.result()).toBe(first); - await expect(first).resolves.toEqual({ - content: [{ type: "text", text: "done" }], + await expect(first).resolves.toMatchObject({ + status: "completed", + result: { content: [{ type: "text", text: "done" }] }, }); await session.close(); }); @@ -692,10 +697,10 @@ describe("V1 input and task behavior", () => { throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { - tools: { currentTool: () => toolDeclarationV1(tool) }, + tools: { currentTool: () => toolDeclaration(tool) }, }); const execution = await session.callTool("notified"); - const iterator = execution.updates()[Symbol.asyncIterator](); + const iterator = legacyUpdates(execution)[Symbol.asyncIterator](); await expect(iterator.next()).resolves.toMatchObject({ value: { task: { status: "working" } }, }); @@ -720,7 +725,7 @@ describe("V1 input and task behavior", () => { done: true, value: undefined, }); - await expect(execution.result()).resolves.toEqual({ + await expect(legacyResult(execution)).resolves.toEqual({ content: [{ type: "text", text: "notified" }], }); expect(getCalls).toBeLessThanOrEqual(1); @@ -765,7 +770,7 @@ describe("V1 input and task behavior", () => { throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { - tools: { currentTool: () => toolDeclarationV1(tool) }, + tools: { currentTool: () => toolDeclaration(tool) }, }); const execution = await session.callTool("x"); await expect(execution.cancel()).rejects.toBeInstanceOf( @@ -777,9 +782,13 @@ describe("V1 input and task behavior", () => { ), ).toBe(false); await execution.close(); - await expect(execution.result()).rejects.toBeInstanceOf( + await expect(legacyResult(execution)).rejects.toBeInstanceOf( TaskExecutionClosedError, ); await session.close(); }); }); +import { + legacyResult, + legacyUpdates, +} from "../../test-support/client/semantic.js"; diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts index 05489eb..5ca2332 100644 --- a/packages/ext-tasks/src/client/v2-input-task.test.ts +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -1,6 +1,6 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { toolDeclarationV2, withTasks } from "./index.js"; +import { toolDeclaration, withTasks } from "./index.js"; import { FakePort, asJson, @@ -50,14 +50,13 @@ describe("V2 input and task behavior", () => { throw new Error(`unexpected method ${formatJson(record.method)}`); }; const session = withTasks(port, { - tools: { currentTool: () => toolDeclarationV2(tool) }, + tools: { currentTool: () => toolDeclaration(tool) }, }); const execution = await session.callTool("long"); expect(execution.kind).toBe("task"); expect(execution.handle).toEqual({ - generation: "v2", taskId: "v2-task", - originalOperation: "tools/call", + operation: "tools/call", }); expect(port.requests[0]).toMatchObject({ method: "tools/call", @@ -69,7 +68,7 @@ describe("V2 input and task behavior", () => { }, }, }); - await expect(execution.result()).resolves.toEqual({ + await expect(legacyResult(execution)).resolves.toEqual({ resultType: "complete", content: [{ type: "text", text: "done" }], }); @@ -152,7 +151,7 @@ describe("V2 input and task behavior", () => { const session = withTasks<{ marker: string }>(port, { tools: { currentTool: () => - toolDeclarationV2({ + toolDeclaration({ name: "x", inputSchema: { type: "object" }, }), @@ -176,7 +175,7 @@ describe("V2 input and task behavior", () => { applicationContext: { marker: "context" }, }, ); - await expect(execution.result()).resolves.toEqual({ + await expect(legacyResult(execution)).resolves.toEqual({ resultType: "complete", content: [], }); @@ -184,9 +183,17 @@ describe("V2 input and task behavior", () => { expect( observed.map((value) => { const entry = expectRecord(asJson(value)); - return expectRecord(entry.context).inputKey; + return expectRecord(entry.context).inputId; }), ).toEqual(inputs.map(({ key }) => key)); + for (const value of observed) { + const context = expectRecord(expectRecord(asJson(value)).context); + expect(context).toMatchObject({ + scope: "task", + delivery: "task-update", + taskId: "input-task", + }); + } const updates = port.requests.filter( (request) => expectRecord(request).method === "tasks/update", ); @@ -272,7 +279,7 @@ describe("V2 input and task behavior", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, onInputRequest: async () => { await Promise.resolve(); @@ -282,7 +289,7 @@ describe("V2 input and task behavior", () => { onError: (error) => errors.push(error), }); const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toMatchObject({ + await expect(legacyResult(execution)).resolves.toMatchObject({ resultType: "complete", }); expect(handlerCalls).toBe(3); @@ -355,7 +362,7 @@ describe("V2 input and task behavior", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, onInputRequest: async () => { await Promise.resolve(); @@ -364,7 +371,7 @@ describe("V2 input and task behavior", () => { onError: (error) => errors.push(error), }); const execution = await session.callTool("x"); - await expect(execution.result()).resolves.toMatchObject({ + await expect(legacyResult(execution)).resolves.toMatchObject({ resultType: "complete", }); const updates = port.requests.filter( @@ -422,7 +429,7 @@ describe("V2 input and task behavior", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ name: "x", inputSchema: { type: "object" } }), + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), }, onInputRequest: (_request, context) => { handlerSignal = context.signal; @@ -455,7 +462,7 @@ describe("V2 input and task behavior", () => { }, }), ); - await expect(execution.result()).resolves.toEqual({ + await expect(legacyResult(execution)).resolves.toEqual({ resultType: "complete", content: [], }); @@ -523,7 +530,7 @@ describe("V2 input and task behavior", () => { const session = withTasks(port, { tools: { currentTool: () => - toolDeclarationV2({ + toolDeclaration({ name: "x", inputSchema: { type: "object" }, }), @@ -531,17 +538,17 @@ describe("V2 input and task behavior", () => { }); const execution = await session.callTool("x"); if (status === "completed") - await expect(execution.result()).resolves.toEqual({ + await expect(legacyResult(execution)).resolves.toEqual({ resultType: "complete", content: [], }); else if (status === "failed") - await expect(execution.result()).rejects.toMatchObject({ + await expect(legacyResult(execution)).rejects.toMatchObject({ name: "JsonRpcResponseError", code: -32000, message: "task failed", }); - else await expect(execution.result()).rejects.toThrow(/cancel/i); + else await expect(legacyResult(execution)).rejects.toThrow(/cancel/i); expect(getCalls).toBe(1); await session.close(); }, @@ -550,3 +557,4 @@ describe("V2 input and task behavior", () => { ); }); }); +import { legacyResult } from "../../test-support/client/semantic.js"; diff --git a/packages/ext-tasks/src/core/index.test.ts b/packages/ext-tasks/src/core/index.test.ts index bc756d5..9910b91 100644 --- a/packages/ext-tasks/src/core/index.test.ts +++ b/packages/ext-tasks/src/core/index.test.ts @@ -5,7 +5,9 @@ import { JsonValueCodec, ProtocolDecodeError, isJsonValue, + runtimeCodecFromStandardSchema, taskId, + toJsonValue, type JsonValue, } from "./index.js"; @@ -82,6 +84,79 @@ describe("core runtime contracts", () => { value, }); }); + it("normalizes values with JSON stringify/parse semantics", () => { + const sparse: unknown[] = []; + sparse.length = 2; + sparse[1] = undefined; + class Value { + constructor(readonly kept: string) {} + } + const normalized = toJsonValue({ + omitted: undefined, + custom: { toJSON: () => ({ answer: 42 }) }, + sparse, + instance: new Value("yes"), + }); + expect(normalized).toEqual({ + custom: { answer: 42 }, + sparse: [null, null], + instance: { kept: "yes" }, + }); + expect(Object.getPrototypeOf(normalized)).toBe(Object.prototype); + expect(() => toJsonValue(undefined)).toThrow(/top-level JSON value/); + expect(() => toJsonValue({ value: 1n })).toThrow(/serialized as JSON/); + }); + + it("adapts canonical synchronous Standard Schema results with structured issues", () => { + const success = runtimeCodecFromStandardSchema({ + "~standard": { + version: 1, + vendor: "test", + validate: () => ({ value: 7 }), + }, + }); + expect(success.parse(null)).toEqual({ success: true, value: 7 }); + const sourceIssues = [ + { message: "not a number", path: ["answer", 0] }, + { message: "out of range", path: [{ key: "limit" }] }, + ] as const; + const issues = runtimeCodecFromStandardSchema({ + "~standard": { + version: 1, + vendor: "test", + validate: () => ({ issues: sourceIssues }), + }, + }); + const issueResult = issues.parse(null); + expect(issueResult.success).toBe(false); + if (!issueResult.success) { + expect(issueResult.error.message).toBe("not a number; out of range"); + expect(issueResult.error.details.issues).toEqual(sourceIssues); + expect(issueResult.error.details.issues).not.toBe(sourceIssues); + expect(Object.isFrozen(issueResult.error.details.issues)).toBe(true); + expect(Object.isFrozen(issueResult.error.details.issues?.[0]?.path)).toBe( + true, + ); + } + const thrownError = new Error("boom"); + const thrown = runtimeCodecFromStandardSchema({ + "~standard": { + version: 1, + vendor: "test", + validate: () => { + throw thrownError; + }, + }, + }); + const thrownResult = thrown.parse(null); + expect(thrownResult.success).toBe(false); + if (!thrownResult.success) { + expect(thrownResult.error).toBeInstanceOf(ProtocolDecodeError); + expect(thrownResult.error.details).toEqual({}); + expect(thrownResult.error.cause).toBe(thrownError); + } + }); + it("brands task identifiers without changing their wire value", () => { fc.assert( fc.property(fc.string(), (value) => { diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts index 7e2dce5..1713904 100644 --- a/packages/ext-tasks/src/core/index.ts +++ b/packages/ext-tasks/src/core/index.ts @@ -12,10 +12,45 @@ export type JsonValue = | readonly JsonValue[] | { readonly [key: string]: JsonValue }; +/** Normalizes an arbitrary JavaScript value through JSON stringify/parse semantics. */ +export function toJsonValue(value: unknown): JsonValue { + let serialized: unknown; + try { + serialized = JSON.stringify(value); + } catch (error) { + throw new TypeError("Value cannot be serialized as JSON", { cause: error }); + } + if (typeof serialized !== "string") + throw new TypeError("Value cannot be serialized as a top-level JSON value"); + const normalized: unknown = JSON.parse(serialized); + if (!isJsonValue(normalized)) + throw new TypeError("JSON serialization produced an invalid JSON value"); + return normalized; +} + +export type StandardSchemaPathSegment = + PropertyKey | { readonly key: PropertyKey }; + +export interface StandardSchemaIssue { + readonly message: string; + readonly path?: readonly StandardSchemaPathSegment[]; +} + +export interface ProtocolDecodeErrorDetails { + readonly issues?: readonly StandardSchemaIssue[]; +} + export class ProtocolDecodeError extends Error { - constructor(message: string, options?: ErrorOptions) { + readonly details: ProtocolDecodeErrorDetails; + + constructor( + message: string, + details: ProtocolDecodeErrorDetails = {}, + options?: ErrorOptions, + ) { super(message, options); this.name = "ProtocolDecodeError"; + this.details = details; } } @@ -27,6 +62,67 @@ export interface RuntimeCodec { parse(value: JsonValue): RuntimeDecodeResult; } +/** Canonical synchronous Standard Schema V1 surface accepted at the package boundary. */ +export interface SynchronousStandardSchema { + readonly "~standard": { + readonly version: 1; + readonly vendor: string; + readonly validate: ( + value: unknown, + ) => + | { readonly value: T; readonly issues?: undefined } + | { readonly issues: readonly StandardSchemaIssue[] }; + }; +} + +/** Adapts a synchronous Standard Schema validator to the package runtime codec. */ +export function runtimeCodecFromStandardSchema( + schema: SynchronousStandardSchema, +): RuntimeCodec { + return { + parse(value) { + try { + const result = schema["~standard"].validate(value); + if ("issues" in result && result.issues !== undefined) { + const issues = Object.freeze( + result.issues.map((issue) => + Object.freeze({ + message: issue.message, + ...(issue.path === undefined + ? {} + : { path: Object.freeze([...issue.path]) }), + }), + ), + ); + const message = issues.map((issue) => issue.message).join("; "); + return { + success: false, + error: new ProtocolDecodeError( + message || "Standard Schema validation failed", + { issues }, + ), + }; + } + if (!("value" in result)) + return { + success: false, + error: new ProtocolDecodeError("Standard Schema returned no value"), + }; + return { success: true, value: result.value }; + } catch (error) { + return { + success: false, + error: new ProtocolDecodeError( + "Standard Schema validation failed", + {}, + { cause: error }, + ), + }; + } + }, + }; +} + export type TaskSnapshot = | { readonly generation: "v1"; readonly task: TaskV1 } | { readonly generation: "v2"; readonly task: TaskV2 | DetailedTaskV2 }; diff --git a/packages/ext-tasks/src/core/v2/schemas.ts b/packages/ext-tasks/src/core/v2/schemas.ts index d9baa33..c7024e7 100644 --- a/packages/ext-tasks/src/core/v2/schemas.ts +++ b/packages/ext-tasks/src/core/v2/schemas.ts @@ -151,7 +151,7 @@ const ToolV2Schema = openObject({ }); const CompleteResultTypeSchema = z.literal("complete").default("complete"); -const CallToolResultV2Schema = openObject({ +const CompleteCallToolResultV2Schema = openObject({ resultType: CompleteResultTypeSchema, content: z.array(ContentBlockV2Schema), structuredContent: JsonValueSchema.optional(), @@ -205,6 +205,20 @@ const InputRequestV2Schema = z.discriminatedUnion("method", [ ElicitRequestV2Schema, ]); const InputRequestsV2Schema = z.record(z.string(), InputRequestV2Schema); +const InputRequiredCallToolResultV2Schema = openObject({ + resultType: z.literal("input_required"), + inputRequests: InputRequestsV2Schema.optional(), + requestState: z.string().optional(), + _meta: MetaSchema.optional(), +}).refine( + (result) => + result.inputRequests !== undefined || result.requestState !== undefined, + { message: "At least one of inputRequests or requestState must be present" }, +); +const CallToolResultV2Schema = z.union([ + CompleteCallToolResultV2Schema, + InputRequiredCallToolResultV2Schema, +]); const CreateMessageResultV2Schema = openObject({ content: JsonValueSchema, @@ -327,6 +341,7 @@ export { ContentBlockV2Schema, ToolV2Schema, CallToolResultV2Schema, + InputRequiredCallToolResultV2Schema, RequestIdV2Schema, TaskStatusV2Schema, TaskEligibleMethodV2Schema, @@ -367,6 +382,9 @@ export { export type ContentBlockV2 = z.infer; export type ToolV2 = z.infer; export type CallToolResultV2 = z.infer; +export type InputRequiredCallToolResultV2 = z.infer< + typeof InputRequiredCallToolResultV2Schema +>; export type RequestIdV2 = z.infer; export type TaskStatusV2 = z.infer; export type TaskEligibleMethodV2 = z.infer; diff --git a/packages/ext-tasks/src/receiver/index.ts b/packages/ext-tasks/src/receiver/index.ts new file mode 100644 index 0000000..608b6f3 --- /dev/null +++ b/packages/ext-tasks/src/receiver/index.ts @@ -0,0 +1,484 @@ +/** SDK Client binding for MCP Tasks V1 receiver requests. */ +import type { Client } from "@modelcontextprotocol/client"; +import { toJsonValue, type JsonValue } from "../core/index.js"; +import type { + CreateTaskResultV1, + TaskStatusV1, + TaskV1, +} from "../core/v1/index.js"; + +const DEFAULT_PAGE_SIZE = 100; +const DEFAULT_MAX_TASKS = 1_000; + +function createDefaultTaskId(): string { + if (typeof globalThis.crypto.randomUUID !== "function") { + throw new Error( + "Task receiver requires crypto.randomUUID or options.createTaskId", + ); + } + return globalThis.crypto.randomUUID(); +} + +export type TaskReceiverMethod = + "sampling/createMessage" | "elicitation/create"; +export type TaskReceiverProtocolMethod = + | TaskReceiverMethod + | "tasks/list" + | "tasks/get" + | "tasks/result" + | "tasks/cancel" + | "notifications/tasks/status"; + +export interface TaskReceiverRequest { + readonly method: TaskReceiverMethod; + readonly params: Readonly>; +} + +export interface TaskReceiverCallbackContext { + readonly taskId: string; + readonly signal: AbortSignal; +} + +export type TaskReceiverCallback< + TResult extends Record = Record, +> = ( + request: TaskReceiverRequest, + context: TaskReceiverCallbackContext, +) => Promise; + +export interface TaskReceiverErrorContext { + readonly method: TaskReceiverProtocolMethod; + readonly taskId?: string; + readonly lateAfter?: "cancel" | "expiry" | "close"; +} + +export interface TaskReceiverOptions { + readonly methods: Partial>; + /** + * Total task lifetime, measured from creation. A function is sampled once for + * each created task; `null` disables time expiry for that task. + */ + readonly ttlMs?: number | null | (() => number | null); + readonly pollIntervalMs?: number | null; + /** Maximum tasks returned in one `tasks/list` page. Defaults to 100. */ + readonly pageSize?: number; + /** Maximum retained tasks, including pending tasks. Defaults to 1,000. */ + readonly maxTasks?: number; + readonly sampling?: TaskReceiverCallback; + readonly elicitation?: TaskReceiverCallback; + readonly onError?: ( + error: unknown, + context: TaskReceiverErrorContext, + ) => void; + readonly createTaskId?: () => string; +} + +export interface TaskReceiverCapabilities { + readonly list: Record; + readonly cancel: Record; + readonly requests: { + readonly sampling?: { readonly createMessage: Record }; + readonly elicitation?: { readonly create: Record }; + }; +} + +export interface TaskReceiverBinding { + readonly capabilities: TaskReceiverCapabilities; + close(): void; +} + +type Handler = (request: unknown, context?: unknown) => Promise; +type FinalDisposition = "expiry" | "close"; +type TaskDisposition = "cancel" | FinalDisposition; + +interface TaskRecord { + task: TaskV1; + readonly method: TaskReceiverMethod; + readonly result: Promise>; + readonly resolve: (value: Record) => void; + readonly reject: (error: unknown) => void; + readonly controller: AbortController; + readonly expiresAt: number | null; + expiryTimer?: ReturnType; + disposition?: TaskDisposition; +} + +interface ClientInternals { + readonly _requestHandlers: Map; +} + +function clientInternals(client: Client): ClientInternals { + const candidate = client as unknown as Partial; + if (!(candidate._requestHandlers instanceof Map)) + throw new TypeError( + "Task receiver binding requires an SDK Client with request-handler restoration support", + ); + return candidate as ClientInternals; +} + +function nonNegativeInteger( + name: string, + value: number | null, + allowNull: boolean, +): void { + if (value === null) { + if (allowNull) return; + throw new RangeError(`${name} must be a non-negative integer`); + } + if (!Number.isInteger(value) || value < 0) + throw new RangeError( + `${name} must be a non-negative integer${allowNull ? " or null" : ""}`, + ); +} + +function positiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value <= 0) + throw new RangeError(`${name} must be a positive integer`); +} + +function paramsOf(request: unknown): Record { + if (request === null || typeof request !== "object" || !("params" in request)) + return {}; + const params = toJsonValue(request.params); + if (params === null || Array.isArray(params) || typeof params !== "object") + throw new TypeError("Request params must normalize to a JSON object"); + return params as Record; +} + +function hasTaskAugmentation(request: unknown): boolean { + if (request === null || typeof request !== "object" || !("params" in request)) + return false; + const { params } = request; + return ( + params !== null && + typeof params === "object" && + "task" in params && + params.task !== null && + params.task !== undefined + ); +} + +function taskIdOf(request: unknown): string { + const taskId = paramsOf(request).taskId; + if (typeof taskId !== "string") + throw new Error("A string taskId is required"); + return taskId; +} + +function clearExpiry(record: TaskRecord): void { + if (record.expiryTimer !== undefined) clearTimeout(record.expiryTimer); + record.expiryTimer = undefined; +} + +function detachTimer(timer: ReturnType): void { + if (typeof timer === "object" && "unref" in timer) timer.unref(); +} + +/** + * Binds task-augmented receiver requests to an SDK Client and owns their task + * lifecycle. The Client creates JSON-RPC envelopes for emitted notifications. + */ +export function bindTaskReceiver( + client: Client, + options: TaskReceiverOptions, +): TaskReceiverBinding { + const internals = clientInternals(client); + const now = Date.now; + const makeId = options.createTaskId ?? createDefaultTaskId; + const ttlMs = options.ttlMs ?? null; + const pollIntervalMs = options.pollIntervalMs; + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + const maxTasks = options.maxTasks ?? DEFAULT_MAX_TASKS; + if (typeof ttlMs !== "function") nonNegativeInteger("ttlMs", ttlMs, true); + if (pollIntervalMs !== undefined) + nonNegativeInteger("pollIntervalMs", pollIntervalMs, true); + positiveInteger("pageSize", pageSize); + positiveInteger("maxTasks", maxTasks); + + const callbacks = new Map(); + if (options.methods["sampling/createMessage"] && options.sampling) + callbacks.set("sampling/createMessage", options.sampling); + if (options.methods["elicitation/create"] && options.elicitation) + callbacks.set("elicitation/create", options.elicitation); + for (const method of Object.keys(options.methods) as TaskReceiverMethod[]) { + if (options.methods[method] && !callbacks.has(method)) + throw new Error(`Enabled receiver method ${method} requires a callback`); + } + + const tasks = new Map(); + const installed = new Map(); + const previous = new Map(); + let closed = false; + + const report = (error: unknown, context: TaskReceiverErrorContext): void => { + options.onError?.(error, context); + }; + const snapshot = (record: TaskRecord): TaskV1 => ({ ...record.task }); + const notify = ( + record: TaskRecord, + origin: TaskReceiverProtocolMethod, + ): void => { + const notification = { + method: "notifications/tasks/status", + params: snapshot(record), + }; + void client.notification(notification).catch((error: unknown) => { + report(error, { method: origin, taskId: record.task.taskId }); + }); + }; + const transition = ( + record: TaskRecord, + status: TaskStatusV1, + origin: TaskReceiverProtocolMethod, + statusMessage?: string, + ): void => { + record.task = { + ...record.task, + status, + lastUpdatedAt: new Date(now()).toISOString(), + ...(statusMessage === undefined ? {} : { statusMessage }), + }; + notify(record, origin); + }; + const remove = (record: TaskRecord, disposition: FinalDisposition): void => { + const firstDisposition = record.disposition === undefined; + if (firstDisposition) { + record.disposition = disposition; + record.controller.abort(); + record.reject( + new Error( + `Task ${disposition === "expiry" ? "expired" : "receiver binding closed"}`, + ), + ); + } + clearExpiry(record); + tasks.delete(record.task.taskId); + }; + const expire = (): void => { + const timestamp = now(); + for (const record of tasks.values()) + if (record.expiresAt !== null && timestamp >= record.expiresAt) + remove(record, "expiry"); + }; + const armExpiry = (record: TaskRecord): void => { + if (record.expiresAt === null) return; + const timer = setTimeout( + () => { + remove(record, "expiry"); + }, + Math.max(0, record.expiresAt - now()), + ); + record.expiryTimer = timer; + detachTimer(timer); + }; + const get = (id: string): TaskRecord => { + expire(); + const record = tasks.get(id); + if (!record) throw new Error(`Unknown or expired task: ${id}`); + return record; + }; + const install = ( + method: string, + handler: Handler, + bypassTaskResultValidation = false, + ): void => { + const prior = internals._requestHandlers.get(method); + previous.set(method, prior); + const guarded: Handler = (request, context) => { + if (closed) + return Promise.reject(new Error("Task receiver binding is closed")); + if (bypassTaskResultValidation && !hasTaskAugmentation(request) && prior) + return prior(request, context); + return handler(request, context); + }; + // The SDK excludes legacy task methods from its public method union, but its + // runtime custom-method path still accepts these handlers. + ( + client.setRequestHandler as unknown as ( + method: string, + handler: Handler, + ) => void + )(method, guarded); + const validating = internals._requestHandlers.get(method); + if (!validating) + throw new Error( + `SDK Client did not install request handler for ${method}`, + ); + const installedHandler: Handler = bypassTaskResultValidation + ? (request, context) => + hasTaskAugmentation(request) + ? guarded(request, context) + : validating(request, context) + : validating; + if (installedHandler !== validating) + internals._requestHandlers.set(method, installedHandler); + installed.set(method, installedHandler); + }; + + for (const [method, callback] of callbacks) + install( + method, + (raw) => { + expire(); + const params = paramsOf(raw); + if (tasks.size >= maxTasks) + throw new Error( + `Task receiver capacity of ${String(maxTasks)} retained tasks reached`, + ); + const id = makeId(); + if (tasks.has(id)) throw new Error(`Duplicate task identifier: ${id}`); + const createdTimestamp = now(); + const createdAt = new Date(createdTimestamp).toISOString(); + const taskTtlMs = typeof ttlMs === "function" ? ttlMs() : ttlMs; + nonNegativeInteger("ttlMs", taskTtlMs, true); + let resolve!: (value: Record) => void; + let reject!: (error: unknown) => void; + const result = new Promise>((yes, no) => { + resolve = yes; + reject = no; + }); + result.catch(() => undefined); + const record: TaskRecord = { + task: { + taskId: id, + status: "input_required", + createdAt, + lastUpdatedAt: createdAt, + ttl: taskTtlMs, + ...(pollIntervalMs === undefined || pollIntervalMs === null + ? {} + : { pollInterval: pollIntervalMs }), + }, + method, + result, + resolve, + reject, + controller: new AbortController(), + expiresAt: taskTtlMs === null ? null : createdTimestamp + taskTtlMs, + }; + tasks.set(id, record); + armExpiry(record); + + let callbackPromise: Promise>; + try { + callbackPromise = callback( + { method, params }, + { taskId: id, signal: record.controller.signal }, + ); + } catch (error) { + callbackPromise = Promise.reject( + error instanceof Error ? error : new Error(String(error)), + ); + } + void callbackPromise.then( + (value) => { + if (record.disposition !== undefined) return; + record.resolve(value); + transition(record, "completed", record.method); + }, + (error: unknown) => { + if (record.disposition !== undefined) { + report(error, { + method: record.method, + taskId: id, + lateAfter: record.disposition, + }); + return; + } + record.reject(error); + transition( + record, + "failed", + record.method, + error instanceof Error ? error.message : String(error), + ); + report(error, { method: record.method, taskId: id }); + }, + ); + return Promise.resolve({ + task: snapshot(record), + } satisfies CreateTaskResultV1); + }, + true, + ); + + install("tasks/list", (request) => { + expire(); + const params = paramsOf(request); + const cursor = Object.hasOwn(params, "cursor") ? params.cursor : undefined; + if (cursor !== undefined && typeof cursor !== "string") + throw new Error("tasks/list cursor must be a string"); + const records = [...tasks.values()]; + let start = 0; + if (cursor !== undefined) { + const cursorIndex = records.findIndex( + (record) => record.task.taskId === cursor, + ); + if (cursorIndex < 0) + throw new Error("Invalid or stale tasks/list cursor"); + start = cursorIndex + 1; + } + const page = records.slice(start, start + pageSize); + const hasMore = start + page.length < records.length; + const last = page.at(-1); + return Promise.resolve({ + tasks: page.map(snapshot), + ...(hasMore && last !== undefined + ? { nextCursor: last.task.taskId } + : {}), + }); + }); + install("tasks/get", (request) => + Promise.resolve(snapshot(get(taskIdOf(request)))), + ); + install("tasks/result", async (request) => { + const record = get(taskIdOf(request)); + if ( + record.task.status === "working" || + record.task.status === "input_required" + ) + throw new Error("Task is not terminal"); + if (record.task.status === "cancelled") + throw new Error("Task was cancelled"); + return record.result; + }); + install("tasks/cancel", (request) => { + const record = get(taskIdOf(request)); + if ( + record.task.status === "working" || + record.task.status === "input_required" + ) { + // Mark cancellation before aborting so re-entrant or immediately-settled + // callbacks cannot overwrite an accepted cancellation. + record.disposition = "cancel"; + transition(record, "cancelled", "tasks/cancel"); + record.controller.abort(); + record.reject(new Error("Task was cancelled")); + } + return Promise.resolve(snapshot(record)); + }); + + const requests: TaskReceiverCapabilities["requests"] = { + ...(callbacks.has("sampling/createMessage") + ? { sampling: { createMessage: {} } } + : {}), + ...(callbacks.has("elicitation/create") + ? { elicitation: { create: {} } } + : {}), + }; + return { + capabilities: { list: {}, cancel: {}, requests }, + close() { + if (closed) return; + closed = true; + for (const record of tasks.values()) remove(record, "close"); + tasks.clear(); + for (const [method, ours] of installed) { + if (internals._requestHandlers.get(method) !== ours) continue; + const prior = previous.get(method); + if (prior) internals._requestHandlers.set(method, prior); + else internals._requestHandlers.delete(method); + } + }, + }; +} diff --git a/packages/ext-tasks/src/receiver/receiver.test.ts b/packages/ext-tasks/src/receiver/receiver.test.ts new file mode 100644 index 0000000..1e0f6bb --- /dev/null +++ b/packages/ext-tasks/src/receiver/receiver.test.ts @@ -0,0 +1,459 @@ +import type { Client } from "@modelcontextprotocol/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { bindTaskReceiver } from "./index.js"; + +type Handler = (request: unknown) => Promise; +type NotificationInput = { method: string; params: Record }; + +class Host { + readonly _requestHandlers = new Map(); + readonly notificationInputs: NotificationInput[] = []; + readonly wireNotifications: Array = + []; + readonly notification = vi.fn((notification: NotificationInput) => { + this.notificationInputs.push(notification); + this.wireNotifications.push({ jsonrpc: "2.0", ...notification }); + return Promise.resolve(); + }); + + setRequestHandler(method: string, handler: Handler): void { + this._requestHandlers.set(method, handler); + } + + call(method: string, params: Record = {}): Promise { + const handler = this._requestHandlers.get(method); + if (!handler) throw new Error(`Missing handler ${method}`); + return Promise.resolve().then(() => handler({ method, params })); + } +} + +class ValidatingHost extends Host { + readonly validatingCalls: string[] = []; + + override setRequestHandler(method: string, handler: Handler): void { + const validating: Handler = async (request) => { + this.validatingCalls.push(method); + const result = await handler(request); + if (result !== null && typeof result === "object" && "task" in result) + throw new Error( + `SDK result validation rejected task result for ${method}`, + ); + return result; + }; + this._requestHandlers.set(method, validating); + } +} + +function asClient(host: Host): Client { + // The fake implements exactly the Client runtime members used by the binding; + // constructing a real Client would couple these unit tests to a transport. + return host as unknown as Client; +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +const flush = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("bindTaskReceiver", () => { + it("validates duration, page, and retention options", () => { + for (const [option, value] of [ + ["ttlMs", -1], + ["ttlMs", 1.5], + ["pollIntervalMs", -1], + ["pollIntervalMs", 1.5], + ["pageSize", 0], + ["pageSize", 1.5], + ["maxTasks", 0], + ["maxTasks", 1.5], + ] as const) { + const host = new Host(); + expect(() => + bindTaskReceiver(asClient(host), { + methods: {}, + [option]: value, + }), + ).toThrow(option); + } + expect(() => + bindTaskReceiver(asClient(new Host()), { + methods: {}, + ttlMs: null, + pollIntervalMs: null, + }), + ).not.toThrow(); + }); + + it("samples a TTL function separately for each task and expires from each creation", async () => { + vi.useFakeTimers(); + const host = new Host(); + const ttlMs = vi.fn().mockReturnValueOnce(5).mockReturnValueOnce(10); + let id = 0; + bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + ttlMs, + sampling: () => new Promise>(() => undefined), + createTaskId: () => `sampled-${String(++id)}`, + }); + + await expect(host.call("sampling/createMessage")).resolves.toMatchObject({ + task: { taskId: "sampled-1", ttl: 5 }, + }); + await expect(host.call("sampling/createMessage")).resolves.toMatchObject({ + task: { taskId: "sampled-2", ttl: 10 }, + }); + expect(ttlMs).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(5); + await expect( + host.call("tasks/get", { taskId: "sampled-1" }), + ).rejects.toThrow("expired"); + await expect( + host.call("tasks/get", { taskId: "sampled-2" }), + ).resolves.toMatchObject({ ttl: 10 }); + await vi.advanceTimersByTimeAsync(5); + await expect( + host.call("tasks/get", { taskId: "sampled-2" }), + ).rejects.toThrow("expired"); + }); + + it("advertises and installs only enabled request methods", () => { + const host = new Host(); + const binding = bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + sampling: () => Promise.resolve({ role: "assistant" }), + }); + expect(binding.capabilities.requests).toEqual({ + sampling: { createMessage: {} }, + }); + expect(host._requestHandlers.has("elicitation/create")).toBe(false); + for (const method of [ + "tasks/list", + "tasks/get", + "tasks/result", + "tasks/cancel", + ]) + expect(host._requestHandlers.has(method)).toBe(true); + binding.close(); + }); + + for (const { method, option, ordinaryResult } of [ + { + method: "sampling/createMessage", + option: "sampling", + ordinaryResult: { + role: "assistant", + content: { type: "text", text: "ok" }, + }, + }, + { + method: "elicitation/create", + option: "elicitation", + ordinaryResult: { action: "accept", content: { answer: "ok" } }, + }, + ] as const) { + it(`bypasses SDK result validation only for task-augmented ${method}`, async () => { + const host = new ValidatingHost(); + const previous = vi.fn(() => Promise.resolve(ordinaryResult)); + host._requestHandlers.set(method, previous); + const callback = vi.fn(() => Promise.resolve({ completed: true })); + const binding = bindTaskReceiver(asClient(host), { + methods: { [method]: true }, + [option]: callback, + createTaskId: () => `${option}-task`, + }); + + await expect( + host.call(method, { task: { ttl: null } }), + ).resolves.toMatchObject({ task: { taskId: `${option}-task` } }); + expect(host.validatingCalls).toEqual([]); + + await expect(host.call(method)).resolves.toEqual(ordinaryResult); + expect(previous).toHaveBeenCalledOnce(); + expect(host.validatingCalls).toEqual([method]); + + const installed = host._requestHandlers.get(method); + expect(installed).toBeDefined(); + binding.close(); + expect(host._requestHandlers.get(method)).toBe(previous); + if (installed) + await expect( + installed({ method, params: { task: { ttl: null } } }), + ).rejects.toThrow("closed"); + }); + } + + it("normalizes params before callback and rejects non-JSON params before allocation", async () => { + const host = new Host(); + const sampling = vi.fn(() => Promise.resolve({ ok: true })); + bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + sampling, + createTaskId: () => "json-task", + }); + await host.call("sampling/createMessage", { + keep: 1, + omit: undefined, + nested: { toJSON: () => ({ projected: true }) }, + }); + expect(sampling).toHaveBeenCalledWith( + expect.objectContaining({ + params: { keep: 1, nested: { projected: true } }, + }), + expect.any(Object), + ); + await expect( + host.call("sampling/createMessage", { invalid: 1n }), + ).rejects.toThrow("serialized as JSON"); + await expect(host.call("tasks/list")).resolves.toMatchObject({ + tasks: [expect.objectContaining({ taskId: "json-task" })], + }); + }); + + it("creates, completes, emits SDK notification input, and returns payloads", async () => { + const host = new Host(); + const binding = bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + ttlMs: 5_000, + pollIntervalMs: 5, + sampling: (request) => + Promise.resolve({ echo: request.params.prompt ?? null }), + createTaskId: () => "task-1", + }); + await expect( + host.call("sampling/createMessage", { prompt: "hi", task: {} }), + ).resolves.toMatchObject({ + task: { + taskId: "task-1", + status: "input_required", + ttl: 5_000, + pollInterval: 5, + }, + }); + await flush(); + await expect( + host.call("tasks/result", { taskId: "task-1" }), + ).resolves.toEqual({ echo: "hi" }); + expect(host.notificationInputs).toHaveLength(1); + expect(host.notificationInputs[0]?.method).toBe( + "notifications/tasks/status", + ); + expect(host.notificationInputs[0]?.params).toMatchObject({ + taskId: "task-1", + status: "completed", + }); + expect(host.notificationInputs[0]).not.toHaveProperty("jsonrpc"); + expect(host.wireNotifications[0]).toMatchObject({ + jsonrpc: "2.0", + method: "notifications/tasks/status", + }); + binding.close(); + }); + + it("does not block transitions on notification and reports notification failures", async () => { + const host = new Host(); + const notification = deferred(); + host.notification.mockImplementationOnce(() => notification.promise); + const onError = vi.fn(); + bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + sampling: () => Promise.resolve({ ok: true }), + createTaskId: () => "notify-task", + onError, + }); + await host.call("sampling/createMessage"); + await flush(); + await expect( + host.call("tasks/result", { taskId: "notify-task" }), + ).resolves.toEqual({ ok: true }); + notification.reject(new Error("send failed")); + await flush(); + expect(onError).toHaveBeenCalledWith(expect.any(Error), { + method: "sampling/createMessage", + taskId: "notify-task", + }); + }); + + it("expires from creation, aborts pending callbacks, rejects payloads, and removes tasks", async () => { + vi.useFakeTimers(); + const host = new Host(); + let signal: AbortSignal | undefined; + const work = deferred>(); + const onError = vi.fn(); + bindTaskReceiver(asClient(host), { + methods: { "elicitation/create": true }, + ttlMs: 10, + elicitation: (_request, context) => { + signal = context.signal; + return work.promise; + }, + createTaskId: () => "expiring", + onError, + }); + await host.call("elicitation/create"); + await vi.advanceTimersByTimeAsync(10); + expect(signal?.aborted).toBe(true); + await expect( + host.call("tasks/get", { taskId: "expiring" }), + ).rejects.toThrow("expired"); + await expect(host.call("tasks/list")).resolves.toEqual({ tasks: [] }); + work.reject(new Error("stopped after expiry")); + await vi.runAllTimersAsync(); + expect(onError).toHaveBeenCalledWith(expect.any(Error), { + method: "elicitation/create", + taskId: "expiring", + lateAfter: "expiry", + }); + }); + + it("creates elicitation tasks as input_required while callback input is outstanding", async () => { + const host = new Host(); + const work = deferred<{ action: string }>(); + bindTaskReceiver(asClient(host), { + methods: { "elicitation/create": true }, + elicitation: () => work.promise, + createTaskId: () => "elicitation-task", + }); + + await expect( + host.call("elicitation/create", { message: "Confirm" }), + ).resolves.toMatchObject({ + task: { taskId: "elicitation-task", status: "input_required" }, + }); + await expect( + host.call("tasks/get", { taskId: "elicitation-task" }), + ).resolves.toMatchObject({ status: "input_required" }); + + work.resolve({ action: "accept" }); + await flush(); + await expect( + host.call("tasks/result", { taskId: "elicitation-task" }), + ).resolves.toEqual({ action: "accept" }); + }); + + it("makes accepted cancellation win over late success and reports late failure", async () => { + const host = new Host(); + const first = deferred>(); + const second = deferred>(); + const work = [first, second]; + const onError = vi.fn(); + let workIndex = 0; + let taskId = 0; + bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + sampling: () => work[workIndex++].promise, + createTaskId: () => `cancel-${String(++taskId)}`, + onError, + }); + await host.call("sampling/createMessage"); + await expect( + host.call("tasks/cancel", { taskId: "cancel-1" }), + ).resolves.toMatchObject({ status: "cancelled" }); + first.resolve({ ignored: true }); + await flush(); + await expect( + host.call("tasks/get", { taskId: "cancel-1" }), + ).resolves.toMatchObject({ status: "cancelled" }); + expect(host.notificationInputs).toHaveLength(1); + + await host.call("sampling/createMessage"); + await host.call("tasks/cancel", { taskId: "cancel-2" }); + second.reject(new Error("late callback failure")); + await flush(); + expect(onError).toHaveBeenCalledWith(expect.any(Error), { + method: "sampling/createMessage", + taskId: "cancel-2", + lateAfter: "cancel", + }); + }); + + it("paginates retained tasks stably and rejects invalid or stale cursors", async () => { + vi.useFakeTimers(); + const host = new Host(); + let id = 0; + bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + sampling: () => Promise.resolve({ ok: true }), + createTaskId: () => `task-${String(++id)}`, + pageSize: 2, + ttlMs: 20, + }); + await host.call("sampling/createMessage"); + await vi.advanceTimersByTimeAsync(1); + await host.call("sampling/createMessage"); + await vi.advanceTimersByTimeAsync(1); + await host.call("sampling/createMessage"); + const first = (await host.call("tasks/list")) as { + tasks: Array<{ taskId: string }>; + nextCursor?: string; + }; + expect(first.tasks.map((task) => task.taskId)).toEqual([ + "task-1", + "task-2", + ]); + expect(first.nextCursor).toBe("task-2"); + await expect( + host.call("tasks/list", { cursor: first.nextCursor }), + ).resolves.toMatchObject({ tasks: [{ taskId: "task-3" }] }); + await expect( + host.call("tasks/list", { cursor: "missing" }), + ).rejects.toThrow("Invalid or stale"); + await vi.advanceTimersByTimeAsync(18); + await expect(host.call("tasks/list", { cursor: "task-1" })).rejects.toThrow( + "Invalid or stale", + ); + }); + + it("rejects new work deterministically at maxTasks and accepts it after expiry", async () => { + vi.useFakeTimers(); + const host = new Host(); + let id = 0; + bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + sampling: () => Promise.resolve({ ok: true }), + createTaskId: () => `capacity-${String(++id)}`, + maxTasks: 1, + ttlMs: 5, + }); + await host.call("sampling/createMessage"); + await expect(host.call("sampling/createMessage")).rejects.toThrow( + "capacity of 1", + ); + await vi.advanceTimersByTimeAsync(5); + await expect(host.call("sampling/createMessage")).resolves.toMatchObject({ + task: { taskId: "capacity-2" }, + }); + }); + + it("guards every installed handler after close and restores only its own handlers", async () => { + const host = new Host(); + const previous = vi.fn(() => Promise.resolve({ previous: true })); + host._requestHandlers.set("tasks/get", previous); + const binding = bindTaskReceiver(asClient(host), { methods: {} }); + const captured = [...host._requestHandlers.values()]; + const replacement = vi.fn(() => Promise.resolve({ replacement: true })); + host._requestHandlers.set("tasks/list", replacement); + binding.close(); + expect(host._requestHandlers.get("tasks/get")).toBe(previous); + expect(host._requestHandlers.get("tasks/list")).toBe(replacement); + for (const handler of captured) + await expect(handler({ params: {} })).rejects.toThrow("closed"); + }); +}); diff --git a/packages/ext-tasks/test-support/client/semantic.ts b/packages/ext-tasks/test-support/client/semantic.ts new file mode 100644 index 0000000..bd4059f --- /dev/null +++ b/packages/ext-tasks/test-support/client/semantic.ts @@ -0,0 +1,45 @@ +/** Test-only adapters for legacy assertions whose subject is not semantic outcomes. */ + +import type { JsonValue } from "../../src/core/index.js"; +import { TaskCancelledError } from "../../src/client/index.js"; +import type { + TaskExecutionEvent, + TaskOutcome, +} from "../../src/client/index.js"; + +interface OutcomeSource { + result(): Promise>; +} + +interface EventSource { + updates(): AsyncIterable>; +} + +/** Unwraps a semantic outcome for tests focused on unrelated behavior. */ +export async function legacyResult( + source: OutcomeSource, +): Promise { + const outcome = await source.result(); + if (outcome.status === "completed") return outcome.result; + if (outcome.status === "failed") { + if (outcome.error.cause instanceof Error) throw outcome.error.cause; + throw outcome.error; + } + throw new TaskCancelledError(); +} + +/** Reconstructs generated snapshots for tests focused on legacy race behavior. */ +export async function* legacyUpdates( + source: EventSource, +): AsyncIterable<{ + readonly generation: "v1" | "v2"; + readonly task: Readonly>; +}> { + for await (const event of source.updates()) { + if (event.type !== "task") continue; + yield { + generation: "ttl" in event.task.raw ? "v1" : "v2", + task: event.task.raw, + }; + } +} From 0c239c08a48cb8aa6d801228270542c4d55a2824 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 8 Sep 2026 19:14:46 -0700 Subject: [PATCH 22/33] feat: preserve request timeouts across task sessions - Add per-request timeout options to tool calls and task controllers - Preserve timeout context across initiating and follow-up requests - Forward timeout values through the SDK client adapter - Cover managed and detached task request propagation Signed-off-by: Luca Chang --- packages/ext-tasks/src/client/api.ts | 4 ++++ packages/ext-tasks/src/client/client-adapter.test.ts | 12 +++++++++--- .../src/client/immediate-session-basics.test.ts | 10 +++++++--- packages/ext-tasks/src/client/port.ts | 2 ++ packages/ext-tasks/src/client/sdk-client-adapter.ts | 3 +++ packages/ext-tasks/src/client/session.ts | 7 ++++++- .../ext-tasks/src/client/task-controller.test.ts | 4 +++- packages/ext-tasks/src/client/task-controller.ts | 7 ++++++- 8 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/ext-tasks/src/client/api.ts b/packages/ext-tasks/src/client/api.ts index a674ae3..0e3de22 100644 --- a/packages/ext-tasks/src/client/api.ts +++ b/packages/ext-tasks/src/client/api.ts @@ -414,11 +414,15 @@ export interface ToolCallOptions { readonly metadata?: Readonly>; /** Additional headers for the initiating call and task follow-up requests. */ readonly headers?: Readonly>; + /** Per-request timeout in milliseconds for the initiating call and task follow-ups. */ + readonly requestTimeoutMs?: number; } export interface TaskControllerOptions { /** Additional headers preserved on every task request. */ readonly headers?: Readonly>; + /** Per-request timeout in milliseconds preserved on every task request. */ + readonly requestTimeoutMs?: number; } export interface TaskResultOptions { diff --git a/packages/ext-tasks/src/client/client-adapter.test.ts b/packages/ext-tasks/src/client/client-adapter.test.ts index a031967..5b9ff1c 100644 --- a/packages/ext-tasks/src/client/client-adapter.test.ts +++ b/packages/ext-tasks/src/client/client-adapter.test.ts @@ -62,18 +62,24 @@ describe("Client adapter", () => { }); }); - it("forwards headers through SDK request options", async () => { + it("forwards headers and request timeout through SDK request options", async () => { const sdk = client(); const request = vi .spyOn(sdk, "request") .mockResolvedValueOnce({ ok: true }); - const port = createSessionPortFromClient(sdk, "headers"); + const port = createSessionPortFromClient(sdk, "request-context"); await port.dispatch( { method: "custom/method" }, - { context: { headers: { "x-trace": "trace-1" } } }, + { + context: { + headers: { "x-trace": "trace-1" }, + requestTimeoutMs: 2_500, + }, + }, ); expect(request.mock.calls[0]?.[2]).toEqual({ headers: { "x-trace": "trace-1" }, + timeout: 2_500, }); }); diff --git a/packages/ext-tasks/src/client/immediate-session-basics.test.ts b/packages/ext-tasks/src/client/immediate-session-basics.test.ts index b7ada3a..1e292f0 100644 --- a/packages/ext-tasks/src/client/immediate-session-basics.test.ts +++ b/packages/ext-tasks/src/client/immediate-session-basics.test.ts @@ -158,6 +158,7 @@ describe("immediate and session basics", () => { const execution = await session.callTool("x", undefined, { task: { retentionMs: 5000 }, headers: { "x-routing-key": "route-task" }, + requestTimeoutMs: 3_000, }); expect(port.requests[0]).toEqual({ method: "tools/call", @@ -178,9 +179,12 @@ describe("immediate and session basics", () => { method: "tasks/cancel", params: { taskId: "task-ttl" }, }); - expect(port.dispatchOptions[1]?.context?.headers).toEqual({ - "x-routing-key": "route-task", - }); + const expectedContext = { + headers: { "x-routing-key": "route-task" }, + requestTimeoutMs: 3_000, + }; + expect(port.dispatchOptions[0]?.context).toEqual(expectedContext); + expect(port.dispatchOptions[1]?.context).toEqual(expectedContext); await session.close(); }); diff --git a/packages/ext-tasks/src/client/port.ts b/packages/ext-tasks/src/client/port.ts index 0b93dbc..5588f0c 100644 --- a/packages/ext-tasks/src/client/port.ts +++ b/packages/ext-tasks/src/client/port.ts @@ -46,6 +46,8 @@ export interface IncomingServerRequest { export interface DispatchContext { /** Additional transport headers. HTTP transports send these on this request. */ readonly headers?: Readonly>; + /** Per-request timeout in milliseconds. */ + readonly requestTimeoutMs?: number; } /** Options for one port dispatch. */ diff --git a/packages/ext-tasks/src/client/sdk-client-adapter.ts b/packages/ext-tasks/src/client/sdk-client-adapter.ts index f144d14..b7f3e68 100644 --- a/packages/ext-tasks/src/client/sdk-client-adapter.ts +++ b/packages/ext-tasks/src/client/sdk-client-adapter.ts @@ -386,6 +386,9 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { ...(options.context?.headers === undefined ? {} : { headers: options.context.headers }), + ...(options.context?.requestTimeoutMs === undefined + ? {} + : { timeout: options.context.requestTimeoutMs }), }, ); return { kind: "result", result }; diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index 987a98e..5bd15d8 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -338,7 +338,12 @@ class PortTaskEnabledSession< ? {} : { ttl: options.task.retentionMs }; const dispatchContext = - options.headers === undefined ? undefined : { headers: options.headers }; + options.headers === undefined && options.requestTimeoutMs === undefined + ? undefined + : { + headers: options.headers, + requestTimeoutMs: options.requestTimeoutMs, + }; const executionId = nextExecutionIdentifier(); this.ordinaryInputCandidates.set(executionId, { lifetime: "basic", diff --git a/packages/ext-tasks/src/client/task-controller.test.ts b/packages/ext-tasks/src/client/task-controller.test.ts index 370b7d8..5f4367e 100644 --- a/packages/ext-tasks/src/client/task-controller.test.ts +++ b/packages/ext-tasks/src/client/task-controller.test.ts @@ -38,7 +38,7 @@ function methods(port: FakePort): unknown[] { } describe("manual task controller", () => { - it("uses V1 get, result, and cancel requests and preserves headers", async () => { + it("uses V1 get, result, and cancel requests and preserves context", async () => { const port = new FakePort({ generation: "v1", capabilities: { requests: { tools: { call: {} } }, cancel: {} }, @@ -52,6 +52,7 @@ describe("manual task controller", () => { const session = withTasks(port, { tools }); const controller = session.task(taskId("manual-v1"), { headers: { authorization: "Bearer test" }, + requestTimeoutMs: 4_000, }); await expect(controller.snapshot()).resolves.toMatchObject({ @@ -73,6 +74,7 @@ describe("manual task controller", () => { expect(dispatchOptions?.signal?.aborted).toBe(false); expect(dispatchOptions?.context).toEqual({ headers: { authorization: "Bearer test" }, + requestTimeoutMs: 4_000, }); } await session.close(); diff --git a/packages/ext-tasks/src/client/task-controller.ts b/packages/ext-tasks/src/client/task-controller.ts index 562d1e8..ecd81ef 100644 --- a/packages/ext-tasks/src/client/task-controller.ts +++ b/packages/ext-tasks/src/client/task-controller.ts @@ -64,7 +64,12 @@ export function createTaskController( throw new Error("Task management is not supported by this session"); const generation = capabilities.generation; const context: DispatchContext | undefined = - options.headers === undefined ? undefined : { headers: options.headers }; + options.headers === undefined && options.requestTimeoutMs === undefined + ? undefined + : { + headers: options.headers, + requestTimeoutMs: options.requestTimeoutMs, + }; const rpc: TaskRpcV1 | TaskRpcV2 = generation === "v1" ? createTaskRpc(generation, { port, taskId, context }) From fa6bad1b787982d4cec8a928a879f60c3df92e99 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Wed, 9 Sep 2026 13:29:00 -0700 Subject: [PATCH 23/33] feat: simplify interfaces and add docs --- .vitepress/config.mts | 53 +++- README.md | 29 +- packages/ext-tasks/README.md | 269 +++--------------- packages/ext-tasks/src/client/api.ts | 21 +- .../src/client/client-adapter.test.ts | 75 +++++ packages/ext-tasks/src/client/execution.ts | 7 + packages/ext-tasks/src/client/index.ts | 2 - .../src/client/sdk-client-adapter.ts | 49 ++-- .../src/client/session-facade.test.ts | 11 +- packages/ext-tasks/src/client/session.ts | 17 -- .../src/client/task-resumption.test.ts | 52 +++- typescript/adapters-and-schemas.md | 131 +++++++++ typescript/client/execution.md | 99 +++++++ typescript/client/input-and-recovery.md | 142 +++++++++ typescript/getting-started.md | 41 +++ typescript/index.md | 54 ++++ typescript/migrating-from-the-sdk.md | 223 +++++++++++++++ typescript/receiver.md | 131 +++++++++ typescript/troubleshooting.md | 113 ++++++++ 19 files changed, 1217 insertions(+), 302 deletions(-) create mode 100644 typescript/adapters-and-schemas.md create mode 100644 typescript/client/execution.md create mode 100644 typescript/client/input-and-recovery.md create mode 100644 typescript/getting-started.md create mode 100644 typescript/index.md create mode 100644 typescript/migrating-from-the-sdk.md create mode 100644 typescript/receiver.md create mode 100644 typescript/troubleshooting.md diff --git a/.vitepress/config.mts b/.vitepress/config.mts index 57a4b2a..f821c00 100644 --- a/.vitepress/config.mts +++ b/.vitepress/config.mts @@ -12,12 +12,61 @@ export default withMermaid( outline: [2, 3], nav: [ + { text: "SDK", link: "/typescript/" }, { text: "SEPs", link: "/seps/2663-tasks-extension" }, { text: "Specification", link: "/specification/2026-07-28/tasks" }, ], sidebar: { - "specification/": [ + "/typescript/": [ + { + text: "Introduction", + items: [ + { text: "Getting started", link: "/typescript/" }, + { + text: "Call your first tool", + link: "/typescript/getting-started", + }, + { + text: "Migrate from the base SDK", + link: "/typescript/migrating-from-the-sdk", + }, + ], + }, + { + text: "Clients", + items: [ + { + text: "Observe and control execution", + link: "/typescript/client/execution", + }, + { + text: "Handle input and recover tasks", + link: "/typescript/client/input-and-recovery", + }, + { + text: "[2025-11-25] Receive sampling and elicitation requests", + link: "/typescript/receiver", + }, + ], + }, + { + text: "Advanced", + items: [ + { + text: "Integrate adapters and schemas", + link: "/typescript/adapters-and-schemas", + }, + ], + }, + { + text: "Help", + items: [ + { text: "Troubleshooting", link: "/typescript/troubleshooting" }, + ], + }, + ], + "/specification/": [ { text: "Specification", items: [ @@ -29,7 +78,7 @@ export default withMermaid( ], }, ], - "seps/": [ + "/seps/": [ { text: "SEPs", items: [ diff --git a/README.md b/README.md index c9ff0e1..70b5fd9 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,25 @@ # MCP Tasks Extension -This repository contains the official [Model Context Protocol](https://modelcontextprotocol.io) Tasks extension (`io.modelcontextprotocol/tasks`), based on [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663). -## Overview +This repository contains the official [Model Context Protocol](https://modelcontextprotocol.io) Tasks extension (`io.modelcontextprotocol/tasks`), based on [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663). -This extension defines the **Tasks** primitive for the Model Context Protocol (MCP). Tasks are durable state machines that carry information about the underlying execution state of a request, enabling requestor polling and deferred result retrieval. Each task is uniquely identifiable by a receiver-generated **task ID**. +## Why Tasks? -Tasks are useful for: +Some MCP requests finish quickly. Others run for minutes, wait for human input, or need to survive a disconnected client. The Tasks extension lets a receiver return a durable task handle so the requester can follow progress and retrieve the result later. -- Representing expensive computations and batch processing requests -- Integrating seamlessly with external job/workflow APIs -- Enabling call-now, fetch-later execution patterns +Use Tasks for long computations, approval workflows, external job systems, and call-now/fetch-later APIs. **Extension Identifier:** `io.modelcontextprotocol/tasks` +## Use it from TypeScript + +The `@modelcontextprotocol/ext-tasks` package provides generation-agnostic requester lifecycle APIs and 2025-11-25 Tasks receiver support. Start with the [TypeScript package guide](https://modelcontextprotocol.github.io/ext-tasks/typescript/) or [call your first task-enabled tool](https://modelcontextprotocol.github.io/ext-tasks/typescript/getting-started.html). + ## Schemas -| Version | Status | TypeScript | JSON Schema | -| --- | --- | --- | --- | -| `2026-07-28` | Stable | [`schema.ts`](schema/2026-07-28/schema.ts) | [`schema.json`](schema/2026-07-28/schema.json) | -| `draft` | Development | [`schema.ts`](schema/draft/schema.ts) | [`schema.json`](schema/draft/schema.json) | +| Version | Status | TypeScript | JSON Schema | +| ------------ | ----------- | ------------------------------------------ | ---------------------------------------------- | +| `2026-07-28` | Stable | [`schema.ts`](schema/2026-07-28/schema.ts) | [`schema.json`](schema/2026-07-28/schema.json) | +| `draft` | Development | [`schema.ts`](schema/draft/schema.ts) | [`schema.json`](schema/draft/schema.json) | Released schema directories are immutable snapshots with version-specific JSON Schema identifiers. Development and schema generation target `schema/draft/` only. To create a release snapshot from the current draft: @@ -30,8 +31,7 @@ npm run snapshot:schema -- YYYY-MM-DD ### SDK Package -The redistributable TypeScript package lives in `packages/ext-tasks`. It is an -npm workspace that publishes as `@modelcontextprotocol/ext-tasks`. +The redistributable package lives in `packages/ext-tasks` and publishes as `@modelcontextprotocol/ext-tasks`. ```bash # Run schema, package, and packed-consumer checks @@ -44,8 +44,7 @@ npm run test:watch npm run pack:package ``` -The package intentionally has no root export. Consumers import `/core`, -`/core/v1`, `/core/v2`, or `/client`. +The package intentionally has no root export. Consumers import `/client`, `/receiver`, `/core`, `/core/v1`, or `/core/v2`; the guide explains which entry point owns each workflow. ### Schema Generation diff --git a/packages/ext-tasks/README.md b/packages/ext-tasks/README.md index 402cb72..17b6491 100644 --- a/packages/ext-tasks/README.md +++ b/packages/ext-tasks/README.md @@ -1,268 +1,83 @@ # `@modelcontextprotocol/ext-tasks` -Requester- and receiver-side lifecycle support plus versioned protocol schemas for MCP Tasks. +Call tools through generation-agnostic Tasks requester APIs. The package also provides 2025-11-25 Tasks receiver support for sampling and elicitation requests. -This package has no root entry point. Import the role or protocol generation needed by the application: +## Install -```ts -import { createTaskSessionFromClient } from "@modelcontextprotocol/ext-tasks/client"; -import { bindTaskReceiver } from "@modelcontextprotocol/ext-tasks/receiver"; +```sh +npm install @modelcontextprotocol/ext-tasks ``` -## Receiver binding - -`bindTaskReceiver(client, options)` adds Tasks V1 lifecycle handling to an SDK `Client`. The binding owns task identifiers, timestamps, bounded retention, result promises, status transitions and notifications, `tasks/list|get|result|cancel`, cancellation signals, and cleanup. Consumers provide only enabled request methods and asynchronous sampling/elicitation callbacks. The SDK Client is the supported host because the package can then honestly preserve and restore displaced handlers; the private `_requestHandlers` compatibility shim remains encapsulated inside the package. +Use the client entry point when your application calls tools: ```ts -const receiver = bindTaskReceiver(client, { - methods: { - "sampling/createMessage": true, - "elicitation/create": true, - }, - ttlMs: 60_000, - pollIntervalMs: 1_000, - pageSize: 100, - maxTasks: 1_000, - sampling: async (request, { signal }) => - runSampling(request.params, { signal }), - elicitation: async (request, { signal }) => - runElicitation(request.params, { signal }), - onError(error, context) { - reportReceiverError(error, context); - }, -}); - -// Merge this under the host's advertised Tasks capability. -const taskCapabilities = receiver.capabilities; - -try { - await serve(); -} finally { - receiver.close(); -} +import { + createTaskSessionFromClient, + resultFromTaskOutcome, +} from "@modelcontextprotocol/ext-tasks/client"; ``` -Only methods set to `true` are installed and advertised; each enabled method requires its matching callback. Receiver-created task-augmented `sampling/createMessage` and `elicitation/create` tasks start as `input_required` because their callbacks represent outstanding client or user input; they transition to `completed` or `failed` when the callback settles. `ttlMs` is the total task lifetime measured from creation, not from settlement. Set it to a non-negative integer or `null`, or to a function returning one of those values; a function is sampled once separately for each task, and that sample is both reported on the task and used for its creation-relative expiry. A finite TTL is armed immediately; expiry aborts pending callback work, rejects the retained payload promise, and removes the task. `null` disables time expiry. `pollIntervalMs` accepts only a non-negative integer or `null`; `null` polling omits the wire hint. `pageSize` (default 100) and `maxTasks` (default 1,000) are positive integers. `tasks/list` uses stable insertion-order task-ID cursors and rejects unknown or expired cursors. At capacity, new task creation is rejected deterministically rather than evicting retained work. - -Cancellation wins once `tasks/cancel` accepts it: the task is marked cancelled before its callback is aborted, late success is discarded, and a late callback failure is reported through `onError` with `lateAfter: "cancel"`. Expiry and close similarly report late callback failures with their disposition, so callback outcomes are always observed. Cancelled records remain retained only until their original creation-based TTL. - -Status notifications are fire-and-forget and never delay lifecycle transitions. The binding passes the SDK notification input `{ method, params }` without `jsonrpc`; the Client owns the JSON-RPC envelope. Send failures are caught and reported through `onError` with the originating transition method. `close()` is idempotent, aborts pending callbacks, drops retained records, and restores handlers displaced when the binding was installed without overwriting handlers installed later. Every installed handler rejects after close, including a handler reference captured before restoration. - -### Receiver migration - -| Consumer-owned receiver concern | Package replacement | -| ------------------------------------------------------------------ | --------------------------------------------------------- | -| Task ID, timestamps, TTL map, payload promises | `bindTaskReceiver` internal lifecycle | -| Sampling/createMessage task wrapper | `methods["sampling/createMessage"]` + `sampling` callback | -| Elicitation/create task wrapper | `methods["elicitation/create"]` + `elicitation` callback | -| Status notification emission | Automatic `notifications/tasks/status` transitions | -| `tasks/list`, `tasks/get`, `tasks/result`, `tasks/cancel` handlers | Installed automatically | -| Cancellation controller lookup | Callback `AbortSignal` | -| Task-augmented create result | Automatic `{ task: TaskV1 }` result | -| Direct `_requestHandlers` access and restoration | Internal compatibility shim + `close()` | - -## Session setup and endpoint identity - -`createTaskSessionFromClient(client, options)` is the primary MCP SDK Client entry point. `options.endpointId` is an opaque, stable identity used only to scope serialized task references; the package never interprets it. The returned `TaskEnabledSession` owns its Client adapter, so `close()` restores the Client callbacks even when session cleanup reports another failure. Construction failures dispose the partially installed adapter before they are rethrown. The factory never closes the Client transport. +## Call a tool -When the host does not already have a stable opaque identity, `createTaskSessionEndpointId(namespace, descriptor)` derives one from host-supplied connection semantics. It normalizes the descriptor as JSON, recursively sorts object keys, and returns a branded, versioned SHA-256 identity. Include only stable endpoint properties; the package deliberately does not inspect a transport or choose descriptor fields. +Create a session from a connected MCP SDK `Client`. The same call handles immediate results and durable task-backed execution. ```ts const session = createTaskSessionFromClient(client, { - endpointId, - onError, - onInputRequest, - // Required only when the connected session negotiates V2 Tasks: - rawDispatch: hostRequestCoordinator.dispatch, - v2RequestFraming: { - protocolVersion, - clientInfo, - clientCapabilities, - }, + endpointId: "production-reports", }); try { const execution = await session.callTool("generate_report", { format: "pdf", }); - const outcome = await execution.result(); + const { outcome } = await execution.settle(); + const result = resultFromTaskOutcome(outcome); } finally { await session.close(); } ``` -For custom transports or advanced ownership, `withTasks(port, options)` still accepts any connected `ConnectedMcpSessionPort` and borrows it. `createSessionPortFromClient(client, endpointId, { rawDispatch, v2RequestFraming })` remains available as the low-level Client adapter; callers composing those APIs separately must dispose the port after closing the session. +## Receive 2025-11-25 Tasks requests -The adapter support matrix is explicit: - -| Connected session | Client adapter requirement | Dispatch path | -| ------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | -| V1 Tasks | `client`, opaque `endpointId` | SDK `Client.request` | -| No Tasks capability | `client`, opaque `endpointId` | SDK `Client.request` | -| V2 Tasks | `client`, opaque `endpointId`, `rawDispatch`, and `v2RequestFraming` when V2 is negotiated | Host request coordinator for V2 `tools/call` and `tasks/*` | - -SDK Client 2.x has no public raw request coordinator or public getters for its constructor-time client identity and capabilities. Therefore V2 without `rawDispatch` and `v2RequestFraming` throws while constructing the port, before a session or call exists. The adapter validates, copies, and deeply freezes framing at creation. It frames every raw V2 task request with the protocol version, client information, and client capabilities, forces the Tasks extension, and preserves unrelated caller `_meta`; the three package-reserved framing keys overwrite caller collisions. Consumers pass unframed task requests. Non-task requests continue through `Client.request` unchanged. +Use the receiver entry point to handle 2025-11-25 task-backed sampling and elicitation requests. ```ts -const port = createSessionPortFromClient(client, endpointId, { - rawDispatch: hostRequestCoordinator.dispatch, - v2RequestFraming: { protocolVersion, clientInfo, clientCapabilities }, -}); -``` - -Do not implement `rawDispatch` with a standalone `client.transport.send`: that bypasses the SDK coordinator and competes for responses. - -## Package boundary adapters - -Use `toJsonValue(value)` from `@modelcontextprotocol/ext-tasks/core` when arbitrary JavaScript data crosses into the package JSON model. Its semantics are explicitly `JSON.stringify(value)` followed by `JSON.parse(...)`: object `undefined` properties are omitted, array holes and `undefined` become `null`, `toJSON` is honored, and prototypes are removed. A top-level value that JSON cannot represent, serialization failure, or invalid normalized result throws `TypeError`. - -`runtimeCodecFromStandardSchema(schema)` adapts a canonical synchronous Standard Schema V1 validator to `RuntimeCodec`; validation failures retain copied readonly issues, including paths, in `ProtocolDecodeError.details.issues`, while thrown validators are retained as the error `cause`. `toolDeclarationFromMcpTool(tool)` from the client subpath converts an SDK `Tool`, requires an object-shaped `inputSchema`, maps `_meta` to neutral metadata, maps task support, preserves unknown top-level fields in `extensions`, and preserves unknown nested `execution` fields in `executionExtensions`. - -These adapters remove consumer-owned stringify/parse helpers, Standard Schema result translation, and MCP Tool projection code. - -## Tool declarations - -> `ToolDeclaration` is structural and generation-neutral. The session projects it to the negotiated wire protocol internally; applications never choose a V1/V2 declaration factory. - -```ts -import { - toolDeclaration, - withTasks, -} from "@modelcontextprotocol/ext-tasks/client"; - -const declaration = toolDeclaration({ - name: "generate_report", - description: "Generate a report", - inputSchema: { type: "object" }, - taskSupport: "required", -}); - -const session = withTasks(port, { - tools: { - currentTool(name) { - return name === declaration.name ? declaration : undefined; - }, - }, -}); -``` - -When no provider is supplied, the package parses `tools/list` using the negotiated protocol and projects every tool to the same neutral shape. Duplicate names reject deterministically. An execution-scoped `declaration` in `callTool` or `resumeTask` options wins over provider lookup and remains available as `execution.declaration`. - -## Runtime codecs - -The public projection API is schema-library neutral. `RuntimeCodec` receives an already-decoded `JsonValue` and returns a success value or `ProtocolDecodeError`. Zod remains an implementation convenience of the versioned generated schema subpaths, not a public client API requirement. - -```ts -import { - ProtocolDecodeError, - type RuntimeCodec, -} from "@modelcontextprotocol/ext-tasks/core"; - -const reportCodec: RuntimeCodec<{ reportUrl: string }> = { - parse(value) { - if ( - value !== null && - !Array.isArray(value) && - typeof value === "object" && - "reportUrl" in value && - typeof value.reportUrl === "string" - ) { - return { success: true, value: { reportUrl: value.reportUrl } }; - } - return { - success: false, - error: new ProtocolDecodeError("Expected reportUrl"), - }; - }, -}; - -const execution = await session.callTool("generate_report", undefined, { - resultCodec: reportCodec, -}); -``` - -`@modelcontextprotocol/ext-tasks/core` has no runtime imports or dependencies. Generated Zod schemas remain available from `/core/v1` and `/core/v2`: - -```ts -import { TaskV2Schema } from "@modelcontextprotocol/ext-tasks/core/v2"; - -const task = TaskV2Schema.parse(taskPayload); -``` - -## Semantic outcomes and events - -`execution.result()` resolves exactly one cached `TaskOutcome`: `{ status: "completed", result, task? }`, `{ status: "failed", error, task? }`, or `{ status: "cancelled", task? }`. Protocol errors and cancellation are values in this semantic union rather than generation-specific result/rejection shapes. `resultFromTaskOutcome(outcome)` is the convenience boundary for code that prefers the traditional result-or-throw shape: it returns the completed result and throws `TaskFailedError` or `TaskCancelledError` otherwise. - -`execution.updates()` is single-acquire and yields normalized `{ type: "task", task: TaskView }` events followed by exactly one `{ type: "outcome", outcome }` event. `taskViewFromExecutionEvent(event)` returns the event's direct task or its outcome task when present. `TaskView` contains `taskId`, semantic `status`, optional `statusMessage` and timestamps, `retentionMs`, `suggestedPollIntervalMs`, plus `raw` and `extensions` for application UI use. The readonly `ttl` and `pollInterval` fields are compatibility aliases populated from those normalized primary names. It has no generation discriminator. - -`execution.settle({ onEvent, signal, close })` concurrently drains that stream and resolves `{ outcome, lastTask }`. It awaits synchronous or asynchronous `onEvent` callbacks. Observer failure or caller abort locally detaches immediately: polling stops and managed ownership is released without sending `tasks/cancel`. Settlement best-effort closes after natural completion by default; pass `close: false` to retain the execution. `detach()` is always local-only, while `close()` may request best-effort cooperative cancellation for an incomplete task. - -## Embedding hosts - -Calls preserve host metadata and transport routing while accepting neutral task options: +import { bindTaskReceiver } from "@modelcontextprotocol/ext-tasks/receiver"; -```ts -await session.callTool("generate_report", undefined, { - metadata: { traceId }, - headers: { "x-routing-key": routingKey }, - task: { preference: "prefer", retentionMs: 60_000 }, +const receiver = bindTaskReceiver(client, { + methods: { "sampling/createMessage": true }, + sampling: async (request, { signal }) => + runSampling(request.params, { signal }), }); -``` - -`preference` is `"allow" | "prefer" | "require" | "forbid"`. Requested retention is mapped only where the negotiated protocol supports it. Use `withRelatedTaskMetadata(existingMetadata, task)` to install `io.modelcontextprotocol/related-task` without mutating or dropping unknown metadata. Task-scoped peer requests and task-update inputs consistently expose related-task evidence through semantic input contexts. -For modern ordinary `tools/call` results with `resultType: "input_required"`, `callTool` completes request-scoped continuation before deciding whether the eventual result is immediate or task-backed. It preserves the original call parameters, per-round opaque `requestState`, validated `inputResponses`, application context, headers, and effective cancellation signal for up to 10 continuation rounds. This is distinct from a task snapshot whose `status` is `"input_required"`, which continues through `tasks/update` during task execution. - -## Capabilities, input contexts, and manual tasks - -`session.capabilities` and `controller.capabilities` expose semantic `TaskCapabilities`: - -```ts -interface TaskCapabilities { - inventory: "server-list" | "known-handles" | "unsupported"; - execution: boolean; - cancellation: boolean; - inputResponses: boolean; - requestedRetention: boolean; +try { + await serve(); +} finally { + receiver.close(); } ``` -Input handlers receive `{ scope: "request" | "task", delivery: "peer-request" | "request-retry" | "task-update", inputId?, taskId?, applicationContext, signal? }`. `request-retry` identifies an ordinary result continuation; `task-update` identifies task-status input. No protocol lifetime names are exposed. `createApplicationInputHandler({ elicitation, sampling, roots })` provides exhaustive, result-preserving routing to kind-specific callbacks. Before a task-scoped callback runs, the package installs standard related-task metadata while preserving existing metadata; request-scoped inputs are unchanged. - -Use `session.task(taskId)` for non-owning manual operations. `snapshot()` returns `TaskView`; `result()` returns `TaskOutcome`; `cancel()` is semantic; `update()` and `updateJson()` are available when `capabilities.inputResponses` is true. - -```ts -import { taskId } from "@modelcontextprotocol/ext-tasks/core"; - -const controller = session.task(taskId(currentSessionTaskId), { - headers: { "x-routing-key": routingKey }, -}); -const view = await controller.snapshot(); -const outcome = await controller.result(); - -const resumed = await session.resumeTask(persistedReference); -``` +The binding owns task state, retention, cancellation, lifecycle handlers, and status notifications. Merge `receiver.capabilities` into the host's advertised Tasks capability. -`execution.handle` is opaque and contains only `taskId` plus `operation`. Serialized references may retain endpoint and version information for persistence, but applications should store and pass them back without inspection. `resumeTask` is the owning cross-session recovery API. +## Documentation -Observation and mutation dispatches retry once only when a `DispatchError` is explicitly marked `retryable`. `session.close()` and execution `close()` are idempotent and resolving. +Start with the [TypeScript package guide](https://modelcontextprotocol.github.io/ext-tasks/typescript/) and [getting-started walkthrough](https://modelcontextprotocol.github.io/ext-tasks/typescript/getting-started.html). Then choose the task you need: -## Generation-neutral migration +- [Migrate an existing MCP SDK client](https://modelcontextprotocol.github.io/ext-tasks/typescript/migrating-from-the-sdk.html) +- [Execute and control tools](https://modelcontextprotocol.github.io/ext-tasks/typescript/client/execution.html) +- [Handle application input and recover tasks](https://modelcontextprotocol.github.io/ext-tasks/typescript/client/input-and-recovery.html) +- [Bind a 2025-11-25 Tasks receiver](https://modelcontextprotocol.github.io/ext-tasks/typescript/receiver.html) +- [Integrate custom adapters or schemas](https://modelcontextprotocol.github.io/ext-tasks/typescript/adapters-and-schemas.html) +- [Troubleshoot setup and lifecycle failures](https://modelcontextprotocol.github.io/ext-tasks/typescript/troubleshooting.html) -- Replace `toolDeclarationV1(rawTool)` / `toolDeclarationV2(rawTool)` with `toolDeclaration(neutralDeclaration)`. -- Replace `preferTask` and `taskTtl` with `task: { preference, retentionMs }`. -- Replace raw/rejecting execution results with `TaskOutcome`: inspect `outcome.status` and then `result` or `error`. -- Replace generation-tagged snapshots with `TaskView`, and snapshot callbacks with `onEvent`. -- Replace `taskGeneration` and controller `generation` reads with semantic `capabilities`. -- Replace `lifetime`, `inputKey`, and generation-bearing input context reads with `scope`, `delivery`, and `inputId`. -- Keep generated wire schemas and advanced generated types imported explicitly from `/core/v1` or `/core/v2`. -- Replace manual `createSessionPortFromClient` + `withTasks` composition with `createTaskSessionFromClient(client, { endpointId, ...options })` when the session should own adapter disposal; keep the low-level APIs only for custom ownership. +For normative wire behavior, use the [MCP Tasks specification](https://modelcontextprotocol.github.io/ext-tasks/specification/2026-07-28/tasks.html). -The public package subpaths are: +## Public entry points -- `@modelcontextprotocol/ext-tasks/core` -- `@modelcontextprotocol/ext-tasks/core/v1` -- `@modelcontextprotocol/ext-tasks/core/v2` -- `@modelcontextprotocol/ext-tasks/client` +- `@modelcontextprotocol/ext-tasks/client` — requester sessions, execution, input routing, recovery, and SDK adapters +- `@modelcontextprotocol/ext-tasks/receiver` — 2025-11-25 Tasks receiver binding +- `@modelcontextprotocol/ext-tasks/core` — generation-neutral JSON, codecs, errors, and identifiers +- `@modelcontextprotocol/ext-tasks/core/v1` — generated 2025-11-25 Tasks schemas and wire types +- `@modelcontextprotocol/ext-tasks/core/v2` — generated Tasks V2 schemas and wire types -Source is emitted as ESM JavaScript, TypeScript declarations, and source maps in `dist/`. +Source is emitted as ESM JavaScript, TypeScript declarations, and source maps in `dist`. diff --git a/packages/ext-tasks/src/client/api.ts b/packages/ext-tasks/src/client/api.ts index 0e3de22..de3c2d6 100644 --- a/packages/ext-tasks/src/client/api.ts +++ b/packages/ext-tasks/src/client/api.ts @@ -325,18 +325,6 @@ export interface ToolExecutionSettlement { readonly lastTask: TaskView | undefined; } -export interface CallToolAndSettleOptions - extends - ToolCallOptions, - ToolExecutionSettleOptions {} - -/** Fully-owned tool-call lifecycle result. */ -export interface CallToolAndSettleResult< - TResult, -> extends ToolExecutionSettlement { - readonly handle: TaskHandle | undefined; -} - export interface ToolExecutionCommon { readonly applicationContext: TApplicationContext; readonly declaration: ToolDeclaration | undefined; @@ -369,6 +357,10 @@ export type ToolExecution = readonly kind: "task"; readonly handle: TaskHandle; serializeReference(): SerializedTaskReference; + /** Persists a resumable reference, then releases local ownership. */ + handoff( + persist: (reference: SerializedTaskReference) => void | Promise, + ): Promise; }); export class TaskUpdatesAlreadyAcquiredError extends Error { @@ -502,11 +494,6 @@ export interface TaskEnabledSession { params?: Readonly>, options?: ToolCallOptions, ): Promise>; - callToolAndSettle( - name: string, - params?: Readonly>, - options?: CallToolAndSettleOptions, - ): Promise>; resumeTask( reference: SerializedTaskReference, options?: TaskRecoveryOptions, diff --git a/packages/ext-tasks/src/client/client-adapter.test.ts b/packages/ext-tasks/src/client/client-adapter.test.ts index 5b9ff1c..8bb3750 100644 --- a/packages/ext-tasks/src/client/client-adapter.test.ts +++ b/packages/ext-tasks/src/client/client-adapter.test.ts @@ -14,6 +14,10 @@ import { toolDeclarationFromMcpTool, withTasks, } from "./index.js"; +import type { + ClientSessionPortOptions, + CreateTaskSessionFromClientOptions, +} from "./sdk-client-adapter.js"; import { ClientSessionPort } from "./sdk-client-adapter.js"; const client = () => new Client({ name: "test", version: "1" }); @@ -37,6 +41,37 @@ const v2RequestFraming = { } as const; describe("Client adapter", () => { + it("requires V2 raw dispatch and framing together at the type boundary", () => { + const withoutRaw: ClientSessionPortOptions = {}; + const withRaw: ClientSessionPortOptions = { + rawDispatch: vi.fn(), + v2RequestFraming, + }; + // @ts-expect-error -- raw dispatch without framing is not a valid adapter configuration. + const missingFraming: ClientSessionPortOptions = { rawDispatch: vi.fn() }; + // @ts-expect-error -- framing without raw dispatch is not a valid adapter configuration. + const missingDispatch: ClientSessionPortOptions = { v2RequestFraming }; + // @ts-expect-error -- the owned session factory also requires framing with raw dispatch. + const ownedMissingFraming: CreateTaskSessionFromClientOptions = { + endpointId: "owned", + rawDispatch: vi.fn(), + }; + // @ts-expect-error -- the owned session factory also requires raw dispatch with framing. + const ownedMissingDispatch: CreateTaskSessionFromClientOptions = { + endpointId: "owned", + v2RequestFraming, + }; + + expect([ + withoutRaw, + withRaw, + missingFraming, + missingDispatch, + ownedMissingFraming, + ownedMissingDispatch, + ]).toHaveLength(6); + }); + it("dispatches with an explicit schema and signal, preserving full protocol errors", async () => { const sdk = client(); const request = vi.spyOn(sdk, "request"); @@ -403,6 +438,46 @@ describe("Client adapter", () => { disposeError(); }); + it("preserves SDK input handlers alongside the Tasks fallback", () => { + class InspectableClient extends Client { + requestHandler(method: string): unknown { + return this._getRequestHandler(method); + } + } + + const sdk = new InspectableClient( + { name: "test", version: "1" }, + { + capabilities: { + elicitation: { form: {} }, + sampling: {}, + }, + }, + ); + sdk.setRequestHandler("elicitation/create", () => ({ + action: "accept", + })); + sdk.setRequestHandler("sampling/createMessage", () => ({ + model: "test-model", + role: "assistant", + content: { type: "text", text: "sampled" }, + })); + const elicitationHandler = sdk.requestHandler("elicitation/create"); + const samplingHandler = sdk.requestHandler("sampling/createMessage"); + + const port = createSessionPortFromClient(sdk, "coexisting-input"); + + expect(sdk.requestHandler("elicitation/create")).toBe(elicitationHandler); + expect(sdk.requestHandler("sampling/createMessage")).toBe(samplingHandler); + expect(sdk.fallbackRequestHandler).toBeTypeOf("function"); + + port[Symbol.dispose](); + + expect(sdk.requestHandler("elicitation/create")).toBe(elicitationHandler); + expect(sdk.requestHandler("sampling/createMessage")).toBe(samplingHandler); + expect(sdk.fallbackRequestHandler).toBeUndefined(); + }); + it("chains prior fallbacks, forwards notifications, invalidates on close, and cleans up", async () => { const sdk = client(); const priorRequest = vi.fn(() => Promise.resolve({ prior: true })); diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts index f57b6e0..98b0b0a 100644 --- a/packages/ext-tasks/src/client/execution.ts +++ b/packages/ext-tasks/src/client/execution.ts @@ -266,6 +266,13 @@ export class TaskExecution< return { endpointId: this.endpointId, ...this.internalHandle }; } + async handoff( + persist: (reference: SerializedTaskReference) => void | Promise, + ): Promise { + await persist(this.serializeReference()); + await this.detach(); + } + onNotification(snapshot: InternalTaskSnapshot): void { if (this.closed || snapshot.generation !== this.internalHandle.generation) return; diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts index c4ae842..7a59d5e 100644 --- a/packages/ext-tasks/src/client/index.ts +++ b/packages/ext-tasks/src/client/index.ts @@ -20,8 +20,6 @@ export { } from "./api.js"; export { withRelatedTaskMetadata } from "./api.js"; export type { - CallToolAndSettleOptions, - CallToolAndSettleResult, ApplicationCreateMessageResult, ApplicationElicitResult, ApplicationInputHandler, diff --git a/packages/ext-tasks/src/client/sdk-client-adapter.ts b/packages/ext-tasks/src/client/sdk-client-adapter.ts index b7f3e68..6fb85fc 100644 --- a/packages/ext-tasks/src/client/sdk-client-adapter.ts +++ b/packages/ext-tasks/src/client/sdk-client-adapter.ts @@ -98,20 +98,30 @@ export interface V2RequestFraming { readonly clientCapabilities: Readonly>; } -/** Options for adapting an SDK Client. */ -export interface ClientSessionPortOptions { - readonly rawDispatch?: RawClientDispatch; - /** Required with rawDispatch for V2; copied and deeply frozen at creation. */ - readonly v2RequestFraming?: V2RequestFraming; +/** Options for adapting an SDK Client without a V2 raw request path. */ +interface ClientSessionPortWithoutRawDispatch { + readonly rawDispatch?: undefined; + readonly v2RequestFraming?: undefined; } -/** Options for creating an owned task-enabled session from an MCP SDK Client. */ -export interface CreateTaskSessionFromClientOptions - extends WithTasksOptions, ClientSessionPortOptions { - /** Opaque stable identity used to scope serialized task references. */ - readonly endpointId: string; +/** Options for adapting an SDK Client with the complete V2 raw request path. */ +interface ClientSessionPortWithRawDispatch { + readonly rawDispatch: RawClientDispatch; + readonly v2RequestFraming: V2RequestFraming; } +/** Options for adapting an SDK Client. V2 raw dispatch and framing are supplied together. */ +export type ClientSessionPortOptions = + ClientSessionPortWithoutRawDispatch | ClientSessionPortWithRawDispatch; + +/** Options for creating an owned task-enabled session from an MCP SDK Client. */ +export type CreateTaskSessionFromClientOptions = + WithTasksOptions & + ClientSessionPortOptions & { + /** Opaque stable identity used to scope serialized task references. */ + readonly endpointId: string; + }; + function requiresRawDispatch( capabilities: SessionTaskCapabilities, request: JsonValue, @@ -473,12 +483,19 @@ export function createTaskSessionFromClient( client: Client, options: CreateTaskSessionFromClientOptions, ): TaskEnabledSession { - const { endpointId, rawDispatch, v2RequestFraming, ...sessionOptions } = - options; - const port = createSessionPortFromClient(client, endpointId, { - rawDispatch, - v2RequestFraming, - }); + const adapterOptions: ClientSessionPortOptions = + options.rawDispatch === undefined + ? {} + : { + rawDispatch: options.rawDispatch, + v2RequestFraming: options.v2RequestFraming, + }; + const sessionOptions: WithTasksOptions = options; + const port = createSessionPortFromClient( + client, + options.endpointId, + adapterOptions, + ); try { return withOwnedTasks(port, sessionOptions, () => { port[Symbol.dispose](); diff --git a/packages/ext-tasks/src/client/session-facade.test.ts b/packages/ext-tasks/src/client/session-facade.test.ts index d0414cf..99be0aa 100644 --- a/packages/ext-tasks/src/client/session-facade.test.ts +++ b/packages/ext-tasks/src/client/session-facade.test.ts @@ -12,17 +12,17 @@ const task = (id: string, status = "working") => ({ }); describe("task session facade", () => { - it("owns immediate call registration and settlement", async () => { + it("settles an immediate execution", async () => { const port = new FakePort(); port.response = { kind: "result", result: { content: [] } }; const session = withTasks(port, { tools: { currentTool: () => undefined }, }); - await expect(session.callToolAndSettle("echo")).resolves.toEqual({ + const execution = await session.callTool("echo"); + await expect(execution.settle()).resolves.toEqual({ outcome: { status: "completed", result: { content: [] } }, lastTask: undefined, - handle: undefined, }); await session.close(); }); @@ -84,11 +84,12 @@ describe("task session facade", () => { tools: { currentTool: () => undefined }, }); - const settled = await session.callToolAndSettle("echo"); - expect(settled.handle).toEqual({ + const execution = await session.callTool("echo"); + expect(execution.handle).toEqual({ taskId: "owned", operation: "tools/call", }); + const settled = await execution.settle(); expect(settled.outcome.status).toBe("completed"); expect(reads).toBeGreaterThan(0); await session.close(); diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index 5bd15d8..6ea3838 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -29,8 +29,6 @@ import { import type { TaskController, TaskControllerOptions } from "./api.js"; import type { TaskRecoveryOptions, TaskSessionEndpointId } from "./api.js"; import type { - CallToolAndSettleOptions, - CallToolAndSettleResult, SerializedTaskReference, TaskEnabledSession, TaskListPage, @@ -270,21 +268,6 @@ class PortTaskEnabledSession< ).cancel(signal); } - async callToolAndSettle( - name: string, - params?: Readonly>, - options: CallToolAndSettleOptions = {}, - ): Promise> { - const { onEvent, close, ...callOptions } = options; - const execution = await this.callTool(name, params, callOptions); - const settlement = await execution.settle({ - signal: options.signal, - close, - onEvent, - }); - return { ...settlement, handle: execution.handle }; - } - async callTool( name: string, params?: Readonly>, diff --git a/packages/ext-tasks/src/client/task-resumption.test.ts b/packages/ext-tasks/src/client/task-resumption.test.ts index 962c2f1..9bb4c39 100644 --- a/packages/ext-tasks/src/client/task-resumption.test.ts +++ b/packages/ext-tasks/src/client/task-resumption.test.ts @@ -1,5 +1,5 @@ import fc from "fast-check"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { TaskId } from "../core/index.js"; import { DispatchError, @@ -32,6 +32,56 @@ describe("task reference resumption", () => { await session.close(); }); + it("persists a task reference before detaching during handoff", async () => { + const port = new FakePort( + { generation: "v2", capabilities: {} }, + "handoff-endpoint", + ); + port.dispatchHandler = async (request, options) => { + const method = expectRecord(request).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "handoff-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + return new Promise((_resolve, reject) => + options?.signal?.addEventListener( + "abort", + () => { + reject(asError(options.signal?.reason)); + }, + { once: true }, + ), + ); + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("handoff"); + expect(execution.kind).toBe("task"); + if (execution.kind !== "task") throw new Error("expected task"); + const detach = vi.spyOn(execution, "detach"); + const persistenceError = new Error("storage unavailable"); + + await expect( + execution.handoff(() => Promise.reject(persistenceError)), + ).rejects.toBe(persistenceError); + expect(detach).not.toHaveBeenCalled(); + + const persist = vi.fn().mockResolvedValue(undefined); + await execution.handoff(persist); + expect(persist).toHaveBeenCalledWith(execution.serializeReference()); + expect(detach).toHaveBeenCalledOnce(); + await session.close(); + }); + it("rejects endpoint, generation, and operation mismatches before dispatch", async () => { await fc.assert( fc.asyncProperty( diff --git a/typescript/adapters-and-schemas.md b/typescript/adapters-and-schemas.md new file mode 100644 index 0000000..b86ebbb --- /dev/null +++ b/typescript/adapters-and-schemas.md @@ -0,0 +1,131 @@ +# Integrate adapters and schemas + +Use these APIs for V2 raw dispatch, custom transports, tool declarations, runtime codecs, and versioned wire schemas. + +## Supply the V2 request path + +For Tasks V2, pass the host's raw request coordinator and the values used during SDK client initialization: + +```ts +const session = createTaskSessionFromClient(client, { + endpointId, + rawDispatch: hostRequestCoordinator.dispatch, + v2RequestFraming: { + protocolVersion, + clientInfo, + clientCapabilities, + }, +}); +``` + +`rawDispatch` must use the host coordinator that owns request IDs and response matching. + +The adapter adds the framing values to V2 task requests. + +## Use a custom connected port + +When the host already has a transport abstraction, adapt it to `ConnectedMcpSessionPort` and pass it to `withTasks()`: + +```ts +import { + withTasks, + type ConnectedMcpSessionPort, +} from "@modelcontextprotocol/ext-tasks/client"; + +const port: ConnectedMcpSessionPort = { + endpointId, + taskCapabilities: { generation: "v1", capabilities: {} }, + dispatch: (request, options) => transport.dispatch(request, options), + onServerRequest: (handler) => transport.onRequest(handler), + onNotification: (listener) => transport.onNotification(listener), + onInvalidated: (listener) => transport.onClose(listener), + get invalidated() { + return transport.closed; + }, +}; + +const session = withTasks(port); +``` + +`withTasks()` borrows the port. Close the session before disposing the port. For SDK clients, `createSessionPortFromClient()` exposes the lower-level adapter used by `createTaskSessionFromClient()`. + +## Provide tool declarations + +Supply a declaration when the host owns tool metadata or overrides task support: + +```ts +import { toolDeclaration } from "@modelcontextprotocol/ext-tasks/client"; + +const generateReport = toolDeclaration({ + name: "generate_report", + description: "Generate a report", + inputSchema: { + type: "object", + properties: { format: { enum: ["pdf", "html"] } }, + }, + taskSupport: "required", +}); + +const session = withTasks(port, { + tools: { + currentTool(name) { + return name === generateReport.name ? generateReport : undefined; + }, + }, +}); +``` + +The session projects the generation-neutral declaration to the negotiated wire shape. An execution-scoped declaration overrides the session provider. + +Convert SDK `Tool` values with `toolDeclarationFromMcpTool(mcpTool)`. + +## Decode custom results + +Adapt a synchronous Standard Schema V1 validator when a tool returns an application-specific shape: + +```ts +import { runtimeCodecFromStandardSchema } from "@modelcontextprotocol/ext-tasks/core"; + +const reportCodec = runtimeCodecFromStandardSchema<{ reportUrl: string }>( + reportSchema, +); + +const execution = await session.callTool("generate_report", undefined, { + resultCodec: reportCodec, +}); +const { outcome } = await execution.settle(); +``` + +The adapter accepts synchronous Standard Schema V1 validators. Validation issues become `ProtocolDecodeError` details; a thrown validator is retained as the error cause. + +## Normalize host data as JSON + +Use `toJsonValue()` when arbitrary JavaScript crosses into APIs typed as `JsonValue`: + +```ts +import { toJsonValue } from "@modelcontextprotocol/ext-tasks/core"; + +const metadata = toJsonValue({ + traceId, + optional: undefined, + createdAt: new Date(), +}); +``` + +Normalization follows a `JSON.stringify()`/`JSON.parse()` round trip. Unsupported values throw. + +## Package entry points + +Use the narrowest package entry point: + +| Import | Use it for | +| ------------------------------------------ | ---------------------------------------------------------------------------------- | +| `@modelcontextprotocol/ext-tasks/client` | Sessions, execution, ports, SDK adapters, and neutral tool declarations. | +| `@modelcontextprotocol/ext-tasks/receiver` | 2025-11-25 Tasks receiver binding and callback options. | +| `@modelcontextprotocol/ext-tasks/core` | Generation-neutral JSON, identifiers, runtime codecs, and decode errors. | +| `@modelcontextprotocol/ext-tasks/core/v1` | 2025-11-25 Tasks schemas and wire types. | +| `@modelcontextprotocol/ext-tasks/core/v2` | V2 schemas and types for code intentionally reading or writing the V2 wire format. | + +## Next steps + +See [Troubleshooting](./troubleshooting.md) for adapter and framing failures, or use the [Tasks specification](/specification/2026-07-28/tasks) for normative wire behavior. diff --git a/typescript/client/execution.md b/typescript/client/execution.md new file mode 100644 index 0000000..1c292e2 --- /dev/null +++ b/typescript/client/execution.md @@ -0,0 +1,99 @@ +# Observe and control execution + +`session.callTool()` returns a `ToolExecution` for immediate and task-backed results. Choose the runtime policy after the server responds. + +## Settle and observe progress + +```ts +import { resultFromTaskOutcome } from "@modelcontextprotocol/ext-tasks/client"; + +const execution = await session.callTool("render-report", { + accountId: "acct-42", +}); + +const { outcome, lastTask } = await execution.settle({ + onEvent(event) { + if (event.type === "task") { + console.log(event.task.status, event.task.statusMessage); + } + }, +}); + +const report = resultFromTaskOutcome(outcome); +``` + +An immediate result may produce no task event. A task-backed call reports task snapshots as it progresses. `lastTask` contains the most recent snapshot. + +Task-backed executions also expose the task handle and handoff API: + +```ts +if (execution.kind === "task") { + console.log(execution.handle.taskId); + await execution.handoff((reference) => taskStore.save(reference)); +} +``` + +## Read the terminal outcome + +```ts +const outcome = await execution.result(); + +switch (outcome.status) { + case "completed": + console.log(outcome.result); + break; + case "failed": + console.error(outcome.error.message); + break; + case "cancelled": + console.log("The work was cancelled"); + break; +} +``` + +Switch on `outcome.status` when failed and cancelled states are application data. Use `resultFromTaskOutcome(outcome)` for result-or-throw handling. + +## Choose what happens when you stop waiting + +| Method | Effect | +| ---------- | ------------------------------------------------------------- | +| `cancel()` | Requests remote cancellation and releases local ownership. | +| `detach()` | Releases local ownership while the remote task keeps running. | +| `close()` | Releases ownership and best-effort cancels unfinished work. | + +Aborting a settlement or observation signal stops local waiting. Use `cancel()` to request remote cancellation. + +## Observe the update stream yourself + +A UI may need its own async stream instead of an `onEvent` callback: + +```ts +for await (const event of execution.updates()) { + if (event.type === "task") renderProgress(event.task); + if (event.type === "outcome") renderOutcome(event.outcome); +} +``` + +`updates()` has one consumer. A second acquisition throws `TaskUpdatesAlreadyAcquiredError`. Use `settle({ onEvent })` when a separate stream is unnecessary. + +## Set request context + +```ts +const execution = await session.callTool( + "render-report", + { accountId: "acct-42" }, + { + headers: { "x-trace-id": "trace-9" }, + requestTimeoutMs: 15_000, + signal: AbortSignal.timeout(60_000), + }, +); +``` + +`headers` and `requestTimeoutMs` apply to the initial call and every task follow-up request. The timeout is per request. `signal` bounds the caller's local lifecycle across the operation. + +For a task ID obtained elsewhere, pass the same request context when creating `session.task(taskId, options)`. Manual controllers and cross-session handoff are covered in [Application input and recovery](./input-and-recovery.md). + +## Next steps + +Continue with [application input and recovery](./input-and-recovery.md) when a tool can pause for input or outlive this connection. See [Troubleshooting](../troubleshooting.md) for ownership and cancellation failures. diff --git a/typescript/client/input-and-recovery.md b/typescript/client/input-and-recovery.md new file mode 100644 index 0000000..c3d2f75 --- /dev/null +++ b/typescript/client/input-and-recovery.md @@ -0,0 +1,142 @@ +# Handle application input and recover tasks + +A long-running tool may pause to ask your application for something: user approval, a model response, or a list of roots. Add one input handler to the session, and the package routes each request back to the execution that caused it. + +## Answer input requests + +```ts +import { + createApplicationInputHandler, + createTaskSessionFromClient, + type ResolvedInputExchangeContext, +} from "@modelcontextprotocol/ext-tasks/client"; + +type AppContext = { traceId: string }; + +const onInputRequest = createApplicationInputHandler({ + elicitation: async (_request, _context) => { + return { action: "accept", content: { approved: true } }; + }, + sampling: async (request, context) => { + return runModel(request.params, { signal: context.signal }); + }, + roots: async (_request, context) => { + return { roots: await findRoots(context.applicationContext.traceId) }; + }, +}); + +const session = createTaskSessionFromClient(client, { + endpointId, + onInputRequest, + onError(error) { + logger.error(error, "task input failed"); + }, +}); +``` + +`createApplicationInputHandler()` keeps the request and response types paired for each input kind. The callback context carries the application state and abort signal for the execution that owns the request. + +Pass that application state when you start the call: + +```ts +const execution = await session.callTool( + "prepare-release", + { version: "2.4.0" }, + { applicationContext: { traceId: "trace-9" } }, +); +``` + +## Distinguish request input from task input + +Every callback context tells you where the input belongs: + +```ts +function auditInput(context: ResolvedInputExchangeContext): void { + if (context.scope === "request") { + console.log("Input continues the active request", context.inputId); + } else { + console.log("Input belongs to task", context.taskId, context.inputId); + } +} +``` + +Request-scoped input continues the active tool call. Task-scoped input belongs to durable work that has already been created. `delivery` tells you whether the package will answer a peer request, retry the original request, or send a task update. + +Use `context.signal` for prompts, model calls, and root discovery. Cancellation, detachment, closure, or session invalidation aborts input work owned by that execution. + +If routing is ambiguous or a handler fails, the package returns a conservative protocol response and reports the failure through the session's `onError` callback. + +## Let a task outlive this connection + +When another process or a later session will continue the work, give the endpoint a stable identity: + +```ts +import { createTaskSessionEndpointId } from "@modelcontextprotocol/ext-tasks/client"; + +const endpointId = await createTaskSessionEndpointId("workspace-mcp", { + transport: "streamable-http", + url: serverUrl, + tenantId, +}); +``` + +Build the descriptor from the connection properties that identify the MCP server, such as its URL and tenant. + +Create the session with that identity, then hand off task-backed executions to durable storage: + +```ts +const session = createTaskSessionFromClient(client, { endpointId }); +const execution = await session.callTool("prepare-release", { + version: "2.4.0", +}); + +if (execution.kind === "task") { + await execution.handoff((reference) => taskStore.save(reference)); +} +``` + +`handoff()` saves the reference before detaching. If persistence fails, the execution remains active and `handoff()` can be retried. + +## Resume after reconnecting + +Create a session for the same endpoint and pass the stored reference to `resumeTask()`: + +```ts +const reference = await taskStore.load(); + +const recovered = await session.resumeTask(reference, { + applicationContext: { traceId: "recovered-trace" }, +}); + +const { outcome } = await recovered.settle({ + onEvent(event) { + if (event.type === "task") console.log(event.task.status); + }, +}); +``` + +The recovered execution exposes the same progress, input, and settlement APIs as a fresh call. + +If the result uses a custom runtime codec, pass the same codec to `resumeTask()` that you used for the original call. See [Adapters and schemas](../adapters-and-schemas.md). + +## Work with a known task ID + +Use a manual controller when the application already has a task ID: + +```ts +const task = session.task(taskId, { + headers: { "x-trace-id": "trace-9" }, + requestTimeoutMs: 15_000, +}); + +const snapshot = await task.snapshot(); +const outcome = await task.result({ resultCodec: reportResultCodec }); + +if (task.capabilities.cancellation) { + await task.cancel(); +} +``` + +## Next steps + +Return to [execution control](./execution.md), or see [Troubleshooting](../troubleshooting.md) for correlation, expiry, and recovery failures. diff --git a/typescript/getting-started.md b/typescript/getting-started.md new file mode 100644 index 0000000..71c33bd --- /dev/null +++ b/typescript/getting-started.md @@ -0,0 +1,41 @@ +# Add Tasks to an MCP client + +Start with a connected `Client` from `@modelcontextprotocol/client`. Create one task-enabled session for that connection: + +```ts +import { + createTaskSessionFromClient, + resultFromTaskOutcome, +} from "@modelcontextprotocol/ext-tasks/client"; + +const session = createTaskSessionFromClient(client, { + endpointId: serverId, +}); +``` + +`endpointId` is a stable identifier for the MCP server. It is used when task references are resumed on a later connection. + +Call tools through the session: + +```ts +try { + const execution = await session.callTool("generate_report", { + quarter: "Q2", + }); + const { outcome } = await execution.settle(); + + const result = resultFromTaskOutcome(outcome); +} finally { + await session.close(); +} +``` + +`callTool()` returns a `ToolExecution` for immediate and task-backed results. `settle()` waits for its terminal outcome. + +## Next steps + +- [Migrate an existing MCP SDK client](./migrating-from-the-sdk.md) for a piece-by-piece conversion that preserves ordinary elicitation, sampling, and MRTR handling. +- [Observe and control execution](./client/execution.md) for progress events, cancellation, timeouts, and handoff. +- [Handle application input and recover tasks](./client/input-and-recovery.md) for elicitation, sampling, roots, and reconnection. +- [Receive 2025-11-25 Tasks requests](./receiver.md) to handle task-backed sampling and elicitation. +- [Integrate adapters and schemas](./adapters-and-schemas.md) for V2 raw dispatch, custom transports, and wire types. diff --git a/typescript/index.md b/typescript/index.md new file mode 100644 index 0000000..d3e5b95 --- /dev/null +++ b/typescript/index.md @@ -0,0 +1,54 @@ +# TypeScript API + +`@modelcontextprotocol/ext-tasks` adds generation-agnostic Tasks requester support and 2025-11-25 Tasks receiver support to applications using the MCP TypeScript SDK v2. + +## Install + +```sh +npm install @modelcontextprotocol/ext-tasks +``` + +## Packages + +| Import | Purpose | +| ------------------------------------------ | ---------------------------------------------------------------- | +| `@modelcontextprotocol/ext-tasks/client` | Tool execution, progress, input handling, cancellation, recovery | +| `@modelcontextprotocol/ext-tasks/receiver` | 2025-11-25 Tasks sampling and elicitation receiver | +| `@modelcontextprotocol/ext-tasks/core` | Generation-neutral codecs, identifiers, and errors | +| `@modelcontextprotocol/ext-tasks/core/v1` | 2025-11-25 Tasks wire schemas and types | +| `@modelcontextprotocol/ext-tasks/core/v2` | Tasks V2 wire schemas and types | + +## Requester + +Given a connected SDK `Client`: + +```ts +const session = createTaskSessionFromClient(client, { endpointId: serverId }); + +const execution = await session.callTool("generate_report", { + format: "pdf", +}); +const { outcome } = await execution.settle(); + +const result = resultFromTaskOutcome(outcome); +``` + +See [Add Tasks to an MCP client](./getting-started.md), [migrate an existing SDK client](./migrating-from-the-sdk.md), [Execution](./client/execution.md), and [Input and recovery](./client/input-and-recovery.md). + +## 2025-11-25 receiver + +```ts +const receiver = bindTaskReceiver(client, { + methods: { "sampling/createMessage": true }, + sampling: async (request, { signal }) => + runSampling(request.params, { signal }), +}); +``` + +Advertise `receiver.capabilities` during client initialization. See [2025-11-25 receiver](./receiver.md). + +## Advanced integration + +See [Adapters and schemas](./adapters-and-schemas.md) for V2 raw dispatch, custom transports, tool declarations, runtime codecs, and versioned schemas. See [Troubleshooting](./troubleshooting.md) for setup and lifecycle errors. + +For wire behavior, use the [MCP Tasks specification](/specification/2026-07-28/tasks). diff --git a/typescript/migrating-from-the-sdk.md b/typescript/migrating-from-the-sdk.md new file mode 100644 index 0000000..b62e93a --- /dev/null +++ b/typescript/migrating-from-the-sdk.md @@ -0,0 +1,223 @@ +# Migrate an MCP SDK client + +This guide starts with an MCP TypeScript SDK client that calls tools and handles elicitation and sampling. Each step keeps that behavior while adding Tasks. + +## Before: use the base SDK + +The base client advertises input capabilities, installs request handlers, connects, and calls tools directly: + +```ts +import { Client } from "@modelcontextprotocol/client"; + +const client = new Client( + { name: "reporting-client", version: "1.0.0" }, + { + capabilities: { + elicitation: { form: {} }, + sampling: {}, + }, + }, +); + +client.setRequestHandler("elicitation/create", async (request) => { + return promptUser(request.params); +}); + +client.setRequestHandler("sampling/createMessage", async (request) => { + return runModel(request.params); +}); + +await client.connect(transport); + +const result = await client.callTool({ + name: "generate_report", + arguments: { quarter: "Q2" }, +}); +``` + +On a 2025-11-25 connection, these handlers answer server-to-client requests. On a 2026-07-28 connection, the SDK also uses them for multi-round-trip requests (MRTR): it fulfills `input_required` results and retries the original call. + +## 1. Install the extension + +```sh +npm install @modelcontextprotocol/ext-tasks +``` + +Add the client imports: + +```ts +import { + createApplicationInputHandler, + createTaskSessionFromClient, + resultFromTaskOutcome, + type ApplicationCreateMessageResult, + type ApplicationElicitResult, +} from "@modelcontextprotocol/ext-tasks/client"; +import type { JsonValue } from "@modelcontextprotocol/ext-tasks/core"; +``` + +Keep using the SDK `Client` and transport. The extension wraps the connected client; it does not replace connection setup or capability negotiation. + +## 2. Share the application input functions + +Move each existing handler body into a named application function, then keep the SDK handlers as thin wrappers. The Tasks session will call the same functions: + +```ts +async function handleElicitation( + params: Readonly>, +): Promise { + return promptUser(params); +} + +async function handleSampling( + params: Readonly>, +): Promise { + return runModel(params); +} + +client.setRequestHandler("elicitation/create", async (request) => { + return handleElicitation(request.params); +}); + +client.setRequestHandler("sampling/createMessage", async (request) => { + return handleSampling(request.params); +}); +``` + +The SDK handlers continue to cover ordinary peer requests and SDK MRTR. + +## 3. Add the Tasks input handler + +Create one generation-agnostic handler for input delivered through a Tasks execution: + +```ts +const taskInputHandler = createApplicationInputHandler({ + elicitation: async (request) => { + return handleElicitation(request.params); + }, + sampling: async (request) => { + return handleSampling(request.params); + }, + roots: async () => ({ roots: [] }), +}); +``` + +This handler covers Tasks-extension MRTR and input attached to a durable task. + +## 4. Create a Tasks session after connecting + +Keep the existing connection, then create one session for it. This is the complete setup for 2025-11-25 Tasks: + +```ts +await client.connect(transport); + +const session = createTaskSessionFromClient(client, { + endpointId: "production-reports", + onInputRequest: taskInputHandler, +}); +``` + +`endpointId` identifies the MCP endpoint when a task is resumed on a later connection. Recreate the session whenever the underlying SDK client connection is replaced. + +A 2026-07-28 Tasks session requires both `rawDispatch` and `v2RequestFraming`; omitting either makes session creation fail. Replace the factory call above with: + +```ts +const session = createTaskSessionFromClient(client, { + endpointId: "production-reports", + onInputRequest: taskInputHandler, + rawDispatch: hostRequestCoordinator.dispatch, + v2RequestFraming: { + protocolVersion, + clientInfo, + clientCapabilities, + }, +}); +``` + +Both values come from the host request coordinator and must be supplied together. See [Supply the V2 request path](./adapters-and-schemas.md#supply-the-v2-request-path). + +## 5. Replace tool calls + +Replace `client.callTool()` with `session.callTool()`, then settle the returned execution: + +```ts +const execution = await session.callTool("generate_report", { + quarter: "Q2", +}); + +const { outcome } = await execution.settle(); +const result = resultFromTaskOutcome(outcome); +``` + +The call now handles immediate and task-backed results through the same path. You can inspect `execution.kind`, observe progress, cancel, or hand off a task before settlement. + +Close the Tasks session before closing or replacing the SDK client: + +```ts +await session.close(); +await client.close(); +``` + +## How input paths coexist + +The base SDK continues to use `setRequestHandler()` for its 2026-07-28 MRTR flow. Calls made through the Tasks session use `onInputRequest` for extension MRTR and durable-task input: + +| Input path | Handler | +| ----------------------------------------------------------- | ---------------------------------- | +| 2025-11-25 server-to-client elicitation or sampling request | SDK `setRequestHandler()` callback | +| Base SDK 2026-07-28 `input_required` auto-fulfilment | SDK `setRequestHandler()` callback | +| Tasks extension MRTR `input_required` | `onInputRequest` callback | +| Input attached to a durable Tasks execution | `onInputRequest` callback | + +Both registrations call the same application functions, so user prompts and model execution stay consistent. + +### 2025-11-25 peer-request semantics + +Keep the SDK handlers for 2025-11-25 peer requests. The SDK dispatches them by method name before the Tasks adapter's fallback, so an ordinary request and a task-associated request use the same handler. + +The handler can inspect related-task metadata in `request.params._meta`, but it does not receive ext-tasks' `ResolvedInputExchangeContext` or the call's `applicationContext`. Keep the shared application functions as the source of truth for both registrations. + +## Complete migrated 2025-11-25 shape + +```ts +const client = new Client( + { name: "reporting-client", version: "1.0.0" }, + { + capabilities: { + elicitation: { form: {} }, + sampling: {}, + }, + }, +); + +client.setRequestHandler("elicitation/create", async (request) => + handleElicitation(request.params), +); +client.setRequestHandler("sampling/createMessage", async (request) => + handleSampling(request.params), +); + +await client.connect(transport); + +const session = createTaskSessionFromClient(client, { + endpointId: "production-reports", + onInputRequest: taskInputHandler, +}); + +try { + const execution = await session.callTool("generate_report", { + quarter: "Q2", + }); + const { outcome } = await execution.settle(); + const result = resultFromTaskOutcome(outcome); +} finally { + await session.close(); + await client.close(); +} +``` + +For 2026-07-28 Tasks, use the paired V2 factory options shown in Step 4. + +## Next steps + +See [Execution](./client/execution.md) for progress, cancellation, and handoff. See [Input and recovery](./client/input-and-recovery.md) for callback context, task resumption, and manual task controllers. diff --git a/typescript/receiver.md b/typescript/receiver.md new file mode 100644 index 0000000..2760e8e --- /dev/null +++ b/typescript/receiver.md @@ -0,0 +1,131 @@ +# Receive 2025-11-25 Tasks requests + +`bindTaskReceiver()` adds 2025-11-25 Tasks lifecycle handling for incoming sampling and elicitation requests. + +## Bind the receiver + +```ts +import { bindTaskReceiver } from "@modelcontextprotocol/ext-tasks/receiver"; + +const receiver = bindTaskReceiver(client, { + methods: { "sampling/createMessage": true }, + sampling: async (request, { signal }) => { + return runSampling(request.params, { signal }); + }, +}); + +// Include this value under the client's advertised Tasks capability. +const tasks = receiver.capabilities; + +try { + await runClient(client, { capabilities: { tasks } }); +} finally { + receiver.close(); +} +``` + +The binding owns the task lifecycle. Your callback owns the application work and receives the task ID and an `AbortSignal`. + +Add `receiver.capabilities` to the host's advertised Tasks capability. The exact SDK hook depends on how the host initializes its client. + +The supported task-backed request methods are `sampling/createMessage` and `elicitation/create`. + +## Add the methods your application supports + +Enable the request methods and provide their callbacks: + +```ts +const receiver = bindTaskReceiver(client, { + methods: { + "sampling/createMessage": true, + "elicitation/create": true, + }, + sampling: async (request, context) => + runSampling(request.params, { signal: context.signal }), + elicitation: async (request, context) => + askUser(request.params, { signal: context.signal }), +}); + +console.log(receiver.capabilities); +// { +// list: {}, +// cancel: {}, +// requests: { +// sampling: { createMessage: {} }, +// elicitation: { create: {} }, +// }, +// } +``` + +Each enabled method requires its callback. `receiver.capabilities` reflects the enabled methods plus task listing and cancellation. + +## What happens after a request arrives + +Sampling and elicitation tasks start in `input_required`. When the callback settles: + +- a returned result moves the task to `completed` and becomes available through `tasks/result`; +- a thrown error moves the task to `failed` and rejects `tasks/result`; +- each transition emits `notifications/tasks/status` without delaying the transition. + +## Set production limits + +Configure retention, polling hints, pagination, and capacity as needed: + +```ts +const receiver = bindTaskReceiver(client, { + methods: { "elicitation/create": true }, + elicitation: async (request, { signal }) => + askUser(request.params, { signal }), + ttlMs: () => 60_000, + pollIntervalMs: 1_000, + pageSize: 50, + maxTasks: 500, +}); +``` + +| Option | Behavior | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ttlMs` | Total lifetime from task creation. Use a non-negative integer, `null`, or a function returning either. The function is sampled once per task. The default is `null` (no time expiry). | +| `pollIntervalMs` | Non-negative polling hint. `null` or omission leaves the hint off the task. | +| `pageSize` | Maximum records in one `tasks/list` page. Must be a positive integer; defaults to `100`. | +| `maxTasks` | Maximum retained records, including pending work. Must be a positive integer; defaults to `1,000`. | +| `createTaskId` | Optional ID factory. Otherwise the binding uses `crypto.randomUUID()`. | + +A finite `ttlMs` expires from task creation. Reaching `maxTasks` rejects new task creation. + +## Handle cancellation and background errors + +`tasks/cancel` marks the task cancelled and aborts the callback signal. + +Report failures that happen outside the request/response path with `onError`: + +```ts +const receiver = bindTaskReceiver(client, { + methods: { "sampling/createMessage": true }, + sampling: async (request, { signal }) => + runSampling(request.params, { signal }), + onError(error, context) { + logger.error({ error, ...context }, "task receiver background error"); + }, +}); +``` + +`context` identifies the method and task, and marks callback failures that arrive after cancellation, expiry, or close. + +## Clean up + +Close the binding when the client role ends: + +```ts +try { + await serve(); +} finally { + receiver.close(); +} +``` + +`close()` aborts pending callbacks and releases retained task state. + +## Next steps + +See [Troubleshooting](./troubleshooting.md) for capacity, expiry, and cancellation failures. If you need a custom client adapter or wire-level schemas, continue to [Adapters and schemas](./adapters-and-schemas.md). diff --git a/typescript/troubleshooting.md b/typescript/troubleshooting.md new file mode 100644 index 0000000..3bbe5eb --- /dev/null +++ b/typescript/troubleshooting.md @@ -0,0 +1,113 @@ +# Troubleshooting + +## Session created before connection + +Create the Tasks session after the SDK client connects: + +```ts +await client.connect(transport); +const session = createTaskSessionFromClient(client, { endpointId }); +``` + +Create a new Tasks session when replacing or reconnecting the SDK client. + +## Adapter already active for this Client + +One SDK `Client` can have one active Tasks session. Close the current session before creating another: + +```ts +await session.close(); +const nextSession = createTaskSessionFromClient(client, { endpointId }); +``` + +## V2 requires raw dispatch + +Pass the host request coordinator and initialization framing together: + +```ts +const session = createTaskSessionFromClient(client, { + endpointId, + rawDispatch: hostRequestCoordinator.dispatch, + v2RequestFraming: { protocolVersion, clientInfo, clientCapabilities }, +}); +``` + +See [Adapters and schemas](./adapters-and-schemas.md). + +## Failed and cancelled outcomes do not throw + +`result()` and `settle()` return terminal outcomes: + +```ts +const { outcome } = await execution.settle(); + +if (outcome.status === "completed") useResult(outcome.result); +if (outcome.status === "failed") reportTaskFailure(outcome.error); +if (outcome.status === "cancelled") reportCancellation(); +``` + +Use `resultFromTaskOutcome(outcome)` for result-or-throw handling. + +## Input callback context is missing + +Pass application context with the call: + +```ts +await session.callTool("review", input, { + applicationContext: { requestId: "request-42" }, +}); +``` + +Read it from `context.applicationContext` in the input callback. See [Input and recovery](./client/input-and-recovery.md). + +## Task updates already acquired + +`execution.updates()` has one consumer. Fan out events inside the application, or use `onEvent` with `execution.settle()`. + +## Recovery fails + +Create the session with the same `endpointId` used by the source execution, then resume the stored reference: + +```ts +const recovered = await session.resumeTask(await taskStore.load()); +``` + +An unknown task has expired or was removed by the server. `TaskRecoveryOwnershipError` indicates an endpoint, generation, operation, or local ownership mismatch. + +Use `execution.handoff((reference) => taskStore.save(reference))` when transferring a live task to durable storage. + +## Cancellation and detachment + +See [Cancellation and detachment](./client/execution.md#choose-what-happens-when-you-stop-waiting). An `AbortSignal` stops local waiting; `cancel()` requests remote cancellation. + +## Task execution unsupported + +Check `session.capabilities.execution` and the tool declaration. Use `task.preference: "allow"` to accept an immediate result, or `"require"` to require task-backed execution. + +## 2025-11-25 receiver capacity or expiry + +Configure `maxTasks` and `ttlMs`: + +```ts +const receiver = bindTaskReceiver(client, { + methods: { "sampling/createMessage": true }, + sampling: handleSampling, + maxTasks: 100, + ttlMs: 60_000, +}); +``` + +At capacity, new task creation is rejected. After TTL expiry, the task is no longer retained. + +## Request timeout missing from follow-up requests + +Set request context on the tool call: + +```ts +const execution = await session.callTool("generate_report", input, { + requestTimeoutMs: 15_000, + headers: { authorization: `Bearer ${token}` }, +}); +``` + +The session applies these options to the initial call and task follow-up requests. From f0b806073839e038f69718c25ea8ceee2dd07e7f Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Wed, 9 Sep 2026 13:48:57 -0700 Subject: [PATCH 24/33] fix(ci): don't use offline install --- packages/ext-tasks/scripts/check-exports.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ext-tasks/scripts/check-exports.mjs b/packages/ext-tasks/scripts/check-exports.mjs index ccb258b..f9e1ccc 100644 --- a/packages/ext-tasks/scripts/check-exports.mjs +++ b/packages/ext-tasks/scripts/check-exports.mjs @@ -383,7 +383,6 @@ async function checkPackedContract() { process.platform === "win32" ? "npm.cmd" : "npm", [ "install", - "--offline", "--ignore-scripts", "--dry-run=false", "--no-audit", From ece665a65fc0066c1937f2182d04b83ffdc9f2ed Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Wed, 9 Sep 2026 18:07:08 -0700 Subject: [PATCH 25/33] fix: address Copilot comments --- packages/ext-tasks/src/client/session.ts | 22 +++- .../ext-tasks/src/client/task-protocol-v2.ts | 34 +++-- .../src/client/v1-input-task.test.ts | 4 + .../src/client/v2-input-task.test.ts | 122 +++++++++++++++++- packages/ext-tasks/src/receiver/index.ts | 12 +- .../ext-tasks/src/receiver/receiver.test.ts | 27 ++++ 6 files changed, 209 insertions(+), 12 deletions(-) diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index 6ea3838..d7cc96a 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -452,6 +452,25 @@ class PortTaskEnabledSession< ); } + if ( + generation === "v2" && + preference === "require" && + !isCreateTaskResultV2(wireResult) + ) + throw new Error( + "Task execution was required but the server returned an immediate result", + ); + if ( + generation === "v2" && + preference === "forbid" && + isCreateTaskResultV2(wireResult) + ) { + this.cleanupLateTaskCreation(response, generation, false); + throw new Error( + "Task execution was forbidden but the server returned a task", + ); + } + if (generation === "v2" && isCreateTaskResultV2(wireResult)) { const created = parseResult(CreateTaskResultV2Schema, wireResult); const handle: InternalTaskHandle & { readonly generation: "v2" } = { @@ -850,7 +869,8 @@ class PortTaskEnabledSession< if (!isJsonValue(result)) throw new Error("Input handler returned a non-JSON value"); return { kind: "result", result }; - } catch { + } catch (error) { + this.reportBackgroundError(reasonAsError(error)); return defaultServerRequestResponse(incoming); } } diff --git a/packages/ext-tasks/src/client/task-protocol-v2.ts b/packages/ext-tasks/src/client/task-protocol-v2.ts index 9f2362a..2212ea7 100644 --- a/packages/ext-tasks/src/client/task-protocol-v2.ts +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -61,6 +61,8 @@ interface V2InputContext extends V2TaskRpcContext { readonly signal: AbortSignal; } +const MAX_TASK_INPUT_ROUNDS = 10; + type InputAcquisition = | { readonly kind: "new" } | { readonly kind: "duplicate" } @@ -88,6 +90,14 @@ class InputRequestLedger { : { kind: "incompatible" }; } + hasNewInput( + inputRequests: Readonly>, + ): boolean { + return Object.keys(inputRequests).some( + (inputKey) => !this.fingerprints.has(inputKey), + ); + } + commit(inputKey: string): void { const acquired = this.fingerprints.get(inputKey); if (acquired !== undefined) @@ -135,6 +145,20 @@ async function driveTaskExecutionV2(args: { let latestDetailedTask = options.initialDetailedTask; let lastNotificationSequence = 0; const acquiredRequestLedger = new InputRequestLedger(); + let inputRound = 0; + const resolveTaskInput = async (task: DetailedTaskV2): Promise => { + if ( + task.status === "input_required" && + acquiredRequestLedger.hasNewInput(task.inputRequests) + ) { + if (inputRound >= MAX_TASK_INPUT_ROUNDS) + throw new Error( + `Task exceeded ${String(MAX_TASK_INPUT_ROUNDS)} input-required rounds`, + ); + inputRound += 1; + } + await resolveAndSubmitInputRequests({ task, inputContext }); + }; const inputContext: V2InputContext = { ...rpcContext, applicationContext: options.applicationContext, @@ -146,10 +170,7 @@ async function driveTaskExecutionV2(args: { }; if (latestDetailedTask !== undefined) - await resolveAndSubmitInputRequests({ - task: latestDetailedTask, - inputContext, - }); + await resolveTaskInput(latestDetailedTask); while (!terminalStatus(knownStatus)) { const delayMs = taskPollInterval( @@ -176,10 +197,7 @@ async function driveTaskExecutionV2(args: { throw new Error("V2 task driver accepted a non-V2 snapshot"); knownStatus = accepted.task.status; latestDetailedTask = accepted.task as DetailedTaskV2; - await resolveAndSubmitInputRequests({ - task: latestDetailedTask, - inputContext, - }); + await resolveTaskInput(latestDetailedTask); } if (driverContext.isClosed()) throw driverContext.errors.closed; diff --git a/packages/ext-tasks/src/client/v1-input-task.test.ts b/packages/ext-tasks/src/client/v1-input-task.test.ts index 6f967ad..5df3514 100644 --- a/packages/ext-tasks/src/client/v1-input-task.test.ts +++ b/packages/ext-tasks/src/client/v1-input-task.test.ts @@ -102,6 +102,7 @@ describe("V1 input and task behavior", () => { it("fails closed when the input handler rejects", async () => { const port = new FakePort({ generation: "v1", capabilities: {} }); + const errors: Error[] = []; let settlement: JsonRpcResponse | undefined; port.dispatchHandler = async () => { settlement = await port.serve({ @@ -116,12 +117,15 @@ describe("V1 input and task behavior", () => { await Promise.resolve(); throw new Error("declined"); }, + onError: (error) => errors.push(error), }); await session.callTool("x"); expect(settlement).toEqual({ kind: "result", result: { action: "cancel" }, }); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ message: "declined" }); await session.close(); }); diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts index 5ca2332..9cd7cea 100644 --- a/packages/ext-tasks/src/client/v2-input-task.test.ts +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -1,5 +1,5 @@ import fc from "fast-check"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { toolDeclaration, withTasks } from "./index.js"; import { FakePort, @@ -75,6 +75,126 @@ describe("V2 input and task behavior", () => { await session.close(); }); + it("enforces V2 task preferences after classifying the response", async () => { + const immediatePort = new FakePort({ generation: "v2", capabilities: {} }); + immediatePort.response = { kind: "result", result: { content: [] } }; + const immediateSession = withTasks(immediatePort, { + tools: { currentTool: () => undefined }, + }); + await expect( + immediateSession.callTool("required", undefined, { + task: { preference: "require" }, + }), + ).rejects.toThrow("server returned an immediate result"); + await immediateSession.close(); + + const taskPort = new FakePort({ generation: "v2", capabilities: {} }); + taskPort.dispatchHandler = (request) => { + const method = expectRecord(request).method; + if (method === "tools/call") + return Promise.resolve({ + kind: "result", + result: asJson({ + resultType: "task", + taskId: "forbidden-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }); + if (method === "tasks/cancel") + return Promise.resolve({ + kind: "result", + result: { resultType: "complete" }, + }); + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const taskSession = withTasks(taskPort, { + tools: { currentTool: () => undefined }, + }); + await expect( + taskSession.callTool("forbidden", undefined, { + task: { preference: "forbid" }, + }), + ).rejects.toThrow("server returned a task"); + await vi.waitFor(() => { + expect( + taskPort.requests.some( + (request) => expectRecord(request).method === "tasks/cancel", + ), + ).toBe(true); + }); + await taskSession.close(); + }); + + it("bounds advancing V2 task input rounds", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + let handlerCalls = 0; + port.dispatchHandler = (request) => { + const method = expectRecord(request).method; + if (method === "tools/call") + return Promise.resolve({ + kind: "result", + result: asJson({ + resultType: "task", + taskId: "bounded-input", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + pollIntervalMs: 0, + }), + }); + if (method === "tasks/get") { + getCalls += 1; + return Promise.resolve({ + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "bounded-input", + status: "input_required", + createdAt: "a", + lastUpdatedAt: String(getCalls), + ttlMs: null, + pollIntervalMs: 0, + inputRequests: { + [`round-${String(getCalls)}`]: { method: "roots/list" }, + }, + }), + }); + } + if (method === "tasks/update") + return Promise.resolve({ + kind: "result", + result: { resultType: "complete" }, + }); + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + onInputRequest: async () => { + await Promise.resolve(); + handlerCalls += 1; + return { roots: [] } as never; + }, + }); + const execution = await session.callTool("bounded"); + await expect(execution.result()).resolves.toMatchObject({ + status: "failed", + error: { message: "Task exceeded 10 input-required rounds" }, + }); + expect(handlerCalls).toBe(10); + expect(getCalls).toBe(11); + expect( + port.requests.filter( + (request) => expectRecord(request).method === "tasks/update", + ), + ).toHaveLength(10); + await session.close(); + }); + it("acquires distinct V2 input keys once and submits one valid subset", async () => { await fc.assert( fc.asyncProperty( diff --git a/packages/ext-tasks/src/receiver/index.ts b/packages/ext-tasks/src/receiver/index.ts index 608b6f3..4f65573 100644 --- a/packages/ext-tasks/src/receiver/index.ts +++ b/packages/ext-tasks/src/receiver/index.ts @@ -91,6 +91,8 @@ type Handler = (request: unknown, context?: unknown) => Promise; type FinalDisposition = "expiry" | "close"; type TaskDisposition = "cancel" | FinalDisposition; +const MAX_TIMER_DELAY_MS = 2_147_483_647; + interface TaskRecord { task: TaskV1; readonly method: TaskReceiverMethod; @@ -262,11 +264,17 @@ export function bindTaskReceiver( }; const armExpiry = (record: TaskRecord): void => { if (record.expiresAt === null) return; + const remainingMs = record.expiresAt - now(); + if (remainingMs <= 0) { + remove(record, "expiry"); + return; + } const timer = setTimeout( () => { - remove(record, "expiry"); + record.expiryTimer = undefined; + armExpiry(record); }, - Math.max(0, record.expiresAt - now()), + Math.min(remainingMs, MAX_TIMER_DELAY_MS), ); record.expiryTimer = timer; detachTimer(timer); diff --git a/packages/ext-tasks/src/receiver/receiver.test.ts b/packages/ext-tasks/src/receiver/receiver.test.ts index 1e0f6bb..6e305a2 100644 --- a/packages/ext-tasks/src/receiver/receiver.test.ts +++ b/packages/ext-tasks/src/receiver/receiver.test.ts @@ -134,6 +134,33 @@ describe("bindTaskReceiver", () => { ).rejects.toThrow("expired"); }); + it("chunks TTLs beyond the maximum timer delay", async () => { + vi.useFakeTimers(); + const host = new Host(); + const ttlMs = 2_147_483_647 + 1_000; + bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + ttlMs, + sampling: () => new Promise>(() => undefined), + createTaskId: () => "long-lived", + }); + + await host.call("sampling/createMessage"); + await vi.advanceTimersByTimeAsync(2_147_483_647); + await expect( + host.call("tasks/get", { taskId: "long-lived" }), + ).resolves.toMatchObject({ taskId: "long-lived", ttl: ttlMs }); + + await vi.advanceTimersByTimeAsync(999); + await expect( + host.call("tasks/get", { taskId: "long-lived" }), + ).resolves.toMatchObject({ taskId: "long-lived" }); + await vi.advanceTimersByTimeAsync(1); + await expect( + host.call("tasks/get", { taskId: "long-lived" }), + ).rejects.toThrow("expired"); + }); + it("advertises and installs only enabled request methods", () => { const host = new Host(); const binding = bindTaskReceiver(asClient(host), { From 03e208940756ebfb22d80fae81fa2d172757a6e9 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Wed, 9 Sep 2026 20:15:40 -0700 Subject: [PATCH 26/33] fix: address Copilot comments --- packages/ext-tasks/src/client/execution.ts | 32 +++++- .../client/immediate-session-basics.test.ts | 26 +++++ packages/ext-tasks/src/client/port.ts | 19 +++- packages/ext-tasks/src/client/session.ts | 18 +++- .../src/client/task-controller.test.ts | 10 +- .../src/client/task-lifecycle-races.test.ts | 99 ++++++++++++++++++- .../src/client/v2-input-task.test.ts | 6 +- packages/ext-tasks/src/core/v2/index.test.ts | 63 +++++++++++- packages/ext-tasks/src/core/v2/schemas.ts | 28 +++++- 9 files changed, 282 insertions(+), 19 deletions(-) diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts index 98b0b0a..066e88d 100644 --- a/packages/ext-tasks/src/client/execution.ts +++ b/packages/ext-tasks/src/client/execution.ts @@ -85,6 +85,12 @@ export function taskPollInterval( ); } +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +function safeTimerDelay(delayMs: number): number { + return Math.min(MAX_TIMER_DELAY_MS, Math.max(0, delayMs)); +} + /** Waits for the next task poll while remaining abortable. */ export async function waitForTaskPoll( delayMs: number, @@ -94,7 +100,7 @@ export async function waitForTaskPoll( try { await withAbort( new Promise((resolve) => { - timeout = setTimeout(resolve, Math.max(0, delayMs)); + timeout = setTimeout(resolve, safeTimerDelay(delayMs)); }), signal, ); @@ -172,6 +178,7 @@ export class TaskExecution< private updatesAcquired = false; private cancelPromise: Promise | undefined; private closePromise: Promise | undefined; + private releaseLifecycleListener: (() => void) | undefined; private settlementPromise: Promise> | undefined; private settled = false; @@ -195,10 +202,17 @@ export class TaskExecution< const { lifecycleSignal } = options; if (lifecycleSignal !== undefined) { const abort = (): void => { + this.releaseLifecycleListener?.(); this.controller.abort(lifecycleSignal.reason); }; if (lifecycleSignal.aborted) abort(); - else lifecycleSignal.addEventListener("abort", abort, { once: true }); + else { + lifecycleSignal.addEventListener("abort", abort, { once: true }); + this.releaseLifecycleListener = () => { + lifecycleSignal.removeEventListener("abort", abort); + this.releaseLifecycleListener = undefined; + }; + } } this.resultPromise = options.driver({ accept: (snapshot) => this.acceptSnapshot(snapshot), @@ -477,7 +491,7 @@ export class TaskExecution< const onAbort = (): void => { finish(this.controller.signal.reason); }; - const timeout = setTimeout(onTurn, Math.max(0, delayMs)); + const timeout = setTimeout(onTurn, safeTimerDelay(delayMs)); this.turnWaiters.add(onTurn); this.controller.signal.addEventListener("abort", onAbort, { once: true }); }); @@ -562,6 +576,7 @@ export class TaskExecution< detach(): Promise { if (this.closed) return Promise.resolve(); this.closed = true; + this.releaseLifecycleListener?.(); this.controller.abort(this.closedError); this.inputController.abort(this.closedError); return Promise.resolve(); @@ -665,6 +680,7 @@ export class ImmediateExecution< private readonly outcomePromise: Promise>; private settlementPromise: Promise> | undefined; + private updatesAcquired = false; constructor( readonly applicationContext: TApplicationContext, @@ -676,6 +692,14 @@ export class ImmediateExecution< } updates(signal?: AbortSignal): AsyncIterable> { + if (this.updatesAcquired) throw new TaskUpdatesAlreadyAcquiredError(); + this.updatesAcquired = true; + return this.observeOutcome(signal); + } + + private observeOutcome( + signal?: AbortSignal, + ): AsyncIterable> { throwIfAborted(signal); const outcome = this.result(); return { @@ -694,7 +718,7 @@ export class ImmediateExecution< ): Promise> { this.settlementPromise ??= settleExecution( this, - this.updates(options.signal), + this.observeOutcome(options.signal), options, ); return this.settlementPromise; diff --git a/packages/ext-tasks/src/client/immediate-session-basics.test.ts b/packages/ext-tasks/src/client/immediate-session-basics.test.ts index 1e292f0..149c92e 100644 --- a/packages/ext-tasks/src/client/immediate-session-basics.test.ts +++ b/packages/ext-tasks/src/client/immediate-session-basics.test.ts @@ -128,6 +128,32 @@ describe("immediate and session basics", () => { await session.close(); }); + it("rejects invalid requested retention before dispatch", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + + for (const retentionMs of [ + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ]) + await expect( + session.callTool("x", undefined, { task: { retentionMs } }), + ).rejects.toThrow("non-negative safe integer"); + expect(port.requests).toEqual([]); + + port.response = { kind: "result", result: { content: [] } }; + await expect( + session.callTool("x", undefined, { task: { retentionMs: 0 } }), + ).resolves.toBeDefined(); + expect(port.requests).toHaveLength(1); + await session.close(); + }); + it("adds requested TTL only to V1 task calls", async () => { const port = new FakePort({ generation: "v1", diff --git a/packages/ext-tasks/src/client/port.ts b/packages/ext-tasks/src/client/port.ts index 5588f0c..07dee8a 100644 --- a/packages/ext-tasks/src/client/port.ts +++ b/packages/ext-tasks/src/client/port.ts @@ -222,15 +222,28 @@ export interface TaskRpcV2 { ) => Promise; } +function v2TaskDispatchContext(options: TaskRpcOptions): DispatchContext { + const headers = Object.fromEntries( + Object.entries(options.context?.headers ?? {}).filter( + ([name]) => name.toLowerCase() !== "mcp-name", + ), + ); + return { + ...options.context, + headers: { ...headers, "Mcp-Name": options.taskId }, + }; +} + async function dispatchTaskRpc( options: TaskRpcOptions, request: JsonValue, schema: InternalSchema | RuntimeCodec, signal: AbortSignal | undefined, + context: DispatchContext | undefined = options.context, ): Promise { const response = await dispatchWithRetry(options.port, request, { signal, - context: options.context, + context, }); return parseResult(schema, responseResult(response)); } @@ -278,6 +291,7 @@ export function createTaskRpc( const params = >>(value: T) => withTaskCapabilityV2(value); + const context = v2TaskDispatchContext(options); return { generation, get: (signal) => @@ -286,6 +300,7 @@ export function createTaskRpc( { method: "tasks/get", params: params({ taskId: options.taskId }) }, GetTaskResultV2Schema, signal, + context, ), cancel: async (signal) => { await dispatchTaskRpc( @@ -293,6 +308,7 @@ export function createTaskRpc( { method: "tasks/cancel", params: params({ taskId: options.taskId }) }, CancelTaskResultV2Schema, signal, + context, ); }, update: async (inputResponses, signal) => { @@ -304,6 +320,7 @@ export function createTaskRpc( }, UpdateTaskResultV2Schema, signal, + context, ); }, }; diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index d7cc96a..98f94e9 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -294,9 +294,19 @@ class PortTaskEnabledSession< if (options.metadata !== undefined) requestParams._meta = options.metadata; const generation = this.port.taskCapabilities.generation; const preference = options.task?.preference ?? "allow"; + const retentionMs = options.task?.retentionMs; if ( - options.task?.retentionMs !== undefined && - options.task.retention === "require-capability" && + retentionMs !== undefined && + (!Number.isSafeInteger(retentionMs) || retentionMs < 0) + ) { + callLifecycle.dispose(); + throw new RangeError( + "task.retentionMs must be a non-negative safe integer", + ); + } + if ( + retentionMs !== undefined && + options.task?.retention === "require-capability" && !this.capabilities.requestedRetention ) { callLifecycle.dispose(); @@ -317,9 +327,7 @@ class PortTaskEnabledSession< } if (callAsTaskV1) requestParams.task = - options.task?.retentionMs === undefined - ? {} - : { ttl: options.task.retentionMs }; + retentionMs === undefined ? {} : { ttl: retentionMs }; const dispatchContext = options.headers === undefined && options.requestTimeoutMs === undefined ? undefined diff --git a/packages/ext-tasks/src/client/task-controller.test.ts b/packages/ext-tasks/src/client/task-controller.test.ts index 5f4367e..ae786a2 100644 --- a/packages/ext-tasks/src/client/task-controller.test.ts +++ b/packages/ext-tasks/src/client/task-controller.test.ts @@ -91,7 +91,9 @@ describe("manual task controller", () => { ); }; const session = withTasks(port, { tools }); - const controller = session.task(taskId("manual-v2")); + const controller = session.task(taskId("manual-v2"), { + headers: { "x-route": "blue", "mcp-name": "caller-value" }, + }); await expect(controller.snapshot()).resolves.toMatchObject({ taskId: "manual-v2", @@ -125,6 +127,12 @@ describe("manual task controller", () => { }, }); } + for (const dispatchOptions of port.dispatchOptions) { + expect(dispatchOptions?.context?.headers).toEqual({ + "x-route": "blue", + "Mcp-Name": "manual-v2", + }); + } expect( expectRecord(expectRecord(port.requests[3]).params).inputResponses, ).toEqual({ prompt: { action: "cancel" } }); diff --git a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts index 1ed514b..ba194ea 100644 --- a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -1,5 +1,5 @@ import fc from "fast-check"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DispatchError, JsonRpcResponseError, @@ -9,7 +9,11 @@ import { toolDeclaration, withTasks, } from "./index.js"; -import { deterministicJson } from "./execution.js"; +import { + deterministicJson, + TaskExecution, + waitForTaskPoll, +} from "./execution.js"; import { FakePort, asJson, @@ -23,6 +27,97 @@ describe("task lifecycle and races", () => { expect(deterministicJson(undefined)).toBe("[undefined]"); expect(deterministicJson({ keep: 1, omit: undefined })).toBe('{"keep":1}'); }); + it("caps poll timers at the platform maximum delay", async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + let settled = false; + const waiting = waitForTaskPoll(2_147_483_648, controller.signal).then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.advanceTimersByTimeAsync(1); + expect(settled).toBe(false); + controller.abort(); + await waiting; + } finally { + vi.useRealTimers(); + } + }); + + it("releases the session lifecycle listener when task ownership ends", async () => { + const lifecycle = new AbortController(); + const remove = vi.spyOn(lifecycle.signal, "removeEventListener"); + const execution = new TaskExecution({ + applicationContext: undefined, + handle: { + generation: "v2", + taskId: "listener-task" as never, + originalOperation: "tools/call", + }, + endpointId: "endpoint" as never, + initialSnapshot: { + generation: "v2", + task: { + taskId: "listener-task", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }, + }, + driver: ({ signal, errors }) => + new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + reject(errors.closed); + }, + { once: true }, + ); + }), + cancelTask: () => Promise.resolve(), + lifecycleSignal: lifecycle.signal, + }); + + await execution.detach(); + expect(remove).toHaveBeenCalledWith("abort", expect.any(Function)); + await expect(execution.result()).resolves.toMatchObject({ + status: "failed", + }); + }); + + it("enforces one-owner immediate updates without consuming them during settle", async () => { + const port = new FakePort(); + port.response = { kind: "result", result: { content: [] } }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + + const updatesFirst = await session.callTool("updates-first"); + updatesFirst.updates(); + expect(() => updatesFirst.updates()).toThrow( + TaskUpdatesAlreadyAcquiredError, + ); + await expect(updatesFirst.settle()).resolves.toMatchObject({ + outcome: { status: "completed" }, + }); + + const settleFirst = await session.callTool("settle-first"); + await expect(settleFirst.settle()).resolves.toMatchObject({ + outcome: { status: "completed" }, + }); + settleFirst.updates(); + expect(() => settleFirst.updates()).toThrow( + TaskUpdatesAlreadyAcquiredError, + ); + await session.close(); + }); + it("shares cancellation and enforces single-consumer task updates", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); let cancelCalls = 0; diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts index 9cd7cea..bf1607e 100644 --- a/packages/ext-tasks/src/client/v2-input-task.test.ts +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -281,7 +281,11 @@ describe("V2 input and task behavior", () => { observed.push({ request, context }); return ( request.kind === "sampling" - ? { model: "m", role: "assistant", content: { type: "text" } } + ? { + model: "m", + role: "assistant", + content: { type: "text", text: "sampled" }, + } : request.kind === "roots" ? { roots: [{ uri: "file:///root" }] } : { action: "cancel" } diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts index 5b3438c..6bc8caa 100644 --- a/packages/ext-tasks/src/core/v2/index.test.ts +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -14,6 +14,7 @@ import { ErrorV2Schema, GetTaskRequestV2Schema, GetTaskResultV2Schema, + CreateMessageResultV2Schema, InputRequestsV2Schema, InputResponsesV2Schema, TaskStatusNotificationParamsV2Schema, @@ -56,6 +57,49 @@ const asJson = (value: unknown): JsonValue => JSON.parse(JSON.stringify(value)) as JsonValue; describe("V2 runtime wire contracts", () => { + it("validates sampling result content blocks", () => { + for (const content of [ + { type: "text", text: "hello" }, + [{ type: "audio", data: "AA==", mimeType: "audio/wav" }], + { + type: "tool_use", + id: "call-1", + name: "weather", + input: { city: "Oslo" }, + }, + { + type: "tool_result", + toolUseId: "call-1", + content: [{ type: "text", text: "cold" }], + structuredContent: { temperature: 2 }, + }, + ]) { + expect( + CreateMessageResultV2Schema.safeParse({ + content, + model: "test-model", + role: "assistant", + stopReason: "endTurn", + }).success, + ).toBe(true); + } + + for (const content of [ + 1, + null, + { type: "bogus" }, + { type: "resource_link", name: "x", uri: "file:///x" }, + { type: "tool_use", name: "missing-id", input: {} }, + ]) + expect( + CreateMessageResultV2Schema.safeParse({ + content, + model: "test-model", + role: "assistant", + }).success, + ).toBe(false); + }); + it("accepts every valid base Task and rejects missing required fields, invalid integers, and statuses", () => { fc.assert( fc.property(baseTask, (task) => { @@ -245,7 +289,18 @@ describe("V2 runtime wire contracts", () => { }), fc.record({ roots: fc.array(fc.jsonValue()) }), fc.record({ - content: fc.jsonValue(), + content: fc.oneof( + fc.record({ + type: fc.constant("text" as const), + text: fc.string(), + }), + fc.array( + fc.record({ + type: fc.constant("text" as const), + text: fc.string(), + }), + ), + ), model: fc.string(), role: fc.constantFrom("user" as const, "assistant" as const), }), @@ -637,7 +692,7 @@ describe("V2 runtime wire contracts", () => { response: { action: "invalid", roots: [], - content: {}, + content: { type: "text", text: "ok" }, model: "model", role: "assistant", }, @@ -647,7 +702,7 @@ describe("V2 runtime wire contracts", () => { InputResponsesV2Schema.safeParse({ response: { roots: "invalid", - content: {}, + content: { type: "text", text: "ok" }, model: "model", role: "assistant", }, @@ -658,7 +713,7 @@ describe("V2 runtime wire contracts", () => { response: { action: "accept", roots: "ignored extension value", - content: {}, + content: { type: "text", text: "ignored extension value" }, model: 1, role: "invalid", }, diff --git a/packages/ext-tasks/src/core/v2/schemas.ts b/packages/ext-tasks/src/core/v2/schemas.ts index c7024e7..c3b57f7 100644 --- a/packages/ext-tasks/src/core/v2/schemas.ts +++ b/packages/ext-tasks/src/core/v2/schemas.ts @@ -129,6 +129,28 @@ const ContentBlockV2Schema = z.union([ ResourceLinkContentBlockV2Schema, EmbeddedResourceContentBlockV2Schema, ]); +const ToolUseContentBlockV2Schema = openObject({ + type: z.literal("tool_use"), + id: z.string(), + name: z.string(), + input: JsonObjectSchema, + _meta: MetaSchema.optional(), +}); +const ToolResultContentBlockV2Schema = openObject({ + type: z.literal("tool_result"), + toolUseId: z.string(), + content: z.array(ContentBlockV2Schema), + structuredContent: JsonValueSchema.optional(), + isError: z.boolean().optional(), + _meta: MetaSchema.optional(), +}); +const SamplingMessageContentBlockV2Schema = z.union([ + TextContentBlockV2Schema, + ImageContentBlockV2Schema, + AudioContentBlockV2Schema, + ToolUseContentBlockV2Schema, + ToolResultContentBlockV2Schema, +]); const ToolV2Schema = openObject({ name: z.string(), @@ -221,9 +243,13 @@ const CallToolResultV2Schema = z.union([ ]); const CreateMessageResultV2Schema = openObject({ - content: JsonValueSchema, + content: z.union([ + SamplingMessageContentBlockV2Schema, + z.array(SamplingMessageContentBlockV2Schema), + ]), model: z.string(), role: z.enum(["user", "assistant"]), + stopReason: z.string().optional(), }); const ListRootsResultV2Schema = openObject({ roots: z.array(JsonValueSchema), From 42169818ac27e384debcbcf133adbfe6a1fe870c Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Thu, 10 Sep 2026 11:39:20 -0700 Subject: [PATCH 27/33] fix: address Copilot comments --- .../client/declarations-capabilities.test.ts | 10 +++++-- .../client/immediate-session-basics.test.ts | 4 +++ .../src/client/protocol-matrix.test.ts | 5 +++- .../src/client/session-facade.test.ts | 11 ++++--- .../src/client/task-lifecycle-races.test.ts | 5 ++-- .../src/client/task-resumption.test.ts | 6 ++-- .../src/client/v2-input-task.test.ts | 16 ++++++---- packages/ext-tasks/src/core/v2/index.test.ts | 30 +++++++++++-------- packages/ext-tasks/src/core/v2/schemas.ts | 2 +- 9 files changed, 58 insertions(+), 31 deletions(-) diff --git a/packages/ext-tasks/src/client/declarations-capabilities.test.ts b/packages/ext-tasks/src/client/declarations-capabilities.test.ts index b818923..ead5f7a 100644 --- a/packages/ext-tasks/src/client/declarations-capabilities.test.ts +++ b/packages/ext-tasks/src/client/declarations-capabilities.test.ts @@ -281,7 +281,10 @@ describe("declarations and capabilities", () => { it("projects one neutral declaration without leaking call listeners", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); - port.response = { kind: "result", result: { content: [] } }; + port.response = { + kind: "result", + result: { resultType: "complete", content: [] }, + }; const declaration = toolDeclaration({ name: "x", inputSchema: { type: "object" }, @@ -306,7 +309,10 @@ describe("declarations and capabilities", () => { it("retains neutral task support while omitting the absent V2 wire field", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); - port.response = { kind: "result", result: { content: [] } }; + port.response = { + kind: "result", + result: { resultType: "complete", content: [] }, + }; const declaration = toolDeclaration({ name: "x", inputSchema: { type: "object" }, diff --git a/packages/ext-tasks/src/client/immediate-session-basics.test.ts b/packages/ext-tasks/src/client/immediate-session-basics.test.ts index 149c92e..fb23281 100644 --- a/packages/ext-tasks/src/client/immediate-session-basics.test.ts +++ b/packages/ext-tasks/src/client/immediate-session-basics.test.ts @@ -101,6 +101,10 @@ describe("immediate and session basics", () => { it("preserves call metadata and transport headers", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); + port.response = { + kind: "result", + result: { resultType: "complete", content: [] }, + }; const session = withTasks(port, { tools: { currentTool: () => undefined }, }); diff --git a/packages/ext-tasks/src/client/protocol-matrix.test.ts b/packages/ext-tasks/src/client/protocol-matrix.test.ts index 26a7cff..05f056a 100644 --- a/packages/ext-tasks/src/client/protocol-matrix.test.ts +++ b/packages/ext-tasks/src/client/protocol-matrix.test.ts @@ -68,7 +68,10 @@ const cases: readonly MatrixCase[] = [ lastUpdatedAt: "b", ttlMs: 4000, pollIntervalMs: 1, - result: { content: [{ type: "text", text: "done" }] }, + result: { + resultType: "complete", + content: [{ type: "text", text: "done" }], + }, }, }, ]; diff --git a/packages/ext-tasks/src/client/session-facade.test.ts b/packages/ext-tasks/src/client/session-facade.test.ts index 99be0aa..334fee1 100644 --- a/packages/ext-tasks/src/client/session-facade.test.ts +++ b/packages/ext-tasks/src/client/session-facade.test.ts @@ -76,7 +76,7 @@ describe("task session facade", () => { createdAt: "a", lastUpdatedAt: "b", ttlMs: 1_000, - result: { content: [] }, + result: { resultType: "complete", content: [] }, }, }); }; @@ -112,7 +112,10 @@ describe("task session facade", () => { }), }; if (method === "tasks/cancel") - return { kind: "result", result: asJson({}) }; + return { + kind: "result", + result: asJson({ resultType: "complete" }), + }; return new Promise(() => {}); }; const session = withTasks(port, { @@ -158,9 +161,9 @@ describe("task session facade", () => { if ((request as { method?: string }).method === "tasks/cancel") { cancelSeen(); await releasePromise; - return { kind: "result", result: {} }; + return { kind: "result", result: { resultType: "complete" } }; } - return { kind: "result", result: {} }; + return { kind: "result", result: { resultType: "complete" } }; }; const session = withTasks(port, { tools: { currentTool: () => undefined }, diff --git a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts index ba194ea..3387b58 100644 --- a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -908,12 +908,13 @@ describe("task lifecycle and races", () => { jsonrpc: "2.0", method: "notifications/tasks", params: { + resultType: "complete", taskId: "synchronous-notification", status: "completed", createdAt: "a", lastUpdatedAt: "b", ttlMs: null, - result: { content: [] }, + result: { resultType: "complete", content: [] }, }, }), ); @@ -1033,7 +1034,7 @@ describe("task lifecycle and races", () => { createdAt: "a", lastUpdatedAt: "b", ttlMs: null, - result: { content: [] }, + result: { resultType: "complete", content: [] }, }), }; } diff --git a/packages/ext-tasks/src/client/task-resumption.test.ts b/packages/ext-tasks/src/client/task-resumption.test.ts index 9bb4c39..396aec7 100644 --- a/packages/ext-tasks/src/client/task-resumption.test.ts +++ b/packages/ext-tasks/src/client/task-resumption.test.ts @@ -454,7 +454,7 @@ describe("task reference resumption", () => { createdAt: "a", lastUpdatedAt: "c", ttlMs: null, - result: { content: [] }, + result: { resultType: "complete", content: [] }, }), }; } @@ -553,7 +553,7 @@ describe("task reference resumption", () => { createdAt: "a", lastUpdatedAt: "b", ttlMs: null, - result: { content: [] }, + result: { resultType: "complete", content: [] }, }), }; }; @@ -590,7 +590,7 @@ describe("task reference resumption", () => { createdAt: "a", lastUpdatedAt: "b", ttlMs: null, - result: { content: [] }, + result: { resultType: "complete", content: [] }, }), }; }; diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts index bf1607e..c65bcbb 100644 --- a/packages/ext-tasks/src/client/v2-input-task.test.ts +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -40,6 +40,7 @@ describe("V2 input and task behavior", () => { lastUpdatedAt: "b", ttlMs: null, result: { + resultType: "complete", content: [{ type: "text", text: "done" }], }, }), @@ -77,7 +78,10 @@ describe("V2 input and task behavior", () => { it("enforces V2 task preferences after classifying the response", async () => { const immediatePort = new FakePort({ generation: "v2", capabilities: {} }); - immediatePort.response = { kind: "result", result: { content: [] } }; + immediatePort.response = { + kind: "result", + result: { resultType: "complete", content: [] }, + }; const immediateSession = withTasks(immediatePort, { tools: { currentTool: () => undefined }, }); @@ -260,7 +264,7 @@ describe("V2 input and task behavior", () => { createdAt: "a", lastUpdatedAt: "c", ttlMs: null, - result: { content: [] }, + result: { resultType: "complete", content: [] }, }), }; } @@ -394,7 +398,7 @@ describe("V2 input and task behavior", () => { createdAt: "a", lastUpdatedAt: "z", ttlMs: null, - result: { content: [] }, + result: { resultType: "complete", content: [] }, }), }; } @@ -473,7 +477,7 @@ describe("V2 input and task behavior", () => { createdAt: "a", lastUpdatedAt: "c", ttlMs: null, - result: { content: [] }, + result: { resultType: "complete", content: [] }, }, ), }; @@ -582,7 +586,7 @@ describe("V2 input and task behavior", () => { createdAt: "a", lastUpdatedAt: "c", ttlMs: null, - result: { content: [] }, + result: { resultType: "complete", content: [] }, }, }), ); @@ -638,7 +642,7 @@ describe("V2 input and task behavior", () => { status === "completed" ? { ...terminal, - result: { content: [] }, + result: { resultType: "complete", content: [] }, } : status === "failed" ? { diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts index 6bc8caa..2762b79 100644 --- a/packages/ext-tasks/src/core/v2/index.test.ts +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -52,7 +52,11 @@ const baseTask = fc.record({ pollIntervalMs: fc.option(fc.integer(), { nil: undefined }), }); const taskFor = (status: TaskStatusV2) => - baseTask.map((task) => ({ ...task, status })); + baseTask.map((task) => ({ + ...task, + status, + ...(status === "completed" ? { resultType: "complete" as const } : {}), + })); const asJson = (value: unknown): JsonValue => JSON.parse(JSON.stringify(value)) as JsonValue; @@ -479,10 +483,9 @@ describe("V2 runtime wire contracts", () => { }, ), ); - expect(CallToolResultV2Schema.parse({ content: [] })).toEqual({ - resultType: "complete", - content: [], - }); + expect(CallToolResultV2Schema.safeParse({ content: [] }).success).toBe( + false, + ); expect( CallToolResultV2Schema.safeParse({ resultType: "complete" }).success, ).toBe(false); @@ -571,12 +574,8 @@ describe("V2 runtime wire contracts", () => { .success, ).toBe(false); } - expect(UpdateTaskResultV2Schema.parse({})).toEqual({ - resultType: "complete", - }); - expect(CancelTaskResultV2Schema.parse({})).toEqual({ - resultType: "complete", - }); + expect(UpdateTaskResultV2Schema.safeParse({}).success).toBe(false); + expect(CancelTaskResultV2Schema.safeParse({}).success).toBe(false); expect( UpdateTaskResultV2Schema.safeParse({ resultType: "task" }).success, ).toBe(false); @@ -586,7 +585,14 @@ describe("V2 runtime wire contracts", () => { fc.dictionary(fc.string(), fc.jsonValue()), (task, result) => { const parsed = GetTaskResultV2Schema.parse( - asJson({ ...task, result }), + asJson({ + ...task, + result: { + resultType: "complete", + content: [], + structuredContent: result, + }, + }), ); expect(parsed.resultType).toBe("complete"); }, diff --git a/packages/ext-tasks/src/core/v2/schemas.ts b/packages/ext-tasks/src/core/v2/schemas.ts index c3b57f7..d51b8cc 100644 --- a/packages/ext-tasks/src/core/v2/schemas.ts +++ b/packages/ext-tasks/src/core/v2/schemas.ts @@ -172,7 +172,7 @@ const ToolV2Schema = openObject({ _meta: MetaSchema.optional(), }); -const CompleteResultTypeSchema = z.literal("complete").default("complete"); +const CompleteResultTypeSchema = z.literal("complete"); const CompleteCallToolResultV2Schema = openObject({ resultType: CompleteResultTypeSchema, content: z.array(ContentBlockV2Schema), From 5e8f02e048dc76c51c23b34685b0b7fae9902017 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Thu, 10 Sep 2026 12:15:10 -0700 Subject: [PATCH 28/33] feat(ci): add publish workflow --- .github/workflows/npm-publish.yml | 113 ++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/workflows/npm-publish.yml diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..b2e60cd --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,113 @@ +# Publishes the @modelcontextprotocol/ext-tasks package to npm when a GitHub +# Release is published. Versioning is manual: bump packages/ext-tasks/package.json +# in the release commit, then cut the GitHub Release from that tag — this workflow +# picks it up from there. +name: Publish to npm + +on: + release: + types: [published] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + name: Validate before publishing + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - name: Install dependencies + run: npm ci + # Full gate: schema provenance, format, lint, typecheck, tests, build, and + # the packed-consumer check. Runs again here rather than trusting CI, so a + # publish can never proceed from a commit CI hasn't fully validated. + - name: Check schemas, package, and packed consumers + run: npm run check + - name: Build documentation + run: npm run docs:build + + publish: + name: Publish + needs: validate + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + registry-url: "https://registry.npmjs.org" + - name: Install dependencies + run: npm ci + # Node 24's bundled npm satisfies the >= 11.5.1 OIDC requirement only from + # ~24.6 onward; pin explicitly so the publish never depends on which Node + # patch the runner resolves. + - name: Ensure npm CLI supports OIDC trusted publishing + run: npm install -g npm@11.5.1 + - name: Determine npm dist-tag + id: npm-tag + run: | + VERSION=$(node -p "require('./packages/ext-tasks/package.json').version") + # Pre-release versions publish under the beta dist-tag. + if [[ "$VERSION" == *"-"* ]]; then + echo "tag=--tag beta" >> "$GITHUB_OUTPUT" + # Releases cut from a non-main branch (patch/maintenance lines) publish + # under release-X.Y so they never displace latest. + elif [[ "${{ github.event.release.target_commitish }}" != "main" ]]; then + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) + echo "tag=--tag release-${MAJOR_MINOR}" >> "$GITHUB_OUTPUT" + else + echo "tag=" >> "$GITHUB_OUTPUT" + fi + - name: Publish to npm + run: npm publish --provenance --access public ${{ steps.npm-tag.outputs.tag }} + working-directory: packages/ext-tasks + env: + NPM_CONFIG_PROVENANCE: "true" + + verify: + name: Verify published package + needs: publish + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - name: Wait for npm to serve the version + run: | + VERSION=$(node -p "require('./packages/ext-tasks/package.json').version") + for i in $(seq 1 30); do + if npm view "@modelcontextprotocol/ext-tasks@${VERSION}" version >/dev/null 2>&1; then + echo "npm is serving ${VERSION}." + exit 0 + fi + echo "Not visible yet (attempt ${i}/30); waiting 10s..." + sleep 10 + done + echo "npm never served ${VERSION} within 5 minutes of publishing." + exit 1 From 6704c7b0b0f1fe91442c47ce858b2455897b9cdf Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Thu, 10 Sep 2026 14:24:38 -0700 Subject: [PATCH 29/33] fix: address Copilot comments --- .../client/declarations-capabilities.test.ts | 39 +++++++++++++++ packages/ext-tasks/src/client/execution.ts | 17 +++++-- .../client/immediate-session-basics.test.ts | 21 ++++++++ packages/ext-tasks/src/client/session.ts | 50 ++++++++++++++----- .../ext-tasks/src/client/tool-declarations.ts | 33 +++++++----- .../src/client/v2-input-task.test.ts | 8 +++ 6 files changed, 140 insertions(+), 28 deletions(-) diff --git a/packages/ext-tasks/src/client/declarations-capabilities.test.ts b/packages/ext-tasks/src/client/declarations-capabilities.test.ts index ead5f7a..fdea026 100644 --- a/packages/ext-tasks/src/client/declarations-capabilities.test.ts +++ b/packages/ext-tasks/src/client/declarations-capabilities.test.ts @@ -191,6 +191,45 @@ describe("declarations and capabilities", () => { await session.close(); }); + it("recovers readiness when an early list_changed supersedes the initial refresh", async () => { + const port = new FakePort({ generation: "v1", capabilities: {} }); + let listCount = 0; + port.dispatchHandler = (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return Promise.resolve({ + kind: "result", + result: asJson({ content: [] }), + }); + if (record.method !== "tools/list") + throw new Error(`unexpected method ${formatJson(record.method)}`); + listCount += 1; + if (listCount === 1) + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => { + reject(new DOMException("superseded", "AbortError")); + }, + { once: true }, + ); + }); + return Promise.resolve({ + kind: "result", + result: asJson({ + tools: [{ name: "x", inputSchema: { type: "object" } }], + }), + }); + }; + const session = withTasks(port); + // Readiness must follow the replacement instead of rejecting forever + // because the early notification aborts the constructor's initial refresh. + port.notify({ method: "notifications/tools/list_changed" }); + const execution = await session.callTool("x"); + expect(execution.kind).toBe("immediate"); + await session.close(); + }); + it("rejects duplicate tools deterministically and aborts managed discovery on close", async () => { const duplicatePort = new FakePort({ generation: "v1", capabilities: {} }); duplicatePort.dispatchHandler = async (request) => { diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts index 066e88d..c7c7fa3 100644 --- a/packages/ext-tasks/src/client/execution.ts +++ b/packages/ext-tasks/src/client/execution.ts @@ -57,9 +57,20 @@ function codecFromSchema(schema: { export function defaultResultCodec( generation: SessionTaskCapabilities["generation"], ): RuntimeCodec { - return generation === "v2" - ? codecFromSchema(CallToolResultV2Schema) - : codecFromSchema(CallToolResultV1Schema); + if (generation === "v2") return codecFromSchema(CallToolResultV2Schema); + if (generation === "v1") return codecFromSchema(CallToolResultV1Schema); + // Accept the union of valid immediate results because "none" retains no + // protocol era: a modern no-Tasks session still returns modern result shapes. + const legacyCodec = codecFromSchema(CallToolResultV1Schema); + const modernCodec = codecFromSchema(CallToolResultV2Schema); + return { + parse(value) { + const legacy = legacyCodec.parse(value); + if (legacy.success) return legacy; + const modern = modernCodec.parse(value); + return modern.success ? modern : legacy; + }, + }; } /** Normalizes an invalidation or abort reason to an Error instance. */ diff --git a/packages/ext-tasks/src/client/immediate-session-basics.test.ts b/packages/ext-tasks/src/client/immediate-session-basics.test.ts index fb23281..b2ca2f6 100644 --- a/packages/ext-tasks/src/client/immediate-session-basics.test.ts +++ b/packages/ext-tasks/src/client/immediate-session-basics.test.ts @@ -132,6 +132,27 @@ describe("immediate and session basics", () => { await session.close(); }); + it("accepts modern immediate result content when tasks are not advertised", async () => { + // The default codec must accept the union of valid immediate result + // shapes because generation "none" retains no protocol era. A + // resultType-bearing modern result with non-object structuredContent is + // valid V2 and rejected by the V1 schema (which requires an object there). + const port = new FakePort({ generation: "none" }); + const modernResult = { + resultType: "complete", + content: [{ type: "text", text: "hi" }], + structuredContent: [1, 2, 3], + }; + port.response = { kind: "result", result: asJson(modernResult) }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("modern"); + expect(execution.kind).toBe("immediate"); + await expect(legacyResult(execution)).resolves.toEqual(modernResult); + await session.close(); + }); + it("rejects invalid requested retention before dispatch", async () => { const port = new FakePort({ generation: "v1", capabilities: {} }); const session = withTasks(port, { diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index 98f94e9..b0de23e 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -72,6 +72,7 @@ import { } from "./port.js"; import type { ConnectedMcpSessionPort, + DispatchContext, IncomingServerRequest, JsonRpcResponse, SessionTaskCapabilities, @@ -370,6 +371,7 @@ class PortTaskEnabledSession< lateResponse, generation, callAsTaskV1, + dispatchContext, ); }, () => {}, @@ -418,7 +420,12 @@ class PortTaskEnabledSession< } } catch (error) { if (response !== undefined) - this.cleanupLateTaskCreation(response, generation, callAsTaskV1); + this.cleanupLateTaskCreation( + response, + generation, + callAsTaskV1, + dispatchContext, + ); throw error; } finally { this.ordinaryInputCandidates.delete(executionId); @@ -473,7 +480,12 @@ class PortTaskEnabledSession< preference === "forbid" && isCreateTaskResultV2(wireResult) ) { - this.cleanupLateTaskCreation(response, generation, false); + this.cleanupLateTaskCreation( + response, + generation, + false, + dispatchContext, + ); throw new Error( "Task execution was forbidden but the server returned a task", ); @@ -637,39 +649,51 @@ class PortTaskEnabledSession< } } - private lateTaskCancellationParams( + private lateTaskCancellationTarget( result: JsonValue, generation: SessionTaskCapabilities["generation"], callAsTaskV1: boolean, - ): JsonValue | undefined { + ): { readonly generation: "v1" | "v2"; readonly taskId: TaskId } | undefined { if (generation === "v1" && callAsTaskV1) { const parsed = CreateTaskResultV1Schema.safeParse(result); if (!parsed.success) return undefined; - return { taskId: parsed.data.task.taskId as TaskId }; + return { generation: "v1", taskId: parsed.data.task.taskId as TaskId }; } if (generation !== "v2" || !isCreateTaskResultV2(result)) return undefined; const parsed = CreateTaskResultV2Schema.safeParse(result); if (!parsed.success) return undefined; - return withTaskCapabilityV2({ taskId: parsed.data.taskId as TaskId }); + return { generation: "v2", taskId: parsed.data.taskId as TaskId }; } private cleanupLateTaskCreation( response: JsonRpcResponse, generation: SessionTaskCapabilities["generation"], callAsTaskV1: boolean, + context?: DispatchContext, ): void { if (response.kind !== "result") return; - const params = this.lateTaskCancellationParams( + const target = this.lateTaskCancellationTarget( response.result, generation, callAsTaskV1, ); - if (params === undefined) return; - void dispatchWithRetry( - this.port, - { method: "tasks/cancel", params }, - undefined, - ).catch(() => { + if (target === undefined) return; + // Cancel through createTaskRpc because it owns generation-specific wire + // details — including the mandatory V2 Mcp-Name routing header that + // task-aware routing depends on. + const rpc = + target.generation === "v1" + ? createTaskRpc("v1", { + port: this.port, + taskId: target.taskId, + context, + }) + : createTaskRpc("v2", { + port: this.port, + taskId: target.taskId, + context, + }); + void rpc.cancel().catch(() => { // A task returned after call abort is cleaned up on a best-effort basis. }); } diff --git a/packages/ext-tasks/src/client/tool-declarations.ts b/packages/ext-tasks/src/client/tool-declarations.ts index dbb5cfd..6f8c078 100644 --- a/packages/ext-tasks/src/client/tool-declarations.ts +++ b/packages/ext-tasks/src/client/tool-declarations.ts @@ -32,17 +32,22 @@ export class ManagedToolDeclarations implements ToolDeclarationProvider { async ensureReady(signal?: AbortSignal): Promise { throwIfAborted(signal); const wait = async (): Promise => { - try { - await this.initialReady; - } catch (error) { - if ( - this.closed || - (error instanceof DOMException && error.name === "AbortError") - ) - throw error; - this.initialReady = this.refresh(); - void this.initialReady.catch(() => {}); - await this.initialReady; + let retried = false; + for (;;) { + const pending = this.initialReady; + try { + await pending; + return; + } catch (error) { + if (this.closed) throw error; + // Follow the replacement rather than failing permanently because a + // newer refresh superseded this one (early tools/list_changed). + if (this.initialReady !== pending) continue; + if (retried) throw error; + retried = true; + this.initialReady = this.refresh(); + void this.initialReady.catch(() => {}); + } } }; const waiting = wait(); @@ -81,7 +86,11 @@ export class ManagedToolDeclarations implements ToolDeclarationProvider { return; const record = notification as Readonly>; if (record.method !== "notifications/tools/list_changed") return; - void this.refresh().catch((error: unknown) => { + const refreshed = this.refresh(); + // Point readiness at the newest refresh because a superseded initial + // refresh's abort would otherwise leave ensureReady() permanently rejected. + this.initialReady = refreshed; + void refreshed.catch((error: unknown) => { if (!(error instanceof DOMException && error.name === "AbortError")) { this.reportError( error instanceof Error diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts index c65bcbb..07136e4 100644 --- a/packages/ext-tasks/src/client/v2-input-task.test.ts +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -129,6 +129,14 @@ describe("V2 input and task behavior", () => { ), ).toBe(true); }); + const cancelIndex = taskPort.requests.findIndex( + (request) => expectRecord(request).method === "tasks/cancel", + ); + // The cleanup cancel carries the mandatory V2 Mcp-Name routing header + // because late-task cleanup routes through the task RPC. + expect(taskPort.dispatchOptions[cancelIndex]?.context?.headers).toEqual({ + "Mcp-Name": "forbidden-task", + }); await taskSession.close(); }); From b7a221e25e7d5e9882ea22a43422d85631618613 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Thu, 10 Sep 2026 17:40:37 -0700 Subject: [PATCH 30/33] fix: address Copilot comments --- packages/ext-tasks/src/client/api.ts | 22 +++++++++- .../src/client/client-adapter.test.ts | 21 +++++++++- packages/ext-tasks/src/client/index.ts | 2 + packages/ext-tasks/src/client/internal.ts | 41 +++++++++++++++---- packages/ext-tasks/src/core/index.test.ts | 6 +++ packages/ext-tasks/src/core/index.ts | 6 ++- packages/ext-tasks/src/core/v2/index.test.ts | 38 ++++++++++++++++- packages/ext-tasks/src/core/v2/schemas.ts | 20 ++++++++- 8 files changed, 141 insertions(+), 15 deletions(-) diff --git a/packages/ext-tasks/src/client/api.ts b/packages/ext-tasks/src/client/api.ts index de3c2d6..3dbce86 100644 --- a/packages/ext-tasks/src/client/api.ts +++ b/packages/ext-tasks/src/client/api.ts @@ -159,9 +159,17 @@ export type ApplicationInputRequest = readonly params?: Readonly>; }; +/** + * Elicitation form values are limited to scalars and string arrays because + * that is the wire shape (`ElicitResult.content`); a wider type would let + * type-correct handlers fail only at response encoding. + */ +export type ApplicationElicitContentValue = + string | number | boolean | readonly string[]; + export interface ApplicationElicitResult { readonly action: "accept" | "decline" | "cancel"; - readonly content?: Readonly>; + readonly content?: Readonly>; } export type ApplicationCreateMessageResult = Readonly< @@ -172,8 +180,18 @@ export type ApplicationCreateMessageResult = Readonly< readonly content: JsonValue; }; +/** + * A root requires a string `uri` because the protocol does (`Root`); typing + * it as an arbitrary record would accept `{ roots: [{}] }` at compile time. + */ +export interface ApplicationRoot { + readonly uri: string; + readonly name?: string; + readonly _meta?: Readonly>; +} + export interface ApplicationListRootsResult { - readonly roots: readonly Readonly>[]; + readonly roots: readonly ApplicationRoot[]; } export type ApplicationInputResult = diff --git a/packages/ext-tasks/src/client/client-adapter.test.ts b/packages/ext-tasks/src/client/client-adapter.test.ts index 8bb3750..b92de1c 100644 --- a/packages/ext-tasks/src/client/client-adapter.test.ts +++ b/packages/ext-tasks/src/client/client-adapter.test.ts @@ -19,6 +19,7 @@ import type { CreateTaskSessionFromClientOptions, } from "./sdk-client-adapter.js"; import { ClientSessionPort } from "./sdk-client-adapter.js"; +import { projectTool, projectToolForGeneration } from "./internal.js"; const client = () => new Client({ name: "test", version: "1" }); const context = { @@ -722,7 +723,8 @@ describe("Client adapter", () => { }, vendorFlag: { enabled: true }, }; - expect(toolDeclarationFromMcpTool(extendedTool)).toEqual({ + const declaration = toolDeclarationFromMcpTool(extendedTool); + expect(declaration).toEqual({ name: "search", inputSchema: { type: "object", @@ -733,6 +735,23 @@ describe("Client adapter", () => { executionExtensions: { vendorExecution: { queue: "batch" } }, extensions: { vendorFlag: { enabled: true } }, }); + // Vendor execution data round-trips: V1 wire projection rebuilds the + // `execution` object from executionExtensions plus taskSupport, and + // re-decoding the projected tool retains it again. + const projected = projectToolForGeneration(declaration, "v1"); + expect(projected.execution).toEqual({ + vendorExecution: { queue: "batch" }, + taskSupport: "required", + }); + const redecoded = projectTool(projected); + expect(redecoded.taskSupport).toBe("required"); + expect(redecoded.executionExtensions).toEqual({ + vendorExecution: { queue: "batch" }, + }); + // V2 wire projection carries no execution member at all. + expect("execution" in projectToolForGeneration(declaration, "v2")).toBe( + false, + ); expect(() => toolDeclarationFromMcpTool({ name: "bad", inputSchema: true } as never), ).toThrow(/inputSchema must be a JSON object/); diff --git a/packages/ext-tasks/src/client/index.ts b/packages/ext-tasks/src/client/index.ts index 7a59d5e..3d13584 100644 --- a/packages/ext-tasks/src/client/index.ts +++ b/packages/ext-tasks/src/client/index.ts @@ -21,12 +21,14 @@ export { export { withRelatedTaskMetadata } from "./api.js"; export type { ApplicationCreateMessageResult, + ApplicationElicitContentValue, ApplicationElicitResult, ApplicationInputHandler, ApplicationInputCallbacks, ApplicationInputRequest, ApplicationInputResult, ApplicationListRootsResult, + ApplicationRoot, InputCorrelationCandidate, InputCorrelationFailureReason, ResolvedInputExchangeContext, diff --git a/packages/ext-tasks/src/client/internal.ts b/packages/ext-tasks/src/client/internal.ts index 0cfeb2b..cfbb707 100644 --- a/packages/ext-tasks/src/client/internal.ts +++ b/packages/ext-tasks/src/client/internal.ts @@ -119,17 +119,29 @@ export function projectTask(snapshot: InternalTaskSnapshot): TaskView { /** Projects a generated tool declaration to the neutral declaration shape. */ export function projectTool(tool: ToolV1 | ToolV2): ToolDeclaration { const raw = jsonRecord(tool); - const taskSupport = + const execution = "execution" in tool && tool.execution !== undefined && tool.execution !== null && typeof tool.execution === "object" && - "taskSupport" in tool.execution && - (tool.execution.taskSupport === "forbidden" || - tool.execution.taskSupport === "optional" || - tool.execution.taskSupport === "required") - ? tool.execution.taskSupport + !Array.isArray(tool.execution) + ? (tool.execution as Readonly>) + : undefined; + const taskSupport = + execution !== undefined && + (execution.taskSupport === "forbidden" || + execution.taskSupport === "optional" || + execution.taskSupport === "required") + ? execution.taskSupport : undefined; + // Retain non-taskSupport execution fields because the neutral declaration + // promises them back for inspection and projection (executionExtensions). + const executionExtensions = + execution === undefined + ? undefined + : Object.fromEntries( + Object.entries(execution).filter(([key]) => key !== "taskSupport"), + ); const known = new Set([ "name", "title", @@ -157,6 +169,10 @@ export function projectTool(tool: ToolV1 | ToolV2): ToolDeclaration { ...(tool.icons === undefined ? {} : { icons: tool.icons }), ...(tool._meta === undefined ? {} : { metadata: tool._meta }), ...(taskSupport === undefined ? {} : { taskSupport }), + ...(executionExtensions === undefined || + Object.keys(executionExtensions).length === 0 + ? {} + : { executionExtensions }), extensions: Object.fromEntries( Object.entries(raw).filter(([key]) => !known.has(key)), ), @@ -200,12 +216,19 @@ export function projectToolForGeneration( ? {} : { _meta: declaration.metadata }), }; + // Rebuild `execution` from executionExtensions plus taskSupport because the + // neutral declaration retains vendor execution data for round-tripping; + // taskSupport is spread last so the recognized field wins a key collision. + const execution = { + ...declaration.executionExtensions, + ...(declaration.taskSupport === undefined + ? {} + : { taskSupport: declaration.taskSupport }), + }; return generation === "v1" ? { ...common, - ...(declaration.taskSupport === undefined - ? {} - : { execution: { taskSupport: declaration.taskSupport } }), + ...(Object.keys(execution).length === 0 ? {} : { execution }), } : common; } diff --git a/packages/ext-tasks/src/core/index.test.ts b/packages/ext-tasks/src/core/index.test.ts index 9910b91..c30365a 100644 --- a/packages/ext-tasks/src/core/index.test.ts +++ b/packages/ext-tasks/src/core/index.test.ts @@ -47,6 +47,11 @@ describe("core runtime contracts", () => { it("rejects exotic, cyclic, sparse, and non-finite values", () => { const sparse: unknown[] = []; sparse.length = 1; + // A hole plus an enumerable extra key keeps length === key count while + // every() skips the hole; the index-ownership check must still reject it. + const disguisedSparse: unknown[] = []; + disguisedSparse.length = 1; + (disguisedSparse as unknown[] & { extra?: string }).extra = "x"; const cyclic: Record = {}; cyclic.self = cyclic; class Exotic { @@ -63,6 +68,7 @@ describe("core runtime contracts", () => { new Date(), new Map(), sparse, + disguisedSparse, cyclic, new Exotic(), Object.create({ inherited: true }) as object, diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts index 1713904..1ca780f 100644 --- a/packages/ext-tasks/src/core/index.ts +++ b/packages/ext-tasks/src/core/index.ts @@ -161,7 +161,11 @@ export function isJsonValue(value: unknown): value is JsonValue { if (visiting.has(candidate)) return false; visiting.add(candidate); const valid = Array.isArray(candidate) - ? candidate.length === Object.keys(candidate).length && + ? // Check every index is an own property because a hole plus an + // enumerable extra key keeps the key count equal while `every()` + // skips the hole — serialization would then change the value. + candidate.length === Object.keys(candidate).length && + Array.from(candidate.keys()).every((index) => index in candidate) && candidate.every(visit) : isJsonObject(candidate, visit); visiting.delete(candidate); diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts index 2762b79..f7d8dcf 100644 --- a/packages/ext-tasks/src/core/v2/index.test.ts +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -291,7 +291,30 @@ describe("V2 runtime wire contracts", () => { "cancel" as const, ), }), - fc.record({ roots: fc.array(fc.jsonValue()) }), + fc.record( + { + action: fc.constant("accept" as const), + content: fc.dictionary( + fc.string(), + fc.oneof( + fc.string(), + fc.integer(), + fc.boolean(), + fc.array(fc.string()), + ), + ), + }, + { requiredKeys: ["action"] }, + ), + // Roots require a string uri; extra fields ride along openly. + fc.record({ + roots: fc.array( + fc.record( + { uri: fc.string(), name: fc.string() }, + { requiredKeys: ["uri"] }, + ), + ), + }), fc.record({ content: fc.oneof( fc.record({ @@ -318,6 +341,19 @@ describe("V2 runtime wire contracts", () => { ), ); expect(InputResponsesV2Schema.safeParse({ key: {} }).success).toBe(false); + // A root without a string uri is rejected rather than forwarded. + expect( + InputResponsesV2Schema.safeParse({ key: { roots: [null] } }).success, + ).toBe(false); + expect( + InputResponsesV2Schema.safeParse({ key: { roots: [{}] } }).success, + ).toBe(false); + // Elicitation content values outside the scalar/string-array union fail. + expect( + InputResponsesV2Schema.safeParse({ + key: { action: "accept", content: { nested: { object: true } } }, + }).success, + ).toBe(false); }); it("decodes complete JSON-RPC errors", () => { diff --git a/packages/ext-tasks/src/core/v2/schemas.ts b/packages/ext-tasks/src/core/v2/schemas.ts index d51b8cc..1dfc528 100644 --- a/packages/ext-tasks/src/core/v2/schemas.ts +++ b/packages/ext-tasks/src/core/v2/schemas.ts @@ -251,11 +251,29 @@ const CreateMessageResultV2Schema = openObject({ role: z.enum(["user", "assistant"]), stopReason: z.string().optional(), }); +// Roots require a string `uri` (file:// for now) with optional `name`/_meta; +// accepting arbitrary JSON here would let invalid handler output reach +// `tasks/update` (pinned schema 2026-07-28 `Root`). +const RootV2Schema = openObject({ + uri: z.string(), + name: z.string().optional(), + _meta: MetaSchema.optional(), +}); const ListRootsResultV2Schema = openObject({ - roots: z.array(JsonValueSchema), + roots: z.array(RootV2Schema), }); +// Elicitation form values are limited to scalars and string arrays (pinned +// schema 2026-07-28 `ElicitResult.content`); an open record would forward +// arbitrary JSON to `tasks/update`. +const ElicitContentValueV2Schema = z.union([ + z.string(), + z.int(), + z.boolean(), + z.array(z.string()), +]); const ElicitResultV2Schema = openObject({ action: z.enum(["accept", "decline", "cancel"]), + content: z.record(z.string(), ElicitContentValueV2Schema).optional(), }); // Response shapes overlap, so this union is intentionally non-discriminated. const InputResponseUnionV2Schema = z.union([ From 6930643afda04a9756a623e732d306380f3608da Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 11 Sep 2026 11:39:14 -0700 Subject: [PATCH 31/33] fix: address Copilot comments --- packages/ext-tasks/src/client/execution.ts | 11 ++- .../src/client/task-lifecycle-races.test.ts | 17 ++--- .../ext-tasks/src/client/task-protocol-v2.ts | 7 +- .../src/client/v2-input-task.test.ts | 67 +++++++++++++++++++ packages/ext-tasks/src/core/v2/index.test.ts | 22 ++++++ packages/ext-tasks/src/core/v2/integration.ts | 11 ++- packages/ext-tasks/src/receiver/index.ts | 37 +++++++--- .../ext-tasks/src/receiver/receiver.test.ts | 52 ++++++++++++++ 8 files changed, 203 insertions(+), 21 deletions(-) diff --git a/packages/ext-tasks/src/client/execution.ts b/packages/ext-tasks/src/client/execution.ts index c7c7fa3..11aa576 100644 --- a/packages/ext-tasks/src/client/execution.ts +++ b/packages/ext-tasks/src/client/execution.ts @@ -578,7 +578,16 @@ export class TaskExecution< cancel(signal?: AbortSignal): Promise { throwIfAborted(signal); - this.cancelPromise ??= this.cancelTask(this.cancellationController.signal); + // The server's cancel ack ends the local lifetime because V2 cancellation + // is cooperative: the server may never reach `cancelled`, so waiting for + // a terminal snapshot could leave result() pending forever. + this.cancelPromise ??= this.cancelTask( + this.cancellationController.signal, + ).then(() => { + this.releaseLifecycleListener?.(); + this.inputController.abort(this.cancelledError); + this.controller.abort(this.cancelledError); + }); return signal === undefined ? this.cancelPromise : withAbort(this.cancelPromise, signal); diff --git a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts index 3387b58..2ede1ce 100644 --- a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it, vi } from "vitest"; import { DispatchError, JsonRpcResponseError, + TaskCancelledError, TaskExecutionClosedError, - TaskFailedError, TaskUpdatesAlreadyAcquiredError, toolDeclaration, withTasks, @@ -178,8 +178,9 @@ describe("task lifecycle and races", () => { }, }); await execution.close(); + // Cancelled, not closed: the awaited cancel settled the outcome first. await expect(legacyResult(execution)).rejects.toBeInstanceOf( - TaskExecutionClosedError, + TaskCancelledError, ); await session.close(); }); @@ -237,11 +238,9 @@ describe("task lifecycle and races", () => { await new Promise((resolve) => setImmediate(resolve)); expect(unhandled).toEqual([]); const outcome = await result; - expect(outcome.status).toBe("failed"); - if (outcome.status !== "failed") - throw new Error("Expected failed outcome"); - expect(outcome.error).toBeInstanceOf(TaskFailedError); - expect(outcome.error.cause).toBeInstanceOf(TaskExecutionClosedError); + // Cancelled, not failed-as-closed: the cancel ack settled the outcome + // before close ran. + expect(outcome.status).toBe("cancelled"); } finally { process.off("unhandledRejection", onUnhandledRejection); } @@ -406,8 +405,10 @@ describe("task lifecycle and races", () => { else await expect(execution.cancel()).rejects.toThrow("cancel failed"); expect(cancelCalls).toBe(retryable ? 2 : 1); await execution.close(); + // A successful cancel settles the outcome as cancelled; only a + // failed one leaves close to decide it. await expect(legacyResult(execution)).rejects.toBeInstanceOf( - TaskExecutionClosedError, + retryable ? TaskCancelledError : TaskExecutionClosedError, ); await session.close(); }), diff --git a/packages/ext-tasks/src/client/task-protocol-v2.ts b/packages/ext-tasks/src/client/task-protocol-v2.ts index 2212ea7..92eda4f 100644 --- a/packages/ext-tasks/src/client/task-protocol-v2.ts +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -306,10 +306,11 @@ async function resolveInputRequest(args: { request, ); if (acquisition.kind !== "new") { + // Throwing fails the execution because reusing a key with a different + // request is a protocol violation: the client already answered this key, + // and polling on would act on input the application never saw. if (acquisition.kind === "incompatible") - inputContext.reportError( - new Error(`V2 task input key ${inputKey} was reused incompatibly`), - ); + throw new Error(`V2 task input key ${inputKey} was reused incompatibly`); return undefined; } diff --git a/packages/ext-tasks/src/client/v2-input-task.test.ts b/packages/ext-tasks/src/client/v2-input-task.test.ts index 07136e4..0dadc8b 100644 --- a/packages/ext-tasks/src/client/v2-input-task.test.ts +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -441,6 +441,73 @@ describe("V2 input and task behavior", () => { await session.close(); }); + it("fails the execution when a committed V2 input key is reused incompatibly", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + let getCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "reuse-incompatible", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (method === "tasks/get") { + getCalls += 1; + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "reuse-incompatible", + status: "input_required", + createdAt: "a", + lastUpdatedAt: String(getCalls), + ttlMs: null, + inputRequests: { + same: + getCalls === 1 + ? { method: "roots/list" } + : { method: "elicitation/create", params: {} }, + }, + }), + }; + } + if (method === "tasks/update") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + onInputRequest: async (request) => { + await Promise.resolve(); + // `as never`, because the generic signature cannot relate a runtime + // kind branch to TRequest (the suite-wide fake-handler pattern). + return ( + request.kind === "roots" + ? { roots: [{ uri: "file:///workspace" }] } + : { action: "cancel" } + ) as never; + }, + }); + const execution = await session.callTool("x"); + // Key "same" returns with a different request shape — a protocol + // violation that must fail the execution, not keep it polling. + await expect(legacyResult(execution)).rejects.toThrow( + "reused incompatibly", + ); + await session.close(); + }); + it("declines keyed V2 elicitation while withholding sampling and roots", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); let getCalls = 0; diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts index f7d8dcf..7300ea2 100644 --- a/packages/ext-tasks/src/core/v2/index.test.ts +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -902,4 +902,26 @@ describe("V2 runtime wire contracts", () => { ).toBe(true); expect(hasTaskServerCapabilityV2({ extensions: {} })).toBe(false); }); + + it("merges the tasks capability into existing client capabilities", () => { + const wire = withTaskCapabilityV2({ + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + sampling: {}, + extensions: { "example.com/other": { enabled: true } }, + }, + }, + }); + expect(wire).toEqual({ + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + sampling: {}, + extensions: { + "example.com/other": { enabled: true }, + "io.modelcontextprotocol/tasks": {}, + }, + }, + }, + }); + }); }); diff --git a/packages/ext-tasks/src/core/v2/integration.ts b/packages/ext-tasks/src/core/v2/integration.ts index 6ebb6a6..043ac87 100644 --- a/packages/ext-tasks/src/core/v2/integration.ts +++ b/packages/ext-tasks/src/core/v2/integration.ts @@ -117,7 +117,16 @@ export function withTaskCapabilityV2< T extends Readonly>, >(params: T): T & Readonly> { const existingMetadata = asObjectRecord(params._meta) ?? {}; - const capability = { extensions: { [TASKS_EXTENSION_ID_V2]: {} } }; + // Merging (like frameV2TaskRequest) because replacing the entry would drop + // any capabilities and extensions the caller already declared. + const existingCapability = + asObjectRecord(existingMetadata[CLIENT_CAPABILITIES_META_KEY_V2]) ?? {}; + const existingExtensions = + asObjectRecord(existingCapability.extensions) ?? {}; + const capability = { + ...existingCapability, + extensions: { ...existingExtensions, [TASKS_EXTENSION_ID_V2]: {} }, + }; return { ...params, _meta: { diff --git a/packages/ext-tasks/src/receiver/index.ts b/packages/ext-tasks/src/receiver/index.ts index 4f65573..ea47c6a 100644 --- a/packages/ext-tasks/src/receiver/index.ts +++ b/packages/ext-tasks/src/receiver/index.ts @@ -160,6 +160,30 @@ function hasTaskAugmentation(request: unknown): boolean { ); } +function asTaskAugmentationRecord( + value: JsonValue, +): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function validateTaskAugmentation(request: unknown): void { + const params = paramsOf(request); + // Absent is fine: without a prior handler, a plain request runs as a task. + // Object.hasOwn, because indexing types absent keys as JsonValue. + if (!Object.hasOwn(params, "task")) return; + // A present augmentation must be an object (the 2025-11-25 shape); + // `task: true` would leave us guessing at what the requester meant. + const augmentation = asTaskAugmentationRecord(params.task); + if (augmentation === undefined) + throw new Error("Task augmentation must be a JSON object"); + if (!Object.hasOwn(augmentation, "ttl") || augmentation.ttl === null) return; + const ttl = augmentation.ttl; + if (typeof ttl !== "number" || !Number.isInteger(ttl) || ttl < 0) + throw new RangeError("task.ttl must be a non-negative integer or null"); +} + function taskIdOf(request: unknown): string { const taskId = paramsOf(request).taskId; if (typeof taskId !== "string") @@ -328,6 +352,7 @@ export function bindTaskReceiver( method, (raw) => { expire(); + validateTaskAugmentation(raw); const params = paramsOf(raw); if (tasks.size >= maxTasks) throw new Error( @@ -439,15 +464,11 @@ export function bindTaskReceiver( install("tasks/get", (request) => Promise.resolve(snapshot(get(taskIdOf(request)))), ); - install("tasks/result", async (request) => { + install("tasks/result", (request) => { const record = get(taskIdOf(request)); - if ( - record.task.status === "working" || - record.task.status === "input_required" - ) - throw new Error("Task is not terminal"); - if (record.task.status === "cancelled") - throw new Error("Task was cancelled"); + // Returning the still-pending promise blocks the response until the task + // settles, because tasks/result is a blocking call per 2025-11-25; the + // promise already rejects on failure, cancellation, expiry, and close. return record.result; }); install("tasks/cancel", (request) => { diff --git a/packages/ext-tasks/src/receiver/receiver.test.ts b/packages/ext-tasks/src/receiver/receiver.test.ts index 6e305a2..04cce54 100644 --- a/packages/ext-tasks/src/receiver/receiver.test.ts +++ b/packages/ext-tasks/src/receiver/receiver.test.ts @@ -161,6 +161,58 @@ describe("bindTaskReceiver", () => { ).rejects.toThrow("expired"); }); + it("rejects malformed task augmentations and invalid augmentation TTLs", async () => { + const host = new Host(); + bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + sampling: () => Promise.resolve({ ok: true }), + createTaskId: () => "augmented", + }); + // `task: true` is not a spec shape: the augmentation is an object. + await expect( + host.call("sampling/createMessage", { task: true }), + ).rejects.toThrow("Task augmentation must be a JSON object"); + await expect( + host.call("sampling/createMessage", { task: [1] }), + ).rejects.toThrow("Task augmentation must be a JSON object"); + await expect( + host.call("sampling/createMessage", { task: { ttl: -1 } }), + ).rejects.toThrow("task.ttl must be a non-negative integer or null"); + await expect( + host.call("sampling/createMessage", { task: { ttl: 1.5 } }), + ).rejects.toThrow("task.ttl must be a non-negative integer or null"); + // A well-formed augmentation still creates the task. + await expect( + host.call("sampling/createMessage", { task: { ttl: null } }), + ).resolves.toMatchObject({ task: { taskId: "augmented" } }); + }); + + it("blocks tasks/result until the task settles instead of rejecting while pending", async () => { + const host = new Host(); + const work = deferred>(); + bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + sampling: () => work.promise, + createTaskId: () => "blocking", + }); + await host.call("sampling/createMessage"); + // Issued mid-work: it must block (per 2025-11-25), not reject. + let settled = false; + const pending = host.call("tasks/result", { taskId: "blocking" }); + void pending.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await flush(); + expect(settled).toBe(false); + work.resolve({}); + await expect(pending).resolves.toEqual({}); + }); + it("advertises and installs only enabled request methods", () => { const host = new Host(); const binding = bindTaskReceiver(asClient(host), { From d46dd3e7e7e9c21f8007d35bd2c1040f7e30a81e Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 11 Sep 2026 14:46:55 -0700 Subject: [PATCH 32/33] fix: address Copilot comments --- packages/ext-tasks/src/client/api.ts | 4 + packages/ext-tasks/src/client/session.ts | 206 +++++++++++------- .../src/client/task-controller.test.ts | 84 +++++-- .../ext-tasks/src/client/task-controller.ts | 31 ++- .../src/client/task-lifecycle-races.test.ts | 54 +++++ .../src/client/task-resumption.test.ts | 82 +++++++ typescript/client/input-and-recovery.md | 2 +- 7 files changed, 365 insertions(+), 98 deletions(-) diff --git a/packages/ext-tasks/src/client/api.ts b/packages/ext-tasks/src/client/api.ts index 3dbce86..b2135cb 100644 --- a/packages/ext-tasks/src/client/api.ts +++ b/packages/ext-tasks/src/client/api.ts @@ -497,6 +497,10 @@ export interface TaskRecoveryOptions { readonly signal?: AbortSignal; /** Execution-scoped declaration. Takes precedence over the session provider. */ readonly declaration?: ToolDeclaration; + /** Additional headers for the initial lookup and every follow-up request. */ + readonly headers?: Readonly>; + /** Per-request timeout in milliseconds for the lookup and follow-ups. */ + readonly requestTimeoutMs?: number; } export interface TaskEnabledSession { diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index b0de23e..472ff7d 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -426,105 +426,133 @@ class PortTaskEnabledSession< callAsTaskV1, dispatchContext, ); + callLifecycle.dispose(); throw error; } finally { this.ordinaryInputCandidates.delete(executionId); - callLifecycle.dispose(); } const wireResult = responseResult(response); const codec = selectResultCodec(generation, options.resultCodec); - - if (generation === "v1" && callAsTaskV1) { - const created = parseResult(CreateTaskResultV1Schema, wireResult); - const handle: InternalTaskHandle & { readonly generation: "v1" } = { - generation: "v1", - taskId: created.task.taskId as TaskId, - originalOperation: "tools/call", - }; - const releaseTaskIdentity = this.acquireTaskIdentity(handle); - const execution = createTaskExecutionV1({ - applicationContext: options.applicationContext as TApplicationContext, - handle, - declaration, - initialTask: created.task, - resultCodec: codec, - port: this.port, - dispatchContext, - lifecycleSignal: this.lifecycleController.signal, + // Task-backed executions receive the linked call lifecycle and dispose it + // only once they settle, because options.signal documents that it bounds + // the operation's local lifecycle — disposing here would disconnect it + // the moment tools/call returns. Immediate and error paths dispose now. + // The flag is set at the call sites rather than inside the helper so the + // finally's check reads an assignment flow analysis can see. + let lifecycleTransferred = false; + const transferLifecycle = ( + execution: TaskExecution, + ): void => { + void execution.result().finally(() => { + callLifecycle.dispose(); }); - return this.trackTaskExecution( - execution, - { - lifetime: "task-v1", + }; + try { + if (generation === "v1" && callAsTaskV1) { + const created = parseResult(CreateTaskResultV1Schema, wireResult); + const handle: InternalTaskHandle & { readonly generation: "v1" } = { generation: "v1", taskId: created.task.taskId as TaskId, - toolName: name, - executionId, - applicationContext: options.applicationContext as TApplicationContext, - signal: execution.inputSignal(), - }, - releaseTaskIdentity, - ); - } - - if ( - generation === "v2" && - preference === "require" && - !isCreateTaskResultV2(wireResult) - ) - throw new Error( - "Task execution was required but the server returned an immediate result", - ); - if ( - generation === "v2" && - preference === "forbid" && - isCreateTaskResultV2(wireResult) - ) { - this.cleanupLateTaskCreation( - response, - generation, - false, - dispatchContext, - ); - throw new Error( - "Task execution was forbidden but the server returned a task", - ); - } - - if (generation === "v2" && isCreateTaskResultV2(wireResult)) { - const created = parseResult(CreateTaskResultV2Schema, wireResult); - const handle: InternalTaskHandle & { readonly generation: "v2" } = { - generation: "v2", - taskId: created.taskId as TaskId, - originalOperation: "tools/call", - }; - const releaseTaskIdentity = this.acquireTaskIdentity(handle); - return this.trackTaskExecution( - createTaskExecutionV2({ + originalOperation: "tools/call", + }; + const releaseTaskIdentity = this.acquireTaskIdentity(handle); + const execution = createTaskExecutionV1({ applicationContext: options.applicationContext as TApplicationContext, - declaration, handle, - initialTask: created, + declaration, + initialTask: created.task, resultCodec: codec, port: this.port, dispatchContext, - lifecycleSignal: this.lifecycleController.signal, - onInputRequest: this.options.onInputRequest, - reportError: (error) => { - this.reportBackgroundError(error); + // callSignal, not the bare session signal: it already links the + // session lifecycle with options.signal, keeping the caller's abort + // effective for the task's whole local lifetime. + lifecycleSignal: callSignal, + }); + const tracked = this.trackTaskExecution( + execution, + { + lifetime: "task-v1", + generation: "v1", + taskId: created.task.taskId as TaskId, + toolName: name, + executionId, + applicationContext: + options.applicationContext as TApplicationContext, + signal: execution.inputSignal(), }, - }), - undefined, - releaseTaskIdentity, + releaseTaskIdentity, + ); + lifecycleTransferred = true; + transferLifecycle(tracked); + return tracked; + } + + if ( + generation === "v2" && + preference === "require" && + !isCreateTaskResultV2(wireResult) + ) + throw new Error( + "Task execution was required but the server returned an immediate result", + ); + if ( + generation === "v2" && + preference === "forbid" && + isCreateTaskResultV2(wireResult) + ) { + this.cleanupLateTaskCreation( + response, + generation, + false, + dispatchContext, + ); + throw new Error( + "Task execution was forbidden but the server returned a task", + ); + } + + if (generation === "v2" && isCreateTaskResultV2(wireResult)) { + const created = parseResult(CreateTaskResultV2Schema, wireResult); + const handle: InternalTaskHandle & { readonly generation: "v2" } = { + generation: "v2", + taskId: created.taskId as TaskId, + originalOperation: "tools/call", + }; + const releaseTaskIdentity = this.acquireTaskIdentity(handle); + const tracked = this.trackTaskExecution( + createTaskExecutionV2({ + applicationContext: + options.applicationContext as TApplicationContext, + declaration, + handle, + initialTask: created, + resultCodec: codec, + port: this.port, + dispatchContext, + lifecycleSignal: callSignal, + onInputRequest: this.options.onInputRequest, + reportError: (error) => { + this.reportBackgroundError(error); + }, + }), + undefined, + releaseTaskIdentity, + ); + lifecycleTransferred = true; + transferLifecycle(tracked); + return tracked; + } + + const resultPromise = Promise.resolve(parseResult(codec, wireResult)); + return new ImmediateExecution( + options.applicationContext as TApplicationContext, + resultPromise, + declaration, ); + } finally { + if (!lifecycleTransferred) callLifecycle.dispose(); } - - const resultPromise = Promise.resolve(parseResult(codec, wireResult)); - return new ImmediateExecution( - options.applicationContext as TApplicationContext, - resultPromise, - declaration, - ); } async resumeTask( @@ -560,6 +588,16 @@ class PortTaskEnabledSession< const resumeSignal = resumeLifecycle.signal; const executionId = nextExecutionIdentifier(); const codec = selectResultCodec(reference.generation, options.resultCodec); + // The same wire context as callTool, because a task resumed without its + // original headers/timeout would silently drop per-request authentication + // on every follow-up. + const dispatchContext = + options.headers === undefined && options.requestTimeoutMs === undefined + ? undefined + : { + headers: options.headers, + requestTimeoutMs: options.requestTimeoutMs, + }; try { throwIfAborted(resumeSignal); this.assertUsable(); @@ -572,6 +610,7 @@ class PortTaskEnabledSession< : await createTaskRpc(reference.generation, { port: this.port, taskId: reference.taskId, + context: dispatchContext, }).get(resumeSignal); this.assertUsable(); throwIfAborted(resumeSignal); @@ -582,6 +621,7 @@ class PortTaskEnabledSession< initialTask: task, resultCodec: codec, port: this.port, + dispatchContext, lifecycleSignal: this.lifecycleController.signal, }); const tracked = this.trackTaskExecution( @@ -615,6 +655,7 @@ class PortTaskEnabledSession< ? await createTaskRpc(reference.generation, { port: this.port, taskId: reference.taskId, + context: dispatchContext, }).get(resumeSignal) : seededDetailedTask; const task = seededTask ?? detailedTask; @@ -630,6 +671,7 @@ class PortTaskEnabledSession< initialDetailedTask: detailedTask, resultCodec: codec, port: this.port, + dispatchContext, lifecycleSignal: this.lifecycleController.signal, onInputRequest: this.options.onInputRequest, reportError: (error) => { diff --git a/packages/ext-tasks/src/client/task-controller.test.ts b/packages/ext-tasks/src/client/task-controller.test.ts index ae786a2..667085f 100644 --- a/packages/ext-tasks/src/client/task-controller.test.ts +++ b/packages/ext-tasks/src/client/task-controller.test.ts @@ -65,6 +65,8 @@ describe("manual task controller", () => { await expect(legacyResult(controller)).resolves.toEqual({ content: [] }); await expect(controller.cancel()).resolves.toBeUndefined(); expect(methods(port)).toEqual([ + "tasks/get", + // result() now polls tasks/get to a terminal state before tasks/result. "tasks/get", "tasks/result", "tasks/cancel", @@ -222,7 +224,15 @@ describe("manual task controller", () => { generation: "v1", capabilities: { requests: { tools: { call: {} } } }, }); - port.response = { kind: "result", result: { custom: "v1" } }; + port.dispatchHandler = (request) => + Promise.resolve( + expectRecord(request).method === "tasks/get" + ? { + kind: "result", + result: asJson({ ...v1Task, taskId: "custom-v1" }), + } + : { kind: "result", result: { custom: "v1" } }, + ); const session = withTasks(port, { tools }); await expect( @@ -234,7 +244,7 @@ describe("manual task controller", () => { }), }, }), - ).resolves.toEqual({ status: "completed", result: "v1" }); + ).resolves.toMatchObject({ status: "completed", result: "v1" }); await session.close(); }); @@ -272,17 +282,27 @@ describe("manual task controller", () => { capabilities: { requests: { tools: { call: {} } } }, }) : new FakePort({ generation, capabilities: {} }); - port.response = - generation === "v1" - ? { kind: "result", result: { custom: generation } } - : { - kind: "result", - result: asJson({ - ...v2CompletedTask, - taskId: `failing-${generation}`, - result: { custom: generation }, - }), - }; + // The V1 result path polls tasks/get first, so its fake must answer + // both methods; V2 answers everything with the terminal snapshot. + if (generation === "v1") + port.dispatchHandler = (request) => + Promise.resolve( + expectRecord(request).method === "tasks/get" + ? { + kind: "result", + result: asJson({ ...v1Task, taskId: "failing-v1" }), + } + : { kind: "result", result: { custom: generation } }, + ); + else + port.response = { + kind: "result", + result: asJson({ + ...v2CompletedTask, + taskId: `failing-${generation}`, + result: { custom: generation }, + }), + }; const codecError = new ProtocolDecodeError(`${generation} codec failed`); const session = withTasks(port, { tools }); @@ -410,6 +430,44 @@ describe("manual task controller", () => { } }); + it("surfaces cancelled and failed V1 tasks without calling tasks/result", async () => { + for (const terminal of [ + { status: "cancelled" }, + { status: "failed", statusMessage: "boom" }, + ] as const) { + const port = new FakePort({ + generation: "v1", + capabilities: { requests: { tools: { call: {} } } }, + }); + port.dispatchHandler = (request) => { + const method = expectRecord(request).method; + // A V1 server errors on tasks/result for any non-completed task — + // reaching it from this test would prove the classification wrong. + if (method === "tasks/result") + throw new Error("tasks/result must not be called"); + return Promise.resolve({ + kind: "result", + result: asJson({ ...v1Task, taskId: "terminal-v1", ...terminal }), + }); + }; + const session = withTasks(port, { tools }); + const outcome = await session.task(taskId("terminal-v1")).result(); + if (terminal.status === "cancelled") { + expect(outcome).toMatchObject({ + status: "cancelled", + task: { taskId: "terminal-v1", status: "cancelled" }, + }); + } else { + expect(outcome).toMatchObject({ + status: "failed", + error: { message: "boom" }, + task: { taskId: "terminal-v1", status: "failed" }, + }); + } + await session.close(); + } + }); + it("propagates caller aborts before and during dispatch", async () => { const port = new FakePort({ generation: "v2", capabilities: {} }); const session = withTasks(port, { tools }); diff --git a/packages/ext-tasks/src/client/task-controller.ts b/packages/ext-tasks/src/client/task-controller.ts index ecd81ef..de8452c 100644 --- a/packages/ext-tasks/src/client/task-controller.ts +++ b/packages/ext-tasks/src/client/task-controller.ts @@ -16,6 +16,7 @@ import type { TaskResultOptions, } from "./api.js"; import { + DEFAULT_TASK_POLL_INTERVAL_MS, defaultResultCodec, taskPollInterval, terminalStatus, @@ -109,8 +110,34 @@ export function createTaskController( ) { const codec = selectResultCodec(generation, resultOptions.resultCodec); return runOperation(resultOptions.signal, async (operationSignal) => { - if (rpc.generation === "v1") - return completedOutcome(rpc.result(codec, operationSignal)); + if (rpc.generation === "v1") { + // Poll to a terminal state before touching tasks/result, because a + // V1 server errors on any non-completed task — calling it directly + // would turn a cancelled task into a failed outcome. + let task = await rpc.get(operationSignal); + while (!terminalStatus(task.status)) { + await waitForTaskPoll( + Math.max( + DEFAULT_TASK_POLL_INTERVAL_MS, + task.pollInterval ?? DEFAULT_TASK_POLL_INTERVAL_MS, + ), + operationSignal, + ); + task = await rpc.get(operationSignal); + } + const view = projectTask({ generation: "v1", task }); + const terminalTask = task; + return completedOutcome( + Promise.resolve().then(() => { + if (terminalTask.status === "cancelled") + throw new TaskCancelledError(); + if (terminalTask.status === "failed") + throw new Error(terminalTask.statusMessage ?? "Task failed"); + return rpc.result(codec, operationSignal); + }), + view, + ); + } let task: DetailedTaskV2 = await rpc.get(operationSignal); while (!terminalStatus(task.status)) { diff --git a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts index 2ede1ce..78aeb87 100644 --- a/packages/ext-tasks/src/client/task-lifecycle-races.test.ts +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -294,6 +294,60 @@ describe("task lifecycle and races", () => { ); }); + it("keeps the caller's signal bounding the task after callTool returns", async () => { + const port = new FakePort({ generation: "v2", capabilities: {} }); + port.dispatchHandler = async (request, options) => { + const record = expectRecord(request); + if (record.method === "tools/call") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "caller-signal", + status: "working", + createdAt: "a", + lastUpdatedAt: "a", + ttlMs: null, + }), + }; + if (record.method === "tasks/get") + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => { + reject(asError(options.signal?.reason)); + }, + { once: true }, + ); + }); + throw new Error(`unexpected method ${formatJson(record.method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const caller = new AbortController(); + const execution = await session.callTool( + "x", + {}, + { + signal: caller.signal, + }, + ); + const reason = new Error("caller stopped"); + // Aborting after callTool resolved must still end the task's local + // lifecycle — the documented contract is that the signal bounds the + // whole operation, not only the initiating call. + caller.abort(reason); + const outcome = await execution.result(); + expect(outcome.status).toBe("failed"); + if (outcome.status !== "failed") throw new Error("expected failed"); + expect(outcome.error.cause).toBe(reason); + await session.close(); + }); + it("retries task observations only for retryable DispatchError", async () => { await fc.assert( fc.asyncProperty(fc.boolean(), async (retryable) => { diff --git a/packages/ext-tasks/src/client/task-resumption.test.ts b/packages/ext-tasks/src/client/task-resumption.test.ts index 396aec7..c875436 100644 --- a/packages/ext-tasks/src/client/task-resumption.test.ts +++ b/packages/ext-tasks/src/client/task-resumption.test.ts @@ -387,6 +387,88 @@ describe("task reference resumption", () => { ); }); + it("preserves recovery headers and request timeout on the lookup and every follow-up", async () => { + for (const generation of ["v1", "v2"] as const) { + const capabilities: SessionTaskCapabilities = + generation === "v1" + ? { + generation: "v1", + capabilities: { + requests: { tools: { call: {} } }, + cancel: {}, + }, + } + : { generation: "v2", capabilities: {} }; + const port = new FakePort(capabilities, "ctx-endpoint"); + let getCalls = 0; + port.dispatchHandler = async (request) => { + await Promise.resolve(); + const method = expectRecord(request).method; + if (method === "tasks/get") { + getCalls += 1; + const terminal = getCalls > 1; + return generation === "v1" + ? { + kind: "result", + result: asJson({ + taskId: "ctx-task", + status: terminal ? "completed" : "working", + createdAt: "a", + lastUpdatedAt: terminal ? "b" : "a", + ttl: null, + pollInterval: 0, + }), + } + : { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "ctx-task", + status: terminal ? "completed" : "working", + createdAt: "a", + lastUpdatedAt: terminal ? "b" : "a", + ttlMs: null, + pollIntervalMs: 0, + ...(terminal + ? { result: { resultType: "complete", content: [] } } + : {}), + }), + }; + } + if (method === "tasks/result") + return { kind: "result", result: asJson({ content: [] }) }; + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const resumed = await session.resumeTask( + { + endpointId: "ctx-endpoint", + generation, + taskId: "ctx-task" as TaskId, + originalOperation: "tools/call", + }, + { + headers: { authorization: "Bearer resumed" }, + requestTimeoutMs: 5_000, + }, + ); + await expect(resumed.result()).resolves.toMatchObject({ + status: "completed", + }); + expect(port.dispatchOptions.length).toBeGreaterThanOrEqual(2); + // toMatchObject because the V1 rpc adds its mandatory Mcp-Name header + // on top of the preserved recovery headers. + for (const dispatchOptions of port.dispatchOptions) + expect(dispatchOptions?.context).toMatchObject({ + headers: { authorization: "Bearer resumed" }, + requestTimeoutMs: 5_000, + }); + await session.close(); + } + }); + it("retries the initial resumed observation only for retryable DispatchError", async () => { await fc.assert( fc.asyncProperty(fc.boolean(), async (retryable) => { diff --git a/typescript/client/input-and-recovery.md b/typescript/client/input-and-recovery.md index c3d2f75..048f5f3 100644 --- a/typescript/client/input-and-recovery.md +++ b/typescript/client/input-and-recovery.md @@ -117,7 +117,7 @@ const { outcome } = await recovered.settle({ The recovered execution exposes the same progress, input, and settlement APIs as a fresh call. -If the result uses a custom runtime codec, pass the same codec to `resumeTask()` that you used for the original call. See [Adapters and schemas](../adapters-and-schemas.md). +If the result uses a custom runtime codec, pass the same codec to `resumeTask()` that you used for the original call. See [Adapters and schemas](../adapters-and-schemas.md). The same applies to per-request wire context: pass the original call's `headers` and `requestTimeoutMs` so the initial lookup and every follow-up keep their authentication and timeout. ## Work with a known task ID From 63e002fe3458f3e0a1cb930610e72f52e4f914df Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Sat, 12 Sep 2026 14:03:03 -0700 Subject: [PATCH 33/33] fix: address Copilot concerns --- .github/workflows/npm-publish.yml | 14 +++++++ packages/ext-tasks/src/client/api.ts | 2 + .../src/client/client-adapter.test.ts | 35 +++++++++++++++++ packages/ext-tasks/src/client/port.ts | 9 ++++- .../src/client/sdk-client-adapter.ts | 31 ++++++++++++++- packages/ext-tasks/src/client/session.ts | 38 ++++++++++++++----- packages/ext-tasks/src/core/v2/index.test.ts | 7 ++++ packages/ext-tasks/src/core/v2/schemas.ts | 6 ++- packages/ext-tasks/src/receiver/index.ts | 9 ++++- .../ext-tasks/src/receiver/receiver.test.ts | 25 ++++++++++++ .../test-support/client/fake-port.ts | 15 ++++++-- 11 files changed, 173 insertions(+), 18 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index b2e60cd..3acdf22 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -63,6 +63,20 @@ jobs: # patch the runner resolves. - name: Ensure npm CLI supports OIDC trusted publishing run: npm install -g npm@11.5.1 + # The tag is compared to the manifest before anything reaches npm, because + # a release cut from the wrong commit (or tagged with the wrong version) + # would otherwise publish a version that does not match its tag. + - name: Validate release tag against package.json + env: + # Passed through env rather than interpolated into the script, + # so a tag name can never be evaluated as shell syntax. + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + VERSION=$(node -p "require('./packages/ext-tasks/package.json').version") + if [[ "${RELEASE_TAG#v}" != "$VERSION" ]]; then + echo "Release tag ${RELEASE_TAG} does not match package.json version ${VERSION}." + exit 1 + fi - name: Determine npm dist-tag id: npm-tag run: | diff --git a/packages/ext-tasks/src/client/api.ts b/packages/ext-tasks/src/client/api.ts index b2135cb..3d75848 100644 --- a/packages/ext-tasks/src/client/api.ts +++ b/packages/ext-tasks/src/client/api.ts @@ -426,6 +426,8 @@ export interface ToolCallOptions { readonly headers?: Readonly>; /** Per-request timeout in milliseconds for the initiating call and task follow-ups. */ readonly requestTimeoutMs?: number; + /** Re-arms each request's timeout on correlated progress notifications. */ + readonly resetTimeoutOnProgress?: boolean; } export interface TaskControllerOptions { diff --git a/packages/ext-tasks/src/client/client-adapter.test.ts b/packages/ext-tasks/src/client/client-adapter.test.ts index b92de1c..dbc0c43 100644 --- a/packages/ext-tasks/src/client/client-adapter.test.ts +++ b/packages/ext-tasks/src/client/client-adapter.test.ts @@ -439,6 +439,41 @@ describe("Client adapter", () => { disposeError(); }); + it("delegates unhandled input requests to the prior fallback handler", async () => { + const sdk = client(); + const prior = vi.fn().mockResolvedValue({ action: "decline" }); + sdk.fallbackRequestHandler = prior; + const port = createSessionPortFromClient(sdk, "endpoint-prior"); + port.onServerRequest(() => Promise.resolve(undefined)); + const request = { + jsonrpc: "2.0", + id: 7, + method: "elicitation/create", + params: {}, + } as const; + await expect( + // Non-optional call: the adapter installed its own handler, and the + // prior assignment above proves the field is defined. + sdk.fallbackRequestHandler(request, context), + ).resolves.toEqual({ action: "decline" }); + expect(prior).toHaveBeenCalledWith(request, context); + + // Without a prior handler, an unhandled input request settles with the + // conservative default: cancel for elicitations, an error otherwise. + const bare = client(); + const barePort = createSessionPortFromClient(bare, "endpoint-bare"); + barePort.onServerRequest(() => Promise.resolve(undefined)); + await expect( + bare.fallbackRequestHandler?.(request, context), + ).resolves.toEqual({ action: "cancel" }); + await expect( + bare.fallbackRequestHandler?.( + { jsonrpc: "2.0", id: 8, method: "roots/list", params: {} }, + context, + ), + ).rejects.toMatchObject({ code: -32603 }); + }); + it("preserves SDK input handlers alongside the Tasks fallback", () => { class InspectableClient extends Client { requestHandler(method: string): unknown { diff --git a/packages/ext-tasks/src/client/port.ts b/packages/ext-tasks/src/client/port.ts index 07dee8a..4b547c9 100644 --- a/packages/ext-tasks/src/client/port.ts +++ b/packages/ext-tasks/src/client/port.ts @@ -48,6 +48,8 @@ export interface DispatchContext { readonly headers?: Readonly>; /** Per-request timeout in milliseconds. */ readonly requestTimeoutMs?: number; + /** Re-arms the request timeout whenever correlated progress arrives. */ + readonly resetTimeoutOnProgress?: boolean; } /** Options for one port dispatch. */ @@ -65,7 +67,12 @@ export interface ConnectedMcpSessionPort { options?: DispatchOptions, ): Promise; onServerRequest( - handler: (incoming: IncomingServerRequest) => Promise, + // A handler resolves undefined to leave the request unhandled, because + // the port owner (e.g. the host's prior fallback handler) may still be + // able to answer an input request the task session cannot correlate. + handler: ( + incoming: IncomingServerRequest, + ) => Promise, ): () => void; onNotification(listener: (notification: JsonValue) => void): () => void; onInvalidated(listener: (reason: unknown) => void): () => void; diff --git a/packages/ext-tasks/src/client/sdk-client-adapter.ts b/packages/ext-tasks/src/client/sdk-client-adapter.ts index 6fb85fc..7e668f3 100644 --- a/packages/ext-tasks/src/client/sdk-client-adapter.ts +++ b/packages/ext-tasks/src/client/sdk-client-adapter.ts @@ -5,6 +5,7 @@ import type { JsonValue } from "../core/index.js"; import { toolDeclaration } from "./api.js"; import type { TaskEnabledSession, WithTasksOptions } from "./api.js"; import { withOwnedTasks } from "./session.js"; +import { defaultServerRequestResponse } from "./input-routing.js"; import type { DispatchOptions, SessionTaskCapabilities } from "./port.js"; import { DispatchError } from "./port.js"; import type { @@ -272,7 +273,7 @@ const adaptedClients = new WeakSet(); export class ClientSessionPort implements ConnectedMcpSessionPort { readonly taskCapabilities: SessionTaskCapabilities; private readonly serverRequestListeners = new Set< - (incoming: IncomingServerRequest) => Promise + (incoming: IncomingServerRequest) => Promise >(); private readonly notificationListeners = new Set< (notification: JsonValue) => void @@ -302,6 +303,25 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { if (!isJsonValue(request)) throw new ProtocolError(-32600, "Inbound request is not JSON"); const response = await listener({ request, requestContext: context }); + // The listener leaves a request it cannot correlate unhandled, because + // the host's prior fallback handler may still be able to answer it. + if (response === undefined) { + if (this.previousFallbackRequestHandler !== undefined) + return this.previousFallbackRequestHandler(request, context); + const fallback = defaultServerRequestResponse({ + request, + requestContext: context, + }); + if (fallback.kind === "error") + throw new ProtocolError( + fallback.error.code, + fallback.error.message, + fallback.error.data, + ); + if (!isJsonRecord(fallback.result)) + throw new ProtocolError(-32603, "Internal error"); + return fallback.result; + } if (response.kind === "error") throw new ProtocolError( response.error.code, @@ -399,6 +419,11 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { ...(options.context?.requestTimeoutMs === undefined ? {} : { timeout: options.context.requestTimeoutMs }), + ...(options.context?.resetTimeoutOnProgress === undefined + ? {} + : { + resetTimeoutOnProgress: options.context.resetTimeoutOnProgress, + }), }, ); return { kind: "result", result }; @@ -422,7 +447,9 @@ export class ClientSessionPort implements ConnectedMcpSessionPort { } onServerRequest( - handler: (incoming: IncomingServerRequest) => Promise, + handler: ( + incoming: IncomingServerRequest, + ) => Promise, ): () => void { this.serverRequestListeners.add(handler); return () => this.serverRequestListeners.delete(handler); diff --git a/packages/ext-tasks/src/client/session.ts b/packages/ext-tasks/src/client/session.ts index 472ff7d..d3b83f9 100644 --- a/packages/ext-tasks/src/client/session.ts +++ b/packages/ext-tasks/src/client/session.ts @@ -330,11 +330,14 @@ class PortTaskEnabledSession< requestParams.task = retentionMs === undefined ? {} : { ttl: retentionMs }; const dispatchContext = - options.headers === undefined && options.requestTimeoutMs === undefined + options.headers === undefined && + options.requestTimeoutMs === undefined && + options.resetTimeoutOnProgress === undefined ? undefined : { headers: options.headers, requestTimeoutMs: options.requestTimeoutMs, + resetTimeoutOnProgress: options.resetTimeoutOnProgress, }; const executionId = nextExecutionIdentifier(); this.ordinaryInputCandidates.set(executionId, { @@ -598,6 +601,17 @@ class PortTaskEnabledSession< headers: options.headers, requestTimeoutMs: options.requestTimeoutMs, }; + // Like callTool, the linked lifecycle transfers to the resumed execution + // and is disposed when it settles, because options.signal bounds the + // recovered operation's local lifecycle — not just the initial lookup. + let lifecycleTransferred = false; + const transferLifecycle = ( + execution: TaskExecution, + ): void => { + void execution.result().finally(() => { + resumeLifecycle.dispose(); + }); + }; try { throwIfAborted(resumeSignal); this.assertUsable(); @@ -622,7 +636,7 @@ class PortTaskEnabledSession< resultCodec: codec, port: this.port, dispatchContext, - lifecycleSignal: this.lifecycleController.signal, + lifecycleSignal: resumeSignal, }); const tracked = this.trackTaskExecution( execution, @@ -639,6 +653,8 @@ class PortTaskEnabledSession< releaseTaskIdentity, ); taskIdentityTransferred = true; + lifecycleTransferred = true; + transferLifecycle(tracked); return tracked; } @@ -672,7 +688,7 @@ class PortTaskEnabledSession< resultCodec: codec, port: this.port, dispatchContext, - lifecycleSignal: this.lifecycleController.signal, + lifecycleSignal: resumeSignal, onInputRequest: this.options.onInputRequest, reportError: (error) => { this.reportBackgroundError(error); @@ -684,10 +700,12 @@ class PortTaskEnabledSession< releaseTaskIdentity, ); taskIdentityTransferred = true; + lifecycleTransferred = true; + transferLifecycle(tracked); return tracked; } finally { if (!taskIdentityTransferred) releaseTaskIdentity(); - resumeLifecycle.dispose(); + if (!lifecycleTransferred) resumeLifecycle.dispose(); } } @@ -915,9 +933,12 @@ class PortTaskEnabledSession< private async handleServerRequest( incoming: IncomingServerRequest, - ): Promise { + ): Promise { + // Unhandled paths resolve undefined instead of a protocol response, + // because the port owner may hold a prior fallback handler that can + // still answer a request this session does not own. const request = projectApplicationInputRequest(incoming); - if (request === undefined) return defaultServerRequestResponse(incoming); + if (request === undefined) return undefined; const resolution = resolveInputCandidate( readRelatedTaskEvidence(request), @@ -932,10 +953,9 @@ class PortTaskEnabledSession< resolution.reason, ), ); - return defaultServerRequestResponse(incoming); + return undefined; } - if (this.options.onInputRequest === undefined) - return defaultServerRequestResponse(incoming); + if (this.options.onInputRequest === undefined) return undefined; try { const context = buildResolvedInputContext(resolution.candidate); diff --git a/packages/ext-tasks/src/core/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts index 7300ea2..fb65ee8 100644 --- a/packages/ext-tasks/src/core/v2/index.test.ts +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -354,6 +354,13 @@ describe("V2 runtime wire contracts", () => { key: { action: "accept", content: { nested: { object: true } } }, }).success, ).toBe(false); + // Fractional numbers pass: a `NumberSchema` form field may be declared + // `type: "number"`, so a float is a valid submitted value. + expect( + InputResponsesV2Schema.safeParse({ + key: { action: "accept", content: { temperature: 0.5 } }, + }).success, + ).toBe(true); }); it("decodes complete JSON-RPC errors", () => { diff --git a/packages/ext-tasks/src/core/v2/schemas.ts b/packages/ext-tasks/src/core/v2/schemas.ts index 1dfc528..8f5fe86 100644 --- a/packages/ext-tasks/src/core/v2/schemas.ts +++ b/packages/ext-tasks/src/core/v2/schemas.ts @@ -264,10 +264,12 @@ const ListRootsResultV2Schema = openObject({ }); // Elicitation form values are limited to scalars and string arrays (pinned // schema 2026-07-28 `ElicitResult.content`); an open record would forward -// arbitrary JSON to `tasks/update`. +// arbitrary JSON to `tasks/update`. `z.number()` rather than `z.int()`, +// because a `NumberSchema` form field may be declared `type: "number"` and +// its accepted value is then a float. const ElicitContentValueV2Schema = z.union([ z.string(), - z.int(), + z.number(), z.boolean(), z.array(z.string()), ]); diff --git a/packages/ext-tasks/src/receiver/index.ts b/packages/ext-tasks/src/receiver/index.ts index ea47c6a..af3de0f 100644 --- a/packages/ext-tasks/src/receiver/index.ts +++ b/packages/ext-tasks/src/receiver/index.ts @@ -237,7 +237,14 @@ export function bindTaskReceiver( let closed = false; const report = (error: unknown, context: TaskReceiverErrorContext): void => { - options.onError?.(error, context); + // The sink call is guarded because report() runs inside detached + // rejection handlers, where a throwing consumer onError would surface + // as an unhandled rejection instead of a diagnostic. + try { + options.onError?.(error, context); + } catch (sinkError) { + console.error(sinkError); + } }; const snapshot = (record: TaskRecord): TaskV1 => ({ ...record.task }); const notify = ( diff --git a/packages/ext-tasks/src/receiver/receiver.test.ts b/packages/ext-tasks/src/receiver/receiver.test.ts index 04cce54..8a7cd19 100644 --- a/packages/ext-tasks/src/receiver/receiver.test.ts +++ b/packages/ext-tasks/src/receiver/receiver.test.ts @@ -370,6 +370,31 @@ describe("bindTaskReceiver", () => { }); }); + it("guards the error sink so a throwing onError cannot become an unhandled rejection", async () => { + const host = new Host(); + const notification = deferred(); + host.notification.mockImplementationOnce(() => notification.promise); + // The sink failure is expected diagnostic output; keep it off the console. + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const sinkFailure = new Error("sink failed"); + bindTaskReceiver(asClient(host), { + methods: { "sampling/createMessage": true }, + sampling: () => Promise.resolve({ ok: true }), + createTaskId: () => "guarded-task", + onError: () => { + throw sinkFailure; + }, + }); + await host.call("sampling/createMessage"); + await flush(); + notification.reject(new Error("send failed")); + await flush(); + expect(consoleError).toHaveBeenCalledWith(sinkFailure); + consoleError.mockRestore(); + }); + it("expires from creation, aborts pending callbacks, rejects payloads, and removes tasks", async () => { vi.useFakeTimers(); const host = new Host(); diff --git a/packages/ext-tasks/test-support/client/fake-port.ts b/packages/ext-tasks/test-support/client/fake-port.ts index 76b7325..a035a69 100644 --- a/packages/ext-tasks/test-support/client/fake-port.ts +++ b/packages/ext-tasks/test-support/client/fake-port.ts @@ -7,6 +7,7 @@ import type { JsonRpcResponse, SessionTaskCapabilities, } from "../../src/client/index.js"; +import { defaultServerRequestResponse } from "../../src/client/input-routing.js"; export const asJson = (value: unknown): JsonValue => JSON.parse(JSON.stringify(value)) as JsonValue; @@ -37,7 +38,7 @@ export class FakePort implements ConnectedMcpSessionPort { ) => Promise; private requestHandler?: ( incoming: IncomingServerRequest, - ) => Promise; + ) => Promise; private notificationListener?: (notification: JsonValue) => void; private invalidationListener?: (reason: unknown) => void; listenerDisposals = 0; @@ -62,7 +63,9 @@ export class FakePort implements ConnectedMcpSessionPort { } onServerRequest( - handler: (incoming: IncomingServerRequest) => Promise, + handler: ( + incoming: IncomingServerRequest, + ) => Promise, ): () => void { this.requestHandler = handler; return () => { @@ -95,7 +98,13 @@ export class FakePort implements ConnectedMcpSessionPort { async serve(request: JsonValue): Promise { if (this.requestHandler === undefined) throw new Error("request handler is not installed"); - return this.requestHandler({ request, requestContext: {} }); + // The fake port plays a host with no prior fallback handler, so an + // unhandled request settles with the same conservative default the SDK + // adapter applies (cancel elicitations, error everything else). + const response = await this.requestHandler({ request, requestContext: {} }); + return ( + response ?? defaultServerRequestResponse({ request, requestContext: {} }) + ); } notify(notification: JsonValue): void {