diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6df5fb8..74f454e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,19 +2,52 @@ 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: + - 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: Check schemas, package, and packed consumers + 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: - - 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:schema:ts - - run: npm run check:schema:json - - run: npm run docs:build + 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/.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 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/.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 3930420..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: @@ -28,6 +29,23 @@ npm run snapshot:schema -- YYYY-MM-DD ## Development +### SDK Package + +The redistributable package lives in `packages/ext-tasks` and publishes as `@modelcontextprotocol/ext-tasks`. + +```bash +# Run schema, package, and packed-consumer checks +npm run check + +# 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 `/client`, `/receiver`, `/core`, `/core/v1`, or `/core/v2`; the guide explains which entry point owns each workflow. + ### 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..ae877d5 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,7 +18,8 @@ "typescript": "^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" } }, "node_modules/@algolia/abtesting": { @@ -347,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", @@ -428,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", @@ -870,6 +925,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", @@ -899,6 +1148,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 +1165,41 @@ "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/@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", @@ -941,6 +1235,62 @@ "@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 + }, + "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 +1321,21 @@ "node": ">=18.0.0" } }, - "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==", + "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" ], @@ -983,12 +1344,16 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "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" ], @@ -997,12 +1362,16 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "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" ], @@ -1011,12 +1380,16 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "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" ], @@ -1025,26 +1398,16 @@ "optional": true, "os": [ "darwin" - ] - }, - "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==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "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" ], @@ -1053,12 +1416,16 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "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" ], @@ -1067,58 +1434,363 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "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": [ - "arm" + "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "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/@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==", + "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": [ - "arm64" + "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.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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "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": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "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": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "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": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "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": [ + "linux" + ] + }, "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 +1798,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 +1815,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 +1832,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 +1849,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 +1866,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 +1883,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 +1900,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 +1917,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 +1934,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 +1948,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 +1962,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 +1976,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 +1990,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 +2004,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 +2104,30 @@ "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", + "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 +2412,20 @@ "@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/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", @@ -1716,6 +2450,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", @@ -1751,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", @@ -1773,25 +2524,255 @@ "dev": true, "license": "MIT" }, - "node_modules/@typescript/vfs": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", - "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "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": { - "debug": "^4.4.3" + "@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": "*" + "@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/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "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": "ISC" + "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", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" }, "node_modules/@upsetjs/venn.js": { "version": "2.0.0", @@ -1804,18 +2785,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": { @@ -2069,6 +3094,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", @@ -2147,6 +3212,26 @@ "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", + "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", @@ -2187,6 +3272,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", @@ -2198,6 +3297,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", @@ -2363,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", @@ -2389,6 +3508,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", @@ -2975,6 +4109,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", @@ -2995,6 +4136,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 +4227,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", @@ -3141,21 +4300,296 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "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" + "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/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "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": "MIT" - }, - "node_modules/fastdom": { + "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", + "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": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "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", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "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", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "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/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", "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", @@ -3183,6 +4617,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", @@ -3223,6 +4667,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", @@ -3284,6 +4764,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", @@ -3301,6 +4807,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", @@ -3346,6 +4865,30 @@ "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-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", @@ -3357,6 +4900,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", @@ -3368,6 +4921,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", @@ -3404,6 +4967,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", @@ -3420,6 +4993,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", @@ -3446,6 +5032,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", @@ -3464,6 +5057,44 @@ "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/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", + "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", @@ -3491,6 +5122,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", @@ -3504,6 +5145,305 @@ "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", + "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", @@ -3572,10 +5512,26 @@ "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=18" + "node": ">=18" + }, + "funding": { + "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/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lodash-es": { @@ -3927,6 +5883,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", @@ -3935,6 +5898,27 @@ "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", + "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", @@ -3963,6 +5947,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", @@ -3970,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", @@ -3977,6 +6028,26 @@ "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", + "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", @@ -3992,9 +6063,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": { @@ -4004,6 +6075,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", @@ -4062,6 +6143,32 @@ "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", + "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", @@ -4073,6 +6180,53 @@ "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", + "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/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", @@ -4114,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", @@ -4155,10 +6322,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 +6374,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" } }, @@ -4236,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": { @@ -4248,6 +6451,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", @@ -4265,6 +6491,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", @@ -4336,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", @@ -4346,6 +6604,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 +6959,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 +6980,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": { @@ -4714,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", @@ -4725,6 +7024,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", @@ -4808,6 +7120,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", @@ -4835,6 +7160,37 @@ "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/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", @@ -4908,6 +7264,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", @@ -4953,46 +7319,59 @@ } }, "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" }, - "optionalDependencies": { - "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" + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@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 +7388,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 +7470,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 +7487,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 +7504,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 +7521,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 +7538,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 +7555,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 +7572,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 +7589,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 +7606,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 +7623,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 +7640,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 +7657,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 +7674,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 +7691,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 +7708,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 +7725,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 +7742,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 +7759,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 +7776,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 +7793,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 +7810,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 +7827,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 +7844,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 +7897,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 +8072,39 @@ } } }, + "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", + "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", @@ -5533,6 +8118,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", @@ -5597,12 +8192,24 @@ "node": ">=8" } }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "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.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" } @@ -5617,6 +8224,32 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/ext-tasks": { + "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", + "eslint": "^10.10.0", + "eslint-plugin-jsdoc": "^64.3.5", + "fast-check": "^4.9.0", + "globals": "^17.12.0", + "prettier": "^3.9.6", + "typescript-eslint": "^8.69.0" + }, + "peerDependencies": { + "@modelcontextprotocol/client": "^2.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/client": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index f582d61..32a839b 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,22 @@ { - "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 && 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", "generate:schemas": "tsx scripts/generate-schemas.ts", "snapshot:schema": "tsx scripts/snapshot-schema.ts", "fetch:spec-schema": "tsx scripts/fetch-spec-schema.ts", @@ -14,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", @@ -23,6 +35,7 @@ "typescript": "^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/.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/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..17b6491 --- /dev/null +++ b/packages/ext-tasks/README.md @@ -0,0 +1,83 @@ +# `@modelcontextprotocol/ext-tasks` + +Call tools through generation-agnostic Tasks requester APIs. The package also provides 2025-11-25 Tasks receiver support for sampling and elicitation requests. + +## Install + +```sh +npm install @modelcontextprotocol/ext-tasks +``` + +Use the client entry point when your application calls tools: + +```ts +import { + createTaskSessionFromClient, + resultFromTaskOutcome, +} from "@modelcontextprotocol/ext-tasks/client"; +``` + +## Call a tool + +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: "production-reports", +}); + +try { + const execution = await session.callTool("generate_report", { + format: "pdf", + }); + const { outcome } = await execution.settle(); + const result = resultFromTaskOutcome(outcome); +} finally { + await session.close(); +} +``` + +## Receive 2025-11-25 Tasks requests + +Use the receiver entry point to handle 2025-11-25 task-backed sampling and elicitation requests. + +```ts +import { bindTaskReceiver } from "@modelcontextprotocol/ext-tasks/receiver"; + +const receiver = bindTaskReceiver(client, { + methods: { "sampling/createMessage": true }, + sampling: async (request, { signal }) => + runSampling(request.params, { signal }), +}); + +try { + await serve(); +} finally { + receiver.close(); +} +``` + +The binding owns task state, retention, cancellation, lifecycle handlers, and status notifications. Merge `receiver.capabilities` into the host's advertised Tasks capability. + +## Documentation + +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: + +- [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) + +For normative wire behavior, use the [MCP Tasks specification](https://modelcontextprotocol.github.io/ext-tasks/specification/2026-07-28/tasks.html). + +## Public entry points + +- `@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`. diff --git a/packages/ext-tasks/eslint.config.mjs b/packages/ext-tasks/eslint.config.mjs new file mode 100644 index 0000000..09b3402 --- /dev/null +++ b/packages/ext-tasks/eslint.config.mjs @@ -0,0 +1,33 @@ +import eslint from "@eslint/js"; +import jsdoc from "eslint-plugin-jsdoc"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["dist/**", "coverage/**", "schema/**"], + }, + eslint.configs.recommended, + ...tseslint.configs.strictTypeChecked, + { + files: ["src/**/*.ts", "test-support/**/*.ts", "vitest.config.ts"], + plugins: { jsdoc }, + rules: { + "jsdoc/require-jsdoc": ["error", { publicOnly: true }], + }, + 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 new file mode 100644 index 0000000..b9640ad --- /dev/null +++ b/packages/ext-tasks/package.json @@ -0,0 +1,102 @@ +{ + "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" + }, + "./receiver": { + "types": "./dist/receiver/index.d.ts", + "import": "./dist/receiver/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" + ], + "receiver": [ + "dist/receiver/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 && 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", + "test:watch": "vitest", + "format": "prettier --write .", + "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 && npm run check:package" + }, + "devDependencies": { + "@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", + "typescript-eslint": "^8.69.0" + }, + "dependencies": { + "zod": "^4.5.4" + } +} 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-exports.mjs b/packages/ext-tasks/scripts/check-exports.mjs new file mode 100644 index 0000000..f9e1ccc --- /dev/null +++ b/packages/ext-tasks/scripts/check-exports.mjs @@ -0,0 +1,573 @@ +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", "receiver"]; +const expectedRuntimeExports = { + core: [ + "JsonValueCodec", + "ProtocolDecodeError", + "isJsonValue", + "runtimeCodecFromStandardSchema", + "taskId", + "toJsonValue", + ], + "core/v1": [ + "CallToolAsTaskRequestV1Schema", + "CallToolRequestV1Schema", + "CallToolResultV1Schema", + "CancelTaskRequestV1Schema", + "CancelTaskResultV1Schema", + "ContentBlockV1Schema", + "CreateTaskResultV1Schema", + "GetTaskRequestV1Schema", + "GetTaskResultRequestV1Schema", + "GetTaskResultV1Schema", + "JsonRpcRequestIdV1Schema", + "ListTasksRequestV1Schema", + "ListTasksResultV1Schema", + "ServerCapabilitiesV1Schema", + "ServerTaskCapabilitiesV1Schema", + "TaskEligibleMethodV1Schema", + "TaskMetadataV1Schema", + "TaskResultV1Schema", + "TaskStatusNotificationV1Schema", + "TaskStatusV1Schema", + "TaskStatusesV1", + "TaskSupportV1Schema", + "TaskV1Schema", + "ToolExecutionV1Schema", + "ToolV1Schema", + "callToolAsTaskV1", + "hasTaskCancelCapabilityV1", + "hasTaskListCapabilityV1", + "hasTaskToolCallCapabilityV1", + "isTaskEligibleMethodV1", + "shouldCallToolAsTaskV1", + ], + "core/v2": [ + "CLIENT_CAPABILITIES_META_KEY_V2", + "CallToolResultV2Schema", + "CancelTaskRequestV2Schema", + "CancelTaskResultV2Schema", + "CancelledTaskV2Schema", + "ClientTaskCapabilityEnvelopeV2Schema", + "CompletedTaskV2Schema", + "ContentBlockV2Schema", + "CreateMessageRequestV2Schema", + "CreateMessageResultV2Schema", + "CreateTaskResultV2Schema", + "DetailedTaskV2Schema", + "ElicitRequestV2Schema", + "ElicitResultV2Schema", + "ErrorV2Schema", + "FailedTaskV2Schema", + "GetTaskRequestV2Schema", + "GetTaskResultV2Schema", + "InputRequestV2Schema", + "InputRequestsV2Schema", + "InputRequiredCallToolResultV2Schema", + "InputRequiredTaskV2Schema", + "InputResponseV2Schema", + "InputResponsesV2Schema", + "ListRootsRequestV2Schema", + "ListRootsResultV2Schema", + "RequestIdV2Schema", + "ServerTaskCapabilityEnvelopeV2Schema", + "TASKS_EXTENSION_ID_V2", + "TaskEligibleMethodV2Schema", + "TaskStatusNotificationParamsV2Schema", + "TaskStatusNotificationV2Schema", + "TaskStatusV2Schema", + "TaskSubscriptionAcknowledgedNotificationsV2Schema", + "TaskSubscriptionNotificationsV2Schema", + "TaskV2Schema", + "TasksExtensionCapabilityV2Schema", + "ToolV2Schema", + "UpdateTaskRequestV2Schema", + "UpdateTaskResultV2Schema", + "WorkingTaskV2Schema", + "contributeTaskFilterV2", + "hasTaskClientCapabilityV2", + "hasTaskServerCapabilityV2", + "isCancelTaskRequestV2", + "isCreateTaskResultV2", + "isDetailedTaskV2", + "isGetTaskRequestV2", + "isTaskStatusNotificationV2", + "isTaskV2", + "isToolCallTaskResultV2", + "isUpdateTaskRequestV2", + "readAcceptedTaskIdsV2", + "withTaskCapabilityV2", + ], + client: [ + "DispatchError", + "InputCorrelationError", + "JsonRpcResponseError", + "TaskCancellationUnsupportedError", + "TaskCancelledError", + "TaskExecutionClosedError", + "TaskFailedError", + "TaskInputUpdateUnsupportedError", + "TaskRecoveryOwnershipError", + "TaskRetentionUnsupportedError", + "TaskUpdatesAlreadyAcquiredError", + "createApplicationInputHandler", + "createTaskSessionEndpointId", + "createSessionPortFromClient", + "createTaskSessionFromClient", + "resultFromTaskOutcome", + "taskViewFromExecutionEvent", + "toolDeclaration", + "toolDeclarationFromMcpTool", + "withRelatedTaskMetadata", + "withTasks", + ], + receiver: ["bindTaskReceiver"], +}; +const removedPrimaryClientNames = [ + "TaskGenerationMismatchError", + "TaskSnapshot", + "toolDeclarationV1", + "toolDeclarationV2", +]; +const removedCoreNames = [ + "DecodePath", + "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", + "TaskExtensionCapabilitiesV2Codec", + "ToolCallResultV2", + "ToolCallResultV2Codec", + "isEligibleTaskResultV2", + "supportsTasksExtensionV2", +]; +const removedRuntimeAliasesV2 = [ + "TaskExtensionCapabilitiesV2Codec", + "ToolCallResultV2Codec", + "isEligibleTaskResultV2", + "supportsTasksExtensionV2", +]; +const unbarreledInternalTypesV2 = [ + "IconV2", + "JsonRpcRequestV2", + "OpenObjectV2", + "ToolAnnotationsV2", +]; +const unavailableV2Names = [ + ...removedPublicAliasesV2, + ...unbarreledInternalTypesV2, +]; + +function sorted(values) { + return [...values].sort(); +} + +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 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( + 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 === "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( + alias in namespace, + false, + `Removed V2 alias returned: ${alias}`, + ); + } + } +} + +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; +} + +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", + "--dry-run=false", + "--json", + "--pack-destination", + packDirectory, + ]); + 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), + ), + 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, + "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", + "--ignore-scripts", + "--dry-run=false", + "--no-audit", + "--no-fund", + "--no-package-lock", + resolve( + packageDirectory, + "../../node_modules/@modelcontextprotocol/client", + ), + tarball, + ], + { cwd: consumerDirectory }, + ); + await access( + join(consumerDirectory, "node_modules", "zod", "package.json"), + ); + + const positiveImports = publicSubpaths + .map( + (subpath) => + `import * as ${subpath.replace(/\W/gu, "_")} from "${packageName}/${subpath}";`, + ) + .join("\n"); + const positiveSource = `${positiveImports} +import { withTasks } from "${packageName}/client"; +import type { ConnectedMcpSessionPort, TaskEnabledSession, TaskOutcome, V2RequestFraming } from "${packageName}/client"; +import { ProtocolDecodeError } from "${packageName}/core"; +import type { RuntimeCodec, SynchronousStandardSchema } 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 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: TaskOutcome = await execution.result(); +void inferred; +const taskSession: TaskEnabledSession = session; +void taskSession; +`; + await writeFile(join(consumerDirectory, "positive.ts"), positiveSource); + 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";`, + ], + [ + "removed-result-codec-option", + `import { withTasks } from "${packageName}/client"; +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";`, + ]), + ...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";`, + ]), + ]; + 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 installedPackageDirectory = join( + consumerDirectory, + "node_modules", + ...packageName.split("/"), + ); + const installedRelativePaths = ( + await listFiles(installedPackageDirectory) + ).map((path) => + relative(installedPackageDirectory, path).replaceAll("\\", "/"), + ); + assert.equal( + 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 }); + } +} + +await checkBuiltContract(); +if (process.argv.includes("--pack")) await checkPackedContract(); 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/scripts/check-schema-provenance.mjs b/packages/ext-tasks/scripts/check-schema-provenance.mjs new file mode 100644 index 0000000..5fbf62d --- /dev/null +++ b/packages/ext-tasks/scripts/check-schema-provenance.mjs @@ -0,0 +1,26 @@ +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/client/api.ts b/packages/ext-tasks/src/client/api.ts new file mode 100644 index 0000000..de3c2d6 --- /dev/null +++ b/packages/ext-tasks/src/client/api.ts @@ -0,0 +1,517 @@ +import { toJsonValue } from "../core/index.js"; +import type { JsonValue, RuntimeCodec, TaskId } from "../core/index.js"; +import type { + CallToolResultV1, + TaskEligibleMethodV1, +} from "../core/v1/index.js"; +import type { + CallToolResultV2, + ErrorV2, + InputResponsesV2, + 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; + } +} + +/** 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 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"; + } +} + +/** 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 } + : {}), + }; +} + +/** 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 }, + }; +} + +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 { + currentTool(name: string): ToolDeclaration | 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 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( + request: TRequest, + context: ResolvedInputExchangeContext, + ): 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" + | "zero-matches" + | "ambiguous-matches"; + +export interface InputCorrelationCandidate { + readonly toolName: string; + readonly executionId: string; +} + +export class InputCorrelationError extends Error { + constructor( + 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; +} + +/** 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 TaskOutcome = + | { + readonly status: "completed"; + readonly result: TResult; + readonly task?: TaskView; + } + | { + 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 ToolExecutionCommon { + readonly applicationContext: TApplicationContext; + 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; +} + +export type ToolExecution = + | (ToolExecutionCommon & { + readonly kind: "immediate"; + readonly handle?: undefined; + }) + | (ToolExecutionCommon & { + 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 { + 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 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 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>; + /** 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 { + 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>; + resumeTask( + reference: SerializedTaskReference, + options?: TaskRecoveryOptions, + ): 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..8bb3750 --- /dev/null +++ b/packages/ext-tasks/src/client/client-adapter.test.ts @@ -0,0 +1,763 @@ +import { + Client, + ProtocolError, + SdkError, + SdkErrorCode, +} 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 { ApplicationInputHandler } from "./index.js"; +import { + createSessionPortFromClient, + createTaskSessionFromClient, + 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" }); +const context = { + mcpReq: { + id: 1, + method: "custom/request", + requestState: () => undefined, + signal: new AbortController().signal, + send: vi.fn(), + 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("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"); + 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("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, "request-context"); + await port.dispatch( + { method: "custom/method" }, + { + context: { + headers: { "x-trace": "trace-1" }, + requestTimeoutMs: 2_500, + }, + }, + ); + expect(request.mock.calls[0]?.[2]).toEqual({ + headers: { "x-trace": "trace-1" }, + timeout: 2_500, + }); + }); + + 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"); + 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, + v2RequestFraming, + }); + const options = { context: { headers: { "x-trace": "trace-2" } } }; + await expect( + 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", + _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"); + vi.spyOn(sdk, "getServerCapabilities").mockReturnValue({ + extensions: { "io.modelcontextprotocol/tasks": {} }, + }); + const request = vi.spyOn(sdk, "request"); + expect(() => createSessionPortFromClient(sdk, "modern")).toThrow( + "requires options.rawDispatch and options.v2RequestFraming", + ); + 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"); + 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")).toThrow( + "requires options.rawDispatch", + ); + const modernPort = createSessionPortFromClient(modern, "modern", { + rawDispatch: async () => { + await Promise.resolve(); + return { kind: "result", result: {} }; + }, + v2RequestFraming, + }); + expect(modernPort.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" }); + legacyPort[Symbol.dispose](); + modernPort[Symbol.dispose](); + }); + + 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("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 })); + 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 port = createSessionPortFromClient( + foreign as unknown as Client, + "foreign-client", + ); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("x"); + await expect(legacyResult(execution)).resolves.toEqual({ content: [] }); + expect(foreign.request).toHaveBeenCalled(); + await session.close(); + port[Symbol.dispose](); + }); + + 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 port = createSessionPortFromClient(sdk, "raw-client"); + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + const execution = await session.callTool("x"); + await expect(legacyResult(execution)).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(); + port[Symbol.dispose](); + 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 port = createSessionPortFromClient(sdk, "close-failure"); + const errors: Error[] = []; + const session = withTasks(port, { + signal: controller.signal, + tools: { currentTool: () => undefined }, + onError: (error) => errors.push(error), + }); + 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](); + }); + + 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 new file mode 100644 index 0000000..b818923 --- /dev/null +++ b/packages/ext-tasks/src/client/declarations-capabilities.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DispatchError, + TaskRetentionUnsupportedError, + toolDeclaration, + withTasks, +} from "./index.js"; +import type { JsonRpcResponse } from "./index.js"; +import { + FakePort, + asJson, + formatJson, + expectRecord, +} 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("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(); + 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); + 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(); + 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("projects one neutral declaration without leaking call listeners", 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 callController = new AbortController(); + const addListener = vi.spyOn(callController.signal, "addEventListener"); + const removeListener = vi.spyOn( + callController.signal, + "removeEventListener", + ); + const session = withTasks(port, { + tools: { currentTool: () => declaration }, + }); + 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([]); + 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..066e88d --- /dev/null +++ b/packages/ext-tasks/src/client/execution.ts @@ -0,0 +1,743 @@ +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 { + JsonRpcResponseError, + TaskCancelledError, + TaskExecutionClosedError, + TaskFailedError, + TaskUpdatesAlreadyAcquiredError, +} 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"; + +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"], +): RuntimeCodec { + return generation === "v2" + ? codecFromSchema(CallToolResultV2Schema) + : codecFromSchema(CallToolResultV1Schema); +} + +/** 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( + typeof reason === "string" ? reason : "MCP session was invalidated", + { cause: reason }, + ); +} + +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, + ), + ); +} + +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, + signal: AbortSignal, +): Promise { + let timeout: ReturnType | undefined; + try { + await withAbort( + new Promise((resolve) => { + timeout = setTimeout(resolve, safeTimerDelay(delayMs)); + }), + signal, + ); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +type TaskTurn = + | { readonly sequence: number; readonly snapshot: InternalTaskSnapshot } + | undefined; + +function wakeAll(waiters: Set<() => void>): void { + for (const wake of waiters) wake(); + waiters.clear(); +} + +export interface TaskDriverContext { + readonly accept: (snapshot: InternalTaskSnapshot) => InternalTaskSnapshot; + readonly nextObservation: ( + afterSequence: number, + delayMs: number | undefined, + observation: (signal: AbortSignal) => Promise, + ) => 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: InternalTaskHandle; + readonly declaration?: ToolDeclaration; + readonly endpointId: TaskSessionEndpointId; + readonly initialSnapshot: InternalTaskSnapshot; + 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 declaration: ToolDeclaration | undefined; + readonly handle: TaskHandle; + 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 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 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: InternalTaskSnapshot | undefined; + private updatesAcquired = false; + private cancelPromise: Promise | undefined; + private closePromise: Promise | undefined; + private releaseLifecycleListener: (() => void) | undefined; + private settlementPromise: + Promise> | undefined; + private settled = false; + private closed = false; + + constructor(options: TaskExecutionOptions) { + this.applicationContext = options.applicationContext; + 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)) { + this.authoritativeTerminalSnapshot = options.initialSnapshot; + this.inputController.abort(); + } + 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 }); + this.releaseLifecycleListener = () => { + lifecycleSignal.removeEventListener("abort", abort); + this.releaseLifecycleListener = undefined; + }; + } + } + 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, + }, + 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; + }, + () => { + this.settled = true; + }, + ); + } + + serializeReference(): SerializedTaskReference { + 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; + if (snapshot.task.taskId !== this.internalHandle.taskId) return; + this.transitionSnapshot(snapshot, "notification"); + } + + updates(signal?: AbortSignal): AsyncIterable> { + if (this.updatesAcquired) throw new TaskUpdatesAlreadyAcquiredError(); + this.updatesAcquired = true; + return this.iterateEvents(signal); + } + + private async *iterateEvents( + signal?: AbortSignal, + ): AsyncIterable> { + for (;;) { + throwIfAborted(signal); + const snapshot = this.takeQueuedUpdate(); + if (snapshot !== undefined) { + yield { type: "task", task: projectTask(snapshot) }; + continue; + } + const settled = await this.waitForUpdateOrResult(signal); + if (!settled) { + yield { type: "outcome", outcome: await this.result() }; + return; + } + } + } + + private takeQueuedUpdate(): InternalTaskSnapshot | 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: InternalTaskSnapshot): InternalTaskSnapshot { + if (this.closed) return snapshot; + return this.transitionSnapshot(snapshot, "accepted"); + } + + private transitionSnapshot( + snapshot: InternalTaskSnapshot, + source: "accepted" | "notification", + ): 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) + return this.authoritativeTerminalSnapshot; + const bytes = deterministicJson(snapshot); + 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(); + } 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); + } + 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 || + 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 notifiedSnapshotAfter(afterSequence: number): TaskTurn { + if ( + this.notificationSequence > afterSequence && + this.latestNotifiedSnapshot !== undefined + ) { + return { + sequence: this.notificationSequence, + snapshot: this.latestNotifiedSnapshot, + }; + } + return undefined; + } + + private async waitForTurn( + afterSequence: number, + delayMs: number | undefined, + ): Promise { + const current = this.notifiedSnapshotAfter(afterSequence); + if (current !== undefined) return current; + if (delayMs === undefined) { + await Promise.resolve(); + throwIfAborted(this.controller.signal); + return this.notifiedSnapshotAfter(afterSequence); + } + await new Promise((resolve, reject) => { + const finish = (error?: unknown): void => { + clearTimeout(timeout); + this.turnWaiters.delete(onTurn); + this.controller.signal.removeEventListener("abort", onAbort); + if (error === undefined) resolve(); + else reject(reasonAsError(error)); + }; + const onTurn = (): void => { + finish(); + }; + const onAbort = (): void => { + finish(this.controller.signal.reason); + }; + const timeout = setTimeout(onTurn, safeTimerDelay(delayMs)); + this.turnWaiters.add(onTurn); + this.controller.signal.addEventListener("abort", onAbort, { once: true }); + }); + return this.notifiedSnapshotAfter(afterSequence); + } + + 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.notifiedSnapshotAfter(afterSequence)); + }; + this.turnWaiters.add(wake); + }); + // 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.turnWaiters.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) => ({ + sequence: afterSequence, + snapshot, + })), + notified, + ]), + this.controller.signal, + ); + } finally { + if (!observationLifecycle.signal.aborted) observationLifecycle.abort(); + observationLifecycle.dispose(); + if (wake !== undefined) this.turnWaiters.delete(wake); + } + } + + result(): Promise> { + return this.outcomePromise; + } + + settle( + options: ToolExecutionSettleOptions = {}, + ): Promise> { + this.settlementPromise ??= settleExecution( + this, + this.observeEvents(options.signal), + options, + ); + return this.settlementPromise; + } + + 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); + } + + 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(); + } + + 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. + }); + return this.closePromise; + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } +} + +/** Produces stable JSON-like text by sorting object keys recursively. */ +export function deterministicJson(value: unknown): string { + 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>; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${deterministicJson(record[key])}`) + .join(",")}}`; +} + +/** Returns whether a task status is terminal. */ +export function terminalStatus(status: TaskV1["status"]): boolean { + return ( + status === "completed" || status === "failed" || status === "cancelled" + ); +} + +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; + private updatesAcquired = false; + + constructor( + readonly applicationContext: TApplicationContext, + private readonly resultPromise: Promise, + declaration?: ToolDeclaration, + ) { + this.declaration = declaration; + this.outcomePromise = completedOutcome(this.resultPromise); + } + + 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 { + async *[Symbol.asyncIterator]() { + yield { type: "outcome" as const, outcome: await outcome }; + }, + }; + } + + result(): Promise> { + return this.outcomePromise; + } + + settle( + options: ToolExecutionSettleOptions = {}, + ): Promise> { + this.settlementPromise ??= settleExecution( + this, + this.observeOutcome(options.signal), + options, + ); + return this.settlementPromise; + } + + cancel(signal?: AbortSignal): Promise { + throwIfAborted(signal); + return Promise.resolve(); + } + + detach(): Promise { + 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..149c92e --- /dev/null +++ b/packages/ext-tasks/src/client/immediate-session-basics.test.ts @@ -0,0 +1,510 @@ +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, + 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 () => { + 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({ + status: "completed", + result, + }); + const updates: unknown[] = []; + for await (const update of legacyUpdates(execution)) + updates.push(update); + expect(updates).toEqual([]); + await execution.cancel(); + expect(port.requests).toHaveLength(1); + await session.close(); + }, + ), + ); + }); + + 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, { + 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("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", + 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: () => + toolDeclaration({ + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }), + }, + }); + 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", + 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" }, + }); + 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(); + }); + + it("uses an application result codec at the dispatch boundary", async () => { + const port = new FakePort(); + port.response = { kind: "result", result: { answer: 42 } }; + 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, { + resultCodec, + applicationContext: "ctx", + }); + expect(execution.applicationContext).toBe("ctx"); + await expect(legacyResult(execution)).resolves.toBe("42"); + + port.response = { kind: "result", result: { answer: "invalid" } }; + await expect( + session.callTool("answer", undefined, { resultCodec }), + ).rejects.toBeInstanceOf(ProtocolDecodeError); + 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(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 new file mode 100644 index 0000000..7a59d5e --- /dev/null +++ b/packages/ext-tasks/src/client/index.ts @@ -0,0 +1,81 @@ +/** Requester-side MCP Tasks session and execution support. */ + +export { + InputCorrelationError, + JsonRpcResponseError, + TaskCancelledError, + TaskFailedError, + TaskCancellationUnsupportedError, + TaskExecutionClosedError, + toolDeclaration, + TaskRecoveryOwnershipError, + TaskUpdatesAlreadyAcquiredError, + TaskRetentionUnsupportedError, + TaskInputUpdateUnsupportedError, +} from "./api.js"; +export { + createTaskSessionEndpointId, + resultFromTaskOutcome, + taskViewFromExecutionEvent, +} from "./api.js"; +export { withRelatedTaskMetadata } from "./api.js"; +export type { + ApplicationCreateMessageResult, + ApplicationElicitResult, + ApplicationInputHandler, + ApplicationInputCallbacks, + ApplicationInputRequest, + ApplicationInputResult, + ApplicationListRootsResult, + InputCorrelationCandidate, + 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, + DispatchContext, + DispatchOptions, + IncomingServerRequest, + JsonRpcResponse, + SessionTaskCapabilities, +} from "./port.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"; +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 new file mode 100644 index 0000000..7fc45ac --- /dev/null +++ b/packages/ext-tasks/src/client/input-routing.ts @@ -0,0 +1,285 @@ +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"; +import type { IncomingServerRequest, JsonRpcResponse } from "./port.js"; + +/** Returns object-valued request parameters, defaulting an omitted value to empty. */ +export function requestParams( + request: Readonly>, +): Readonly> { + if (!Object.hasOwn(request, "params")) 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; +} + +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 toolName: string; + readonly executionId: string; +} + +export type InputCandidateResolution = + | { + readonly kind: "resolved"; + readonly candidate: InputCandidate; + } + | { + readonly kind: "failed"; + readonly reason: InputCorrelationFailureReason; + 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, +): 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, + }; +} + +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, + ordinaryCandidates: readonly OrdinaryInputCandidate[], + taskCandidates: readonly V1TaskInputCandidate[], +): InputCandidateResolution { + const matches: readonly InputCandidate[] = + evidence.kind === "task-id" + ? taskCandidates.filter( + (candidate) => candidate.taskId === evidence.taskId, + ) + : evidence.kind === "absent" + ? ordinaryCandidates + : []; + const reason = correlationFailureReason(evidence, matches.length); + if (reason !== undefined) { + return { + kind: "failed", + reason, + candidates: matches.map((candidate) => ({ + toolName: candidate.toolName, + executionId: candidate.executionId, + })), + }; + } + 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 { + scope: "task", + delivery: "peer-request", + taskId: candidate.taskId, + applicationContext: candidate.applicationContext, + ...(candidate.signal === undefined ? {} : { signal: candidate.signal }), + }; + } + return { + scope: "request", + delivery: "peer-request", + inputId: candidate.executionId, + applicationContext: candidate.applicationContext, + ...(candidate.signal === undefined ? {} : { signal: candidate.signal }), + }; +} + +let nextExecutionId = 0; + +/** Allocates a process-local identifier for an ordinary tool execution. */ +export function nextExecutionIdentifier(): string { + return `execution-${String(++nextExecutionId)}`; +} + +/** Returns the conservative fallback response for an unhandled server request. */ +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" } }; +} + +/** Throws if the signal is aborted. */ +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/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 new file mode 100644 index 0000000..07dee8a --- /dev/null +++ b/packages/ext-tasks/src/client/port.ts @@ -0,0 +1,327 @@ +import { + ProtocolDecodeError, + type JsonValue, + type RuntimeCodec, +} from "../core/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"; + +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; +} + +/** 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>; + /** Per-request timeout in milliseconds. */ + readonly requestTimeoutMs?: number; +} + +/** 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?: DispatchOptions, + ): 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; + } +} +/** Races a promise against an optional abort signal and releases its listener. */ +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); + } +} + +/** Links abort signals into one disposable lifecycle. */ +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(); + }, + }; +} + +/** Dispatches a request with the retry policy for its observation or mutation intent. */ +export async function dispatchWithRetry( + port: ConnectedMcpSessionPort, + request: JsonValue, + dispatchOptions: DispatchOptions | AbortSignal | undefined, +): Promise { + const options = + dispatchOptions instanceof AbortSignal + ? { signal: dispatchOptions } + : dispatchOptions; + const signal = options?.signal; + try { + return await port.dispatch(request, options); + } catch (error) { + throwIfAborted(signal); + if (!(error instanceof DispatchError) || !error.retryable) { + throw error; + } + return port.dispatch(request, options); + } +} + +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. */ +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; +} + +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, + }); + 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); + const context = v2TaskDispatchContext(options); + return { + generation, + get: (signal) => + dispatchTaskRpc( + options, + { method: "tasks/get", params: params({ taskId: options.taskId }) }, + GetTaskResultV2Schema, + signal, + context, + ), + cancel: async (signal) => { + await dispatchTaskRpc( + options, + { method: "tasks/cancel", params: params({ taskId: options.taskId }) }, + CancelTaskResultV2Schema, + signal, + context, + ); + }, + update: async (inputResponses, signal) => { + await dispatchTaskRpc( + options, + { + method: "tasks/update", + params: params({ taskId: options.taskId, inputResponses }), + }, + UpdateTaskResultV2Schema, + signal, + context, + ); + }, + }; +} 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 new file mode 100644 index 0000000..6fb85fc --- /dev/null +++ b/packages/ext-tasks/src/client/sdk-client-adapter.ts @@ -0,0 +1,507 @@ +import { Client, ProtocolError } from "@modelcontextprotocol/client"; +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 { + ConnectedMcpSessionPort, + IncomingServerRequest, + 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 allowsInputRequired(request: JsonValue): boolean { + return isJsonRecord(request) && request.method === "tools/call"; +} + +function clientTaskCapabilities( + client: ClientPublicSurface, +): SessionTaskCapabilities { + const capabilities = client.getServerCapabilities(); + if (client.getProtocolEra() === "modern") { + const extension = + capabilities?.extensions?.["io.modelcontextprotocol/tasks"]; + if ( + 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 (!Object.hasOwn(request, "params")) 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" + ); +} + +/** Host-owned request coordinator used when SDK wire codecs reject V2 task traffic. */ +export type RawClientDispatch = ( + request: JsonValue, + 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 without a V2 raw request path. */ +interface ClientSessionPortWithoutRawDispatch { + readonly rawDispatch?: undefined; + readonly v2RequestFraming?: undefined; +} + +/** 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, +): boolean { + if (capabilities.generation !== "v2") return false; + const method = asClientRequest(request).method; + 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" + | "getProtocolEra" + | "getServerCapabilities" + | "fallbackRequestHandler" + | "fallbackNotificationHandler" + | "onclose" +>; + +const adaptedClients = new WeakSet(); + +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 readonly v2RequestFraming: V2RequestFraming | undefined; + 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, + 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 || this.v2RequestFraming === undefined) + ) + throw new TypeError( + "A V2 task session requires options.rawDispatch and options.v2RequestFraming to coordinate task wire shapes", + ); + this.previousFallbackRequestHandler = client.fallbackRequestHandler; + this.previousFallbackNotificationHandler = + client.fallbackNotificationHandler; + this.previousOnclose = client.onclose; + 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: DispatchOptions = {}, + ): Promise { + try { + if (requiresRawDispatch(this.taskCapabilities, request)) { + if ( + this.rawDispatch === undefined || + this.v2RequestFraming === undefined + ) + throw new DispatchError( + "SDK Client cannot dispatch V2 task wire shapes without rawDispatch and v2RequestFraming", + ); + 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 + ? {} + : { headers: options.context.headers }), + ...(options.context?.requestTimeoutMs === undefined + ? {} + : { timeout: options.context.requestTimeoutMs }), + }, + ); + 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 }), + }, + }; + } + if (error instanceof DispatchError) throw error; + 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); + } +} + +/** Creates a disposable connected session port backed by an MCP SDK client. */ +export function createSessionPortFromClient( + client: Client, + endpointId: string, + options: ClientSessionPortOptions = {}, +): ConnectedMcpSessionPort & Disposable { + 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 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](); + }); + } 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..99be0aa --- /dev/null +++ b/packages/ext-tasks/src/client/session-facade.test.ts @@ -0,0 +1,179 @@ +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("settles an immediate execution", async () => { + const port = new FakePort(); + port.response = { kind: "result", result: { content: [] } }; + const session = withTasks(port, { + tools: { currentTool: () => undefined }, + }); + + const execution = await session.callTool("echo"); + await expect(execution.settle()).resolves.toEqual({ + outcome: { status: "completed", result: { content: [] } }, + lastTask: 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 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(); + }); + + 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 new file mode 100644 index 0000000..98f94e9 --- /dev/null +++ b/packages/ext-tasks/src/client/session.ts @@ -0,0 +1,916 @@ +import { isJsonValue } from "../core/index.js"; +import type { JsonValue, RuntimeCodec, TaskId } from "../core/index.js"; +import { + CreateTaskResultV1Schema, + ListTasksResultV1Schema, + TaskStatusNotificationV1Schema, + shouldCallToolAsTaskV1, +} from "../core/v1/index.js"; +import type { CallToolResultV1, TaskV1 } from "../core/v1/index.js"; +import { + CreateTaskResultV2Schema, + DetailedTaskV2Schema, + InputRequiredCallToolResultV2Schema, + TaskStatusNotificationV2Schema, + isCreateTaskResultV2, + withTaskCapabilityV2, +} from "../core/v2/index.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 { + SerializedTaskReference, + TaskEnabledSession, + TaskListPage, + ToolCallOptions, + ToolDeclarationProvider, + ToolExecution, + WithTasksOptions, +} from "./api.js"; +import { + ImmediateExecution, + TaskExecution, + defaultResultCodec, + reasonAsError, +} from "./execution.js"; +import { + projectTask, + projectToolForGeneration, + semanticCapabilities, +} from "./internal.js"; +import type { InternalTaskHandle, InternalTaskSnapshot } from "./internal.js"; +import { + buildResolvedInputContext, + defaultServerRequestResponse, + nextExecutionIdentifier, + projectApplicationInputRequest, + readRelatedTaskEvidence, + resolveInputCandidate, + throwIfAborted, +} from "./input-routing.js"; +import type { + OrdinaryInputCandidate, + V1TaskInputCandidate, +} from "./input-routing.js"; +import { + createTaskRpc, + parseResult, + dispatchWithRetry, + linkAbortSignals, + responseResult, + withAbort, +} from "./port.js"; +import type { + ConnectedMcpSessionPort, + IncomingServerRequest, + 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, + 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; +}): 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 { + readonly endpointId: TaskSessionEndpointId; + readonly capabilities; + private closed = false; + private closePromise: Promise | 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 + >(); + 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); + } catch (sinkError) { + console.error(sinkError); + } + }; + 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 + ? 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(); + } + } + + 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 callTool( + name: string, + params?: Readonly>, + options: ToolCallOptions = {}, + ): Promise> { + this.assertUsable(); + const callLifecycle = linkAbortSignals( + this.lifecycleController.signal, + options.signal, + ); + const callSignal = callLifecycle.signal; + let declaration = options.declaration; + try { + throwIfAborted(callSignal); + await this.managedDeclarations?.ensureReady(callSignal); + this.assertUsable(); + declaration ??= this.declarations.currentTool(name); + } catch (error) { + callLifecycle.dispose(); + throw error; + } + 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"; + const retentionMs = options.task?.retentionMs; + if ( + 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(); + throw new TaskRetentionUnsupportedError(); + } + const callAsTaskV1 = + generation === "v1" && + declaration !== undefined && + shouldCallToolAsTaskV1( + this.port.taskCapabilities.capabilities, + 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 = + retentionMs === undefined ? {} : { ttl: retentionMs }; + const dispatchContext = + options.headers === undefined && options.requestTimeoutMs === undefined + ? undefined + : { + headers: options.headers, + requestTimeoutMs: options.requestTimeoutMs, + }; + const executionId = nextExecutionIdentifier(); + this.ordinaryInputCandidates.set(executionId, { + lifetime: "basic", + generation: generation === "none" ? "v1" : generation, + toolName: name, + executionId, + applicationContext: options.applicationContext as TApplicationContext, + signal: callSignal, + }); + let continuedRequestParams: Record = requestParams; + let response: JsonRpcResponse | undefined; + try { + 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) { + if (response !== undefined) + this.cleanupLateTaskCreation(response, generation, callAsTaskV1); + 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, + }); + 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" && + 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" } = { + generation: "v2", + taskId: created.taskId as TaskId, + originalOperation: "tools/call", + }; + const releaseTaskIdentity = this.acquireTaskIdentity(handle); + return this.trackTaskExecution( + createTaskExecutionV2({ + applicationContext: options.applicationContext as TApplicationContext, + declaration, + handle, + initialTask: created, + 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(codec, wireResult)); + return new ImmediateExecution( + options.applicationContext as TApplicationContext, + resultPromise, + declaration, + ); + } + + async resumeTask( + reference: SerializedTaskReference, + options: TaskRecoveryOptions = {}, + initialSnapshot?: InternalTaskSnapshot, + ): 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"); + const activeTaskIdentity = this.taskIdentityOwners.get( + taskIdentityKey(reference), + ); + if (activeTaskIdentity !== undefined) { + throw new TaskRecoveryOwnershipError( + 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, + options.signal, + ); + const resumeSignal = resumeLifecycle.signal; + const executionId = nextExecutionIdentifier(); + const codec = selectResultCodec(reference.generation, options.resultCodec); + try { + throwIfAborted(resumeSignal); + this.assertUsable(); + throwIfAborted(resumeSignal); + + if (reference.generation === "v1") { + 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, + port: this.port, + lifecycleSignal: this.lifecycleController.signal, + }); + 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 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: detailedTask, + 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(); + } + } + + 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; + const params = this.lateTaskCancellationParams( + response.result, + generation, + callAsTaskV1, + ); + if (params === undefined) return; + void dispatchWithRetry( + this.port, + { method: "tasks/cancel", params }, + undefined, + ).catch(() => { + // A task returned after call abort is cleaned up on a best-effort basis. + }); + } + + close(): Promise { + 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"), + ); + for (const dispose of this.disposeListeners) { + try { + dispose(); + } catch (error) { + this.reportBackgroundError(reasonAsError(error)); + } + } + await Promise.all(childClosures); + })(); + return this.closePromise; + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } + + private acquireTaskIdentity( + reference: SerializedTaskReference | InternalTaskHandle, + ): () => void { + const key = taskIdentityKey(reference); + const active = this.taskIdentityOwners.get(key); + if (active !== undefined) { + throw new TaskRecoveryOwnershipError( + 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); + this.activeTaskExecutionsById.set(execution.handle.taskId, 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().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; + } + + 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" + ? TaskStatusNotificationV1Schema.safeParse(notification) + : generation === "v2" && method === "notifications/tasks" + ? TaskStatusNotificationV2Schema.safeParse(notification) + : undefined; + if (parsed === undefined) return; + if (!parsed.success) { + this.reportBackgroundError(parsed.error); + return; + } + const snapshot: InternalTaskSnapshot = + generation === "v1" + ? { 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); + } + } + + 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 { + const request = projectApplicationInputRequest(incoming); + if (request === undefined) return defaultServerRequestResponse(incoming); + + const resolution = resolveInputCandidate( + readRelatedTaskEvidence(request), + [...this.ordinaryInputCandidates.values()], + [...this.v1TaskInputCandidates.values()], + ); + if (resolution.kind === "failed") { + this.reportBackgroundError( + new InputCorrelationError( + request.kind, + resolution.candidates, + resolution.reason, + ), + ); + return defaultServerRequestResponse(incoming); + } + if (this.options.onInputRequest === undefined) + return defaultServerRequestResponse(incoming); + + try { + 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"); + return { kind: "result", result }; + } catch (error) { + this.reportBackgroundError(reasonAsError(error)); + 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"); + } +} + +/** Adds task execution support to a connected MCP session port. */ +export function withTasks( + session: ConnectedMcpSessionPort, + options: WithTasksOptions = {}, +): 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..ae786a2 --- /dev/null +++ b/packages/ext-tasks/src/client/task-controller.test.ts @@ -0,0 +1,510 @@ +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 context", 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" }, + requestTimeoutMs: 4_000, + }); + + 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" }, + requestTimeoutMs: 4_000, + }); + } + 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"), { + headers: { "x-route": "blue", "mcp-name": "caller-value" }, + }); + + 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": {} }, + }, + }, + }); + } + 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" } }); + 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..ecd81ef --- /dev/null +++ b/packages/ext-tasks/src/client/task-controller.ts @@ -0,0 +1,169 @@ +/** 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 && options.requestTimeoutMs === undefined + ? undefined + : { + headers: options.headers, + requestTimeoutMs: options.requestTimeoutMs, + }; + 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 new file mode 100644 index 0000000..ba194ea --- /dev/null +++ b/packages/ext-tasks/src/client/task-lifecycle-races.test.ts @@ -0,0 +1,1232 @@ +import fc from "fast-check"; +import { describe, expect, it, vi } from "vitest"; +import { + DispatchError, + JsonRpcResponseError, + TaskExecutionClosedError, + TaskFailedError, + TaskUpdatesAlreadyAcquiredError, + toolDeclaration, + withTasks, +} from "./index.js"; +import { + deterministicJson, + TaskExecution, + waitForTaskPoll, +} from "./execution.js"; +import { + FakePort, + asJson, + formatJson, + asError, + expectRecord, +} 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("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; + 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: () => + toolDeclaration({ 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(legacyResult(execution)).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + 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: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const result = execution.result(); + const updates = legacyUpdates(execution)[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([]); + 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); + } + 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: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await session.close(); + expect(cancelCalls).toBe(1); + await expect(legacyResult(execution)).rejects.toBeInstanceOf( + TaskExecutionClosedError, + ); + }); + + 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: {} }); + 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: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + 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 }, + ); + }); + + 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: () => + toolDeclaration({ 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(legacyResult(execution)).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: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const iterator = legacyUpdates(execution)[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("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: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + const iterator = legacyUpdates(execution)[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(legacyResult(execution)).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; + 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: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await expect(legacyResult(execution)).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: () => + toolDeclaration({ 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: { + type: "task", + 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(legacyResult(execution)).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: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await expect(execution.close()).resolves.toBeUndefined(); + await expect(legacyResult(execution)).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: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + port.invalidate(new Error("session replaced")); + await expect(legacyResult(execution)).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 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") + return { + kind: "result", + result: asJson({ + resultType: "task", + taskId: "preempt", + 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; + return new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + reject(asError(signal.reason)); + }, + { + once: true, + }, + ); + markGetStarted(); + }); + } + 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: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await getStarted; + 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(legacyResult(execution)).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(observationSignal?.aborted).toBe(true); + 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: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + }); + const execution = await session.callTool("x"); + await expect(legacyResult(execution)).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: () => + toolDeclaration({ 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("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 new file mode 100644 index 0000000..aa41218 --- /dev/null +++ b/packages/ext-tasks/src/client/task-protocol-v1.ts @@ -0,0 +1,90 @@ +/** Generation-specific requester-side V1 task execution. */ + +import type { RuntimeCodec } from "../core/index.js"; +import type { TaskV1 } from "../core/v1/index.js"; +import { TaskCancellationUnsupportedError } 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 { 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: InternalTaskHandle & { readonly generation: "v1" }; + readonly declaration?: ToolDeclaration; + readonly initialTask: TaskV1; + readonly resultCodec: RuntimeCodec; + readonly port: ConnectedMcpSessionPort; + readonly dispatchContext?: DispatchContext; + readonly lifecycleSignal: AbortSignal; +}): TaskExecution { + const { + applicationContext, + dispatchContext, + handle, + initialTask, + resultCodec, + port, + } = options; + const rpc = createTaskRpc("v1", { + port, + taskId: handle.taskId, + context: dispatchContext, + }); + return new TaskExecution({ + applicationContext, + declaration: options.declaration, + handle, + endpointId: port.endpointId as TaskSessionEndpointId, + initialSnapshot: { generation: "v1", task: initialTask }, + driver: async (context) => { + let current = initialTask; + let notificationSequence = 0; + while (!terminalStatus(current.status)) { + const observed = await context.nextObservation( + notificationSequence, + Math.max( + DEFAULT_TASK_POLL_INTERVAL_MS, + current.pollInterval ?? DEFAULT_TASK_POLL_INTERVAL_MS, + ), + async (observationSignal) => ({ + generation: "v1" as const, + task: await rpc.get(observationSignal), + }), + ); + 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 (!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 (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"); + return rpc.result(resultCodec, context.signal); + }, + cancelTask: async (signal) => { + const capabilities = port.taskCapabilities; + if ( + capabilities.generation !== "v1" || + capabilities.capabilities.cancel === undefined + ) + throw new TaskCancellationUnsupportedError(); + 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 new file mode 100644 index 0000000..2212ea7 --- /dev/null +++ b/packages/ext-tasks/src/client/task-protocol-v2.ts @@ -0,0 +1,374 @@ +/** Generation-specific requester-side V2 task execution. */ + +import type { JsonValue, RuntimeCodec } from "../core/index.js"; +import type { z } from "zod/v4"; +import { + CreateMessageResultV2Schema, + ElicitResultV2Schema, + ListRootsResultV2Schema, + type DetailedTaskV2, + type InputRequestV2, + type InputResponseV2, + type TaskV2, +} from "../core/v2/index.js"; +import { JsonRpcResponseError } from "./api.js"; +import type { + ApplicationInputHandler, + ApplicationInputRequest, + TaskSessionEndpointId, + ToolDeclaration, +} from "./api.js"; +import type { InternalTaskHandle } from "./internal.js"; +import { + TaskExecution, + deterministicJson, + taskPollInterval, + terminalStatus, +} from "./execution.js"; +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: InternalTaskHandle & { readonly generation: "v2" }; + readonly declaration?: ToolDeclaration; + readonly initialTask: TaskV2; + readonly initialDetailedTask?: DetailedTaskV2; + readonly resultCodec: RuntimeCodec; + readonly port: ConnectedMcpSessionPort; + readonly dispatchContext?: DispatchContext; + readonly lifecycleSignal: AbortSignal; + readonly onInputRequest?: ApplicationInputHandler["handle"]; + readonly reportError: (error: Error) => void; +} + +interface V2TaskRpcContext { + readonly rpc: TaskRpcV2; + readonly handle: InternalTaskHandle & { 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; +} + +const MAX_TASK_INPUT_ROUNDS = 10; + +type InputAcquisition = + | { readonly kind: "new" } + | { readonly kind: "duplicate" } + | { readonly kind: "incompatible" }; + +/** Reserves input keys during handling and commits them only after update succeeds. */ +class InputRequestLedger { + private readonly fingerprints = new Map< + string, + { + readonly fingerprint: string; + readonly state: "reserved" | "committed"; + } + >(); + + acquire(inputKey: string, request: InputRequestV2): InputAcquisition { + const fingerprint = deterministicJson(request); + const acquired = this.fingerprints.get(inputKey); + if (acquired === undefined) { + this.fingerprints.set(inputKey, { fingerprint, state: "reserved" }); + return { kind: "new" }; + } + return acquired.fingerprint === fingerprint + ? { kind: "duplicate" } + : { 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) + 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. */ +export function createTaskExecutionV2( + options: TaskExecutionV2Options, +): TaskExecution { + const rpcContext: V2TaskRpcContext = { + 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 as TaskSessionEndpointId, + initialSnapshot: { generation: "v2", task: options.initialTask }, + driver: (driverContext) => + driveTaskExecutionV2({ options, rpcContext, driverContext }), + cancelTask: (signal) => rpcContext.rpc.cancel(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(); + 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, + onInputRequest: options.onInputRequest, + reportError: options.reportError, + acquiredRequestLedger, + inputSignal: driverContext.inputSignal, + signal: driverContext.signal, + }; + + if (latestDetailedTask !== undefined) + await resolveTaskInput(latestDetailedTask); + + while (!terminalStatus(knownStatus)) { + const delayMs = taskPollInterval( + latestDetailedTask?.pollIntervalMs, + options.initialTask.pollIntervalMs, + ); + const observed = await driverContext.nextObservation( + lastNotificationSequence, + delayMs, + async (signal) => ({ + generation: "v2", + task: await rpcContext.rpc.get(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 resolveTaskInput(latestDetailedTask); + } + + if (driverContext.isClosed()) throw driverContext.errors.closed; + if (latestDetailedTask === undefined) + latestDetailedTask = await rpcContext.rpc.get(driverContext.signal); + return resolveTerminalTaskResult({ + task: latestDetailedTask, + resultCodec: options.resultCodec, + cancelledError: driverContext.errors.cancelled, + }); +} + +/** 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; +}): TResult { + const { task, resultCodec, cancelledError } = args; + switch (task.status) { + case "cancelled": + throw cancelledError; + case "failed": + throw new JsonRpcResponseError(task.error); + case "completed": + return parseResult(resultCodec, task.result); + default: + throw new Error(`Unsupported terminal task status: ${task.status}`); + } +} + +type InputResolution = { + readonly inputKey: string; + readonly response: InputResponseV2; +}; + +/** 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") + return { + kind: "roots", + ...(request.params === undefined ? {} : { params: request.params }), + }; + return { kind: "elicitation", params: request.params }; +} + +/** Selects the response validator for a V2 input request. */ +export function responseSchemaForInputRequest( + request: InputRequestV2, +): z.ZodType { + if (request.method === "sampling/createMessage") + return CreateMessageResultV2Schema; + if (request.method === "roots/list") return ListRootsResultV2Schema; + return ElicitResultV2Schema; +} + +type InputHandlerOutcome = + | { readonly kind: "result"; readonly value: unknown } + | { readonly kind: "skipped" }; + +async function invokeInputHandler(args: { + readonly inputKey: string; + readonly request: InputRequestV2; + readonly inputContext: V2InputContext; +}): Promise { + const { 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), { + scope: "task", + delivery: "task-update", + taskId: inputContext.handle.taskId, + inputId: 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 inputKey: string; + readonly request: InputRequestV2; + readonly inputContext: V2InputContext; +}): Promise { + const { 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; + } + + const outcome = await invokeInputHandler({ + inputKey, + request, + inputContext, + }); + if (outcome.kind === "skipped") { + inputContext.acquiredRequestLedger.release(inputKey); + return undefined; + } + + try { + return { + inputKey, + response: parseResult( + responseSchemaForInputRequest(request), + outcome.value as JsonValue, + ), + }; + } catch (error) { + inputContext.reportError( + error instanceof Error ? error : new Error(String(error)), + ); + inputContext.acquiredRequestLedger.release(inputKey); + return undefined; + } +} + +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({ + inputKey, + request, + inputContext, + }); + if (inputContext.inputSignal.aborted) return; + if (resolution !== undefined) + inputResponses[resolution.inputKey] = resolution.response; + } + if ( + inputContext.inputSignal.aborted || + Object.keys(inputResponses).length === 0 + ) + return; + try { + await inputContext.rpc.update(inputResponses, inputContext.signal); + 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 new file mode 100644 index 0000000..9bb4c39 --- /dev/null +++ b/packages/ext-tasks/src/client/task-resumption.test.ts @@ -0,0 +1,615 @@ +import fc from "fast-check"; +import { describe, expect, it, vi } from "vitest"; +import type { TaskId } from "../core/index.js"; +import { + DispatchError, + TaskRecoveryOwnershipError, + toolDeclaration, + withTasks, +} from "./index.js"; +import type { + JsonRpcResponse, + SessionTaskCapabilities, + SerializedTaskReference, +} from "./index.js"; +import { + FakePort, + asJson, + formatJson, + asError, + expectRecord, +} 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("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( + 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("does not misroute evidence-free initiating input to a resumed task", 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 contexts: unknown[] = []; + const session = withTasks(port, { + tools: { + currentTool: (name) => + name === "ordinary" + ? toolDeclaration({ + 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({ + 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 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({ + scope: "request", + delivery: "peer-request", + applicationContext: undefined, + }); + 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" + ? toolDeclaration({ + name: "roundtrip", + inputSchema: { type: "object" }, + execution: { taskSupport: "required" }, + }) + : toolDeclaration({ + name: "roundtrip", + inputSchema: { type: "object" }, + }), + }, + }); + 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(legacyResult(resumed)).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 only for retryable 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 resume = session.resumeTask({ + endpointId: port.endpointId, + generation: "v2", + taskId: "retry-resume" as TaskId, + originalOperation: "tools/call", + }); + 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 }, + ); + }); + + 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(legacyResult(execution)).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(legacyResult(execution)).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 legacyResult(first); + await Promise.resolve(); + const second = await session.resumeTask(reference); + 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 new file mode 100644 index 0000000..dbb5cfd --- /dev/null +++ b/packages/ext-tasks/src/client/tool-declarations.ts @@ -0,0 +1,146 @@ +import type { JsonValue } from "../core/index.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 { projectTool } from "./internal.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): ToolDeclaration | 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"); + const generation = this.port.taskCapabilities.generation; + for (const value of listed) { + let declaration: ToolDeclaration; + if (generation === "v1") { + const parsed = ToolV1Schema.safeParse(value); + if (!parsed.success) throw parsed.error; + declaration = projectTool(parsed.data); + } else { + const parsed = ToolV2Schema.safeParse(value); + if (!parsed.success) throw parsed.error; + declaration = projectTool(parsed.data); + } + 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; + } 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..5df3514 --- /dev/null +++ b/packages/ext-tasks/src/client/v1-input-task.test.ts @@ -0,0 +1,798 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import type { JsonValue } from "../core/index.js"; +import type { ServerTaskCapabilitiesV1, ToolV1 } from "../core/v1/index.js"; +import { + InputCorrelationError, + TaskCancellationUnsupportedError, + TaskExecutionClosedError, + toolDeclaration, + withTasks, +} from "./index.js"; +import type { JsonRpcResponse } from "./index.js"; +import { + FakePort, + asJson, + formatJson, + asError, + expectRecord, +} 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: { + scope: "request", + delivery: "peer-request", + applicationContext: { marker: "ctx" }, + }, + }); + expect( + (observed[0] as { context: { inputId: string } }).context.inputId, + ).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: {} }); + const errors: Error[] = []; + 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"); + }, + 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(); + }); + + 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) => + toolDeclaration({ + 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-${String(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 === "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({ + scope: "task", + delivery: "peer-request", + 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" + ? "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).toEqual([]); + } + 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: () => + toolDeclaration({ + 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: { + _meta: { + "io.modelcontextprotocol/related-task": { taskId: "lifecycle" }, + }, + }, + }); + expect(handlerSignal?.aborted).toBe(false); + await execution.close(); + await expect(legacyResult(execution)).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 toolDeclaration(tool); + }, + }, + }); + taskSelected = + present && + (taskSupport === "required" || + (taskSupport === "optional" && preferTask)); + const execution = await session.callTool("x", undefined, { + task: { preference: preferTask ? "prefer" : "allow" }, + }); + 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: () => toolDeclaration(tool) }, + }); + const execution = await session.callTool("long"); + expect(execution.kind).toBe("task"); + expect(execution.handle).toEqual({ + taskId: "v1-task", + operation: "tools/call", + }); + const snapshots: unknown[] = []; + for await (const snapshot of legacyUpdates(execution)) + 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.toMatchObject({ + status: "completed", + result: { content: [{ type: "text", text: "done" }] }, + }); + 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: () => toolDeclaration(tool) }, + }); + const execution = await session.callTool("notified"); + const iterator = legacyUpdates(execution)[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(legacyResult(execution)).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", + 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: () => toolDeclaration(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(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 new file mode 100644 index 0000000..bf1607e --- /dev/null +++ b/packages/ext-tasks/src/client/v2-input-task.test.ts @@ -0,0 +1,684 @@ +import fc from "fast-check"; +import { describe, expect, it, vi } from "vitest"; +import { toolDeclaration, withTasks } from "./index.js"; +import { + FakePort, + asJson, + formatJson, + asError, + expectRecord, +} 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" as const } }; + 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: { + 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: () => toolDeclaration(tool) }, + }); + const execution = await session.callTool("long"); + expect(execution.kind).toBe("task"); + expect(execution.handle).toEqual({ + taskId: "v2-task", + operation: "tools/call", + }); + expect(port.requests[0]).toMatchObject({ + method: "tools/call", + params: { + _meta: { + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }, + }, + }); + await expect(legacyResult(execution)).resolves.toEqual({ + resultType: "complete", + content: [{ type: "text", text: "done" }], + }); + 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( + 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: { 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: () => + toolDeclaration({ + 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", text: "sampled" }, + } + : request.kind === "roots" + ? { roots: [{ uri: "file:///root" }] } + : { action: "cancel" } + ) as never; + }, + }); + const execution = await session.callTool( + "x", + {}, + { + applicationContext: { marker: "context" }, + }, + ); + await expect(legacyResult(execution)).resolves.toEqual({ + resultType: "complete", + content: [], + }); + expect(observed).toHaveLength(inputs.length); + expect( + observed.map((value) => { + const entry = expectRecord(asJson(value)); + 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", + ); + 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("does not reacquire repeated V2 keys and reports incompatible reuse", 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 <= 3) + return { + kind: "result", + result: asJson({ + resultType: "complete", + taskId: "repeat", + status: "input_required", + createdAt: "a", + lastUpdatedAt: String(getCalls), + ttlMs: null, + inputRequests: { + same: + getCalls <= 2 + ? { 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: { content: [] }, + }), + }; + } + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const session = withTasks(port, { + tools: { + currentTool: () => + toolDeclaration({ 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(legacyResult(execution)).resolves.toMatchObject({ + resultType: "complete", + }); + expect(handlerCalls).toBe(3); + expect( + errors.some((error) => error.message.includes("reused incompatibly")), + ).toBe(false); + expect(errors.some((error) => error.message === "declined")).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: { content: [] }, + }, + ), + }; + } + if (method === "tasks/update") + return { kind: "result", result: { resultType: "complete" } }; + throw new Error(`unexpected method ${formatJson(method)}`); + }; + const errors: Error[] = []; + const session = withTasks(port, { + tools: { + currentTool: () => + toolDeclaration({ name: "x", inputSchema: { type: "object" } }), + }, + onInputRequest: async () => { + await Promise.resolve(); + throw new Error("declined"); + }, + onError: (error) => errors.push(error), + }); + const execution = await session.callTool("x"); + await expect(legacyResult(execution)).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" }, + }); + expect(errors).toHaveLength(3); + expect(errors.every((error) => error.message === "declined")).toBe(true); + 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: () => + toolDeclaration({ 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: { content: [] }, + }, + }), + ); + await expect(legacyResult(execution)).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: { 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: () => + toolDeclaration({ + name: "x", + inputSchema: { type: "object" }, + }), + }, + }); + const execution = await session.callTool("x"); + if (status === "completed") + await expect(legacyResult(execution)).resolves.toEqual({ + resultType: "complete", + content: [], + }); + else if (status === "failed") + await expect(legacyResult(execution)).rejects.toMatchObject({ + name: "JsonRpcResponseError", + code: -32000, + message: "task failed", + }); + else await expect(legacyResult(execution)).rejects.toThrow(/cancel/i); + expect(getCalls).toBe(1); + await session.close(); + }, + ), + { numRuns: 9 }, + ); + }); +}); +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 new file mode 100644 index 0000000..9910b91 --- /dev/null +++ b/packages/ext-tasks/src/core/index.test.ts @@ -0,0 +1,167 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { + JsonValueCodec, + ProtocolDecodeError, + isJsonValue, + runtimeCodecFromStandardSchema, + taskId, + toJsonValue, + type JsonValue, +} from "./index.js"; + +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 and parses exactly JSON-compatible generated values", () => { + fc.assert( + fc.property(jsonValue, (value) => { + expect(isJsonValue(value)).toBe(true); + expect(JsonValueCodec.parse(value)).toEqual({ success: true, value }); + }), + ); + fc.assert( + fc.property( + fc.oneof(fc.constant(undefined), fc.bigInt(), fc.constant(Symbol("x"))), + (value) => { + expect(isJsonValue(value)).toBe(false); + const decoded = JsonValueCodec.parse(value as never); + expect(decoded.success).toBe(false); + if (!decoded.success) + expect(decoded.error).toBeInstanceOf(ProtocolDecodeError); + }, + ), + ); + }); + + it("rejects exotic, cyclic, sparse, and non-finite values", () => { + const sparse: unknown[] = []; + sparse.length = 1; + const cyclic: Record = {}; + cyclic.self = cyclic; + class Exotic { + readonly marker = "non-plain"; + } + 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/, + ]; + + for (const value of nonJsonValues) { + 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(JsonValueCodec.parse(value as JsonValue)).toEqual({ + success: true, + 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) => { + expect(taskId(value)).toBe(value); + }), + ); + }); +}); diff --git a/packages/ext-tasks/src/core/index.ts b/packages/ext-tasks/src/core/index.ts new file mode 100644 index 0000000..1713904 --- /dev/null +++ b/packages/ext-tasks/src/core/index.ts @@ -0,0 +1,183 @@ +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 }; + +/** 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 { + readonly details: ProtocolDecodeErrorDetails; + + constructor( + message: string, + details: ProtocolDecodeErrorDetails = {}, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "ProtocolDecodeError"; + this.details = details; + } +} + +export type RuntimeDecodeResult = + | { readonly success: true; readonly value: T } + | { readonly success: false; readonly error: ProtocolDecodeError }; + +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 }; + +/** Brands a string as a task identifier without runtime validation or transformation. */ +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. + */ +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); + const valid = Array.isArray(candidate) + ? candidate.length === Object.keys(candidate).length && + candidate.every(visit) + : isJsonObject(candidate, visit); + visiting.delete(candidate); + return valid; + }; + return visit(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/index.test.ts b/packages/ext-tasks/src/core/v1/index.test.ts new file mode 100644 index 0000000..f13d717 --- /dev/null +++ b/packages/ext-tasks/src/core/v1/index.test.ts @@ -0,0 +1,424 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import type { z } from "zod/v4"; + +import { + CallToolRequestV1Schema, + CallToolResultV1Schema, + CancelTaskRequestV1Schema, + CancelTaskResultV1Schema, + CreateTaskResultV1Schema, + GetTaskRequestV1Schema, + GetTaskResultRequestV1Schema, + GetTaskResultV1Schema, + ListTasksRequestV1Schema, + ListTasksResultV1Schema, + ServerTaskCapabilitiesV1Schema, + TaskResultV1Schema, + TaskStatusNotificationV1Schema, + TaskStatusV1Schema, + TaskV1Schema, + ToolV1Schema, + 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 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 jsonRecordArb = fc.dictionary( + fc.string().filter((key) => key !== "__proto__"), + fc.jsonValue(), +); +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() }), + }); + +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 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.assert( + fc.property( + taskArb, + fc.constantFrom( + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttl", + ), + (task, key) => { + const invalid = Object.fromEntries( + Object.entries(task).filter(([candidate]) => candidate !== key), + ); + expect(TaskV1Schema.safeParse(invalid).success).toBe(false); + }, + ), + ); + fc.assert( + fc.property( + taskArb, + fc + .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( + 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("enforces exact JSON-RPC literals and required request fields", () => { + const cases = [ + [GetTaskRequestV1Schema, taskRequestArb("tasks/get")], + [GetTaskResultRequestV1Schema, taskRequestArb("tasks/result")], + [CancelTaskRequestV1Schema, taskRequestArb("tasks/cancel")], + ] as const; + for (const [schema, arbitrary] of cases) { + fc.assert( + fc.property(arbitrary, (request) => { + expectRoundTrip(schema, request); + expect(schema.safeParse({ ...request, jsonrpc: "1.0" }).success).toBe( + false, + ); + expect( + schema.safeParse({ ...request, method: "tasks/nope" }).success, + ).toBe(false); + for (const key of ["jsonrpc", "id", "method", "params"] as const) { + 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( + 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("parses all result and notification schema outputs", () => { + fc.assert( + 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, _meta: metadata }, + }); + }), + ); + fc.assert( + fc.property( + fc.array(taskArb), + fc.option(fc.string(), { nil: undefined }), + (tasks, nextCursor) => { + expectRoundTrip(ListTasksResultV1Schema, { + tasks, + ...(nextCursor === undefined ? {} : { nextCursor }), + }); + }, + ), + ); + fc.assert( + fc.property( + idArb, + fc.option(fc.string(), { nil: undefined }), + (id, cursor) => { + expectRoundTrip(ListTasksRequestV1Schema, { + jsonrpc: "2.0", + id, + method: "tasks/list", + ...(cursor === undefined ? {} : { params: { cursor } }), + }); + }, + ), + ); + fc.assert( + fc.property(jsonRecordArb, (result) => { + expectRoundTrip(TaskResultV1Schema, result); + }), + ); + }); + + it("validates tool content discriminators and required fields", () => { + const content = [ + { 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(CallToolResultV1Schema, { + content, + structuredContent: { ok: true }, + isError: false, + }); + fc.assert( + fc.property( + fc + .string() + .filter( + (type) => + !["text", "image", "audio", "resource_link", "resource"].includes( + type, + ), + ), + (type) => { + expect( + CallToolResultV1Schema.safeParse({ content: [{ type }] }).success, + ).toBe(false); + }, + ), + ); + expect( + CallToolResultV1Schema.safeParse({ content: [{ type: "text" }] }).success, + ).toBe(false); + expect(CallToolResultV1Schema.safeParse({}).success).toBe(false); + }); + + 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, + }), + jsonRecordArb, + fc.array(jsonRecordArb), + (name, taskSupport, metadata, icons) => { + expectRoundTrip(ToolV1Schema, { + name, + title: "title", + description: "description", + inputSchema: { type: "object" }, + outputSchema: { type: "object", properties: {} }, + execution: { + ...(taskSupport === undefined ? {} : { taskSupport }), + }, + annotations: metadata, + icons, + _meta: metadata, + }); + }, + ), + ); + expect(ToolV1Schema.safeParse({ name: "x", inputSchema: {} }).success).toBe( + false, + ); + expect( + ToolV1Schema.safeParse({ + name: "x", + inputSchema: { type: "object" }, + execution: { taskSupport: "sometimes" }, + }).success, + ).toBe(false); + fc.assert( + 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: {}, + cancel: {}, + requests: { tools: { call: {} } }, + }); + expect( + ServerTaskCapabilitiesV1Schema.safeParse({ + requests: { tools: { call: true } }, + }).success, + ).toBe(false); + }); + + it("follows every capability-first negotiation row", () => { + 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 new file mode 100644 index 0000000..113db08 --- /dev/null +++ b/packages/ext-tasks/src/core/v1/index.ts @@ -0,0 +1,60 @@ +/** 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, + 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 "./schemas.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..4f1c264 --- /dev/null +++ b/packages/ext-tasks/src/core/v1/negotiation.ts @@ -0,0 +1,63 @@ +/** 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 "./schemas.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, + preferTask = false, +): boolean { + if (!hasTaskToolCallCapabilityV1(capabilities)) return false; + return ( + tool.execution?.taskSupport === "required" || + (tool.execution?.taskSupport === "optional" && preferTask) + ); +} +/** + * Builds a `tools/call` task request, omitting `arguments` when none are supplied. + */ +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/schemas.ts b/packages/ext-tasks/src/core/v1/schemas.ts new file mode 100644 index 0000000..63fad18 --- /dev/null +++ b/packages/ext-tasks/src/core/v1/schemas.ts @@ -0,0 +1,222 @@ +/** MCP Tasks V1 runtime schemas and schema-derived wire types. */ +import * as z from "zod/v4"; + +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") }) + .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/v2/index.test.ts b/packages/ext-tasks/src/core/v2/index.test.ts new file mode 100644 index 0000000..6bc8caa --- /dev/null +++ b/packages/ext-tasks/src/core/v2/index.test.ts @@ -0,0 +1,863 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { type JsonValue } from "../index.js"; + +import * as coreV2 from "./index.js"; + +import { + CallToolResultV2Schema, + CancelTaskRequestV2Schema, + CancelTaskResultV2Schema, + CreateTaskResultV2Schema, + DetailedTaskV2Schema, + ErrorV2Schema, + GetTaskRequestV2Schema, + GetTaskResultV2Schema, + CreateMessageResultV2Schema, + InputRequestsV2Schema, + InputResponsesV2Schema, + TaskStatusNotificationParamsV2Schema, + TaskStatusNotificationV2Schema, + TasksExtensionCapabilityV2Schema, + TaskV2Schema, + ToolV2Schema, + UpdateTaskRequestV2Schema, + UpdateTaskResultV2Schema, + contributeTaskFilterV2, + hasTaskClientCapabilityV2, + hasTaskServerCapabilityV2, + isToolCallTaskResultV2, + readAcceptedTaskIdsV2, + withTaskCapabilityV2, + type CallToolResultV2, + type TasksExtensionCapabilityV2, + 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 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) => { + expect(TaskV2Schema.safeParse(asJson(task)).success).toBe(true); + }), + ); + fc.assert( + fc.property( + baseTask, + fc.constantFrom( + "taskId", + "status", + "createdAt", + "lastUpdatedAt", + "ttlMs", + ), + (task, key) => { + const invalid = Object.fromEntries( + Object.entries(task).filter(([candidate]) => candidate !== key), + ); + expect(TaskV2Schema.safeParse(asJson(invalid)).success).toBe(false); + }, + ), + ); + fc.assert( + fc.property( + baseTask, + fc + .string() + .filter((status) => !statuses.includes(status as TaskStatusV2)), + (task, status) => { + expect( + TaskV2Schema.safeParse(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( + TaskV2Schema.safeParse(asJson({ ...task, ttlMs })).success, + ).toBe(false); + }, + ), + ); + }); + + it("keeps Task closed while preserving wrapper metadata", () => { + const decoded = TaskV2Schema.safeParse({ + taskId: "task", + status: "working", + createdAt: "created", + lastUpdatedAt: "updated", + ttlMs: null, + vendorHint: 1, + }); + expect(decoded.success).toBe(true); + if (decoded.success) expect("vendorHint" in decoded.data).toBe(false); + + const notification = TaskStatusNotificationParamsV2Schema.safeParse({ + taskId: "task", + status: "working", + createdAt: "created", + lastUpdatedAt: "updated", + ttlMs: null, + _meta: { vendorHint: 1 }, + }); + expect(notification.success).toBe(true); + if (notification.success) + expect(notification.data._meta).toEqual({ vendorHint: 1 }); + }); + + it("enforces status-owned DetailedTask payloads", () => { + fc.assert( + fc.property(taskFor("working"), (task) => { + expect(DetailedTaskV2Schema.safeParse(asJson(task)).success).toBe(true); + }), + ); + fc.assert( + fc.property(taskFor("cancelled"), (task) => { + expect(DetailedTaskV2Schema.safeParse(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( + DetailedTaskV2Schema.safeParse(asJson({ ...task, inputRequests })) + .success, + ).toBe(true); + }, + ), + ); + fc.assert( + fc.property( + taskFor("completed"), + fc.dictionary(fc.string(), fc.jsonValue()), + (task, result) => { + expect( + DetailedTaskV2Schema.safeParse(asJson({ ...task, result })).success, + ).toBe(true); + }, + ), + ); + fc.assert( + fc.property( + taskFor("failed"), + fc.integer(), + fc.string(), + (task, code, message) => { + expect( + DetailedTaskV2Schema.safeParse( + asJson({ ...task, error: { code, message } }), + ).success, + ).toBe(true); + }, + ), + ); + fc.assert( + fc.property( + fc.constantFrom("input_required", "completed", "failed"), + (status) => { + expect( + DetailedTaskV2Schema.safeParse({ + 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( + InputRequestsV2Schema.safeParse(asJson(requests)).success, + ).toBe(true); + }, + ), + ); + expect( + InputRequestsV2Schema.safeParse({ + 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.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), + }), + ), + ), + (responses) => { + expect( + InputResponsesV2Schema.safeParse(asJson(responses)).success, + ).toBe(true); + }, + ), + ); + expect(InputResponsesV2Schema.safeParse({ 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( + ErrorV2Schema.safeParse( + asJson({ + code, + message, + ...(data === undefined ? {} : { data }), + }), + ).success, + ).toBe(true); + }, + ), + ); + expect(ErrorV2Schema.safeParse({ code: 1 }).success).toBe(false); + expect(ErrorV2Schema.safeParse({ 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 = ToolV2Schema.safeParse(tool); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data).toEqual(tool); + }, + ), + ); + expect(ToolV2Schema.safeParse({ name: "x", inputSchema: {} }).success).toBe( + false, + ); + expect( + ToolV2Schema.safeParse({ name: "x", inputSchema: { type: "array" } }) + .success, + ).toBe(false); + for (const [field, invalid] of [ + ["outputSchema", true], + ["annotations", true], + ["icons", true], + ["_meta", true], + ] as const) { + expect( + ToolV2Schema.safeParse({ + name: "x", + inputSchema: { type: "object" }, + [field]: invalid, + }).success, + ).toBe(false); + } + expect( + ToolV2Schema.safeParse({ + name: "x", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: "yes" }, + }).success, + ).toBe(false); + expect( + ToolV2Schema.safeParse({ + 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.constant("complete" as const), + 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 = CallToolResultV2Schema.safeParse(result); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data).toEqual(result); + }, + ), + ); + expect(CallToolResultV2Schema.parse({ content: [] })).toEqual({ + resultType: "complete", + content: [], + }); + expect( + CallToolResultV2Schema.safeParse({ resultType: "complete" }).success, + ).toBe(false); + expect( + CallToolResultV2Schema.safeParse({ resultType: 1, content: [] }).success, + ).toBe(false); + expect( + CallToolResultV2Schema.safeParse({ resultType: "task", content: [] }) + .success, + ).toBe(false); + expect( + CallToolResultV2Schema.safeParse({ + 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( + CallToolResultV2Schema.safeParse({ + resultType: "complete", + content: [{ type }], + }).success, + ).toBe(false); + }, + ), + ); + expect( + CallToolResultV2Schema.safeParse({ + 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( + GetTaskRequestV2Schema.safeParse({ + jsonrpc: "2.0", + id, + method: "tasks/get", + params: { taskId }, + }).success, + ).toBe(true); + expect( + CancelTaskRequestV2Schema.safeParse({ + jsonrpc: "2.0", + id, + method: "tasks/cancel", + params: { taskId }, + }).success, + ).toBe(true); + expect( + UpdateTaskRequestV2Schema.safeParse({ + jsonrpc: "2.0", + id, + method: "tasks/update", + params: { taskId, inputResponses: {} }, + }).success, + ).toBe(true); + }, + ), + ); + for (const schema of [ + GetTaskRequestV2Schema, + UpdateTaskRequestV2Schema, + CancelTaskRequestV2Schema, + ]) { + expect( + 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( + UpdateTaskResultV2Schema.safeParse({ resultType: "task" }).success, + ).toBe(false); + fc.assert( + fc.property( + taskFor("completed"), + fc.dictionary(fc.string(), fc.jsonValue()), + (task, result) => { + const parsed = GetTaskResultV2Schema.parse( + asJson({ ...task, result }), + ); + expect(parsed.resultType).toBe("complete"); + }, + ), + ); + }); + + it("discriminates Task creation only for eligible tools/call results", () => { + fc.assert( + fc.property(baseTask, (task) => { + const result = asJson({ ...task, resultType: "task" }); + expect(CreateTaskResultV2Schema.safeParse(result).success).toBe(true); + expect(isToolCallTaskResultV2("tools/call", result)).toBe(true); + expect(isToolCallTaskResultV2("prompts/get", result)).toBe(false); + }), + ); + expect( + CreateTaskResultV2Schema.safeParse({ resultType: "complete" }).success, + ).toBe(false); + }); + + it("decodes detailed task notifications with exact envelope discriminators", () => { + fc.assert( + fc.property(taskFor("working"), (task) => { + expect( + TaskStatusNotificationV2Schema.safeParse( + asJson({ + jsonrpc: "2.0", + method: "notifications/tasks", + params: task, + }), + ).success, + ).toBe(true); + }), + ); + expect( + TaskStatusNotificationV2Schema.safeParse({ + jsonrpc: "2.0", + method: "notifications/wrong", + params: {}, + }).success, + ).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 = + TaskStatusNotificationParamsV2Schema.safeParse(params); + expect(paramsResult.success).toBe(true); + if (paramsResult.success) + expect(paramsResult.data._meta).toEqual(asJson(meta)); + + const notificationResult = TaskStatusNotificationV2Schema.safeParse({ + jsonrpc: "2.0", + method: "notifications/tasks", + params, + }); + expect(notificationResult.success).toBe(true); + if (notificationResult.success) + expect(notificationResult.data.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 = + TaskStatusNotificationParamsV2Schema.safeParse(params); + expect(paramsResult.success).toBe(false); + if (!paramsResult.success) { + expect(paramsResult.error.issues[0]?.path).toEqual(["_meta"]); + } + + const notificationResult = TaskStatusNotificationV2Schema.safeParse({ + jsonrpc: "2.0", + method: "notifications/tasks", + params, + }); + expect(notificationResult.success).toBe(false); + if (!notificationResult.success) + 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: { type: "text", text: "ok" }, + model: "model", + role: "assistant", + }, + }).success, + ).toBe(true); + expect( + InputResponsesV2Schema.safeParse({ + response: { + roots: "invalid", + content: { type: "text", text: "ok" }, + model: "model", + role: "assistant", + }, + }).success, + ).toBe(true); + expect( + InputResponsesV2Schema.safeParse({ + response: { + action: "accept", + roots: "ignored extension value", + content: { type: "text", text: "ignored extension value" }, + 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(TasksExtensionCapabilityV2Schema.safeParse(capability)).toEqual({ + success: true, + data: {}, + }); + expect(CallToolResultV2Schema.safeParse(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 [ + "ToolCallResultV2Schema", + "isEligibleTaskResultV2", + "TaskExtensionCapabilitiesV2Schema", + "supportsTasksExtensionV2", + ...Object.keys(coreV2).filter((name) => name.endsWith("Codec")), + ]) { + expect(removed in coreV2).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< + 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([]); + }, + ), + ); + }); + + 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 new file mode 100644 index 0000000..9c48a27 --- /dev/null +++ b/packages/ext-tasks/src/core/v2/index.ts @@ -0,0 +1,17 @@ +/** MCP Tasks V2 public API. */ +export * from "./schemas.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..6ebb6a6 --- /dev/null +++ b/packages/ext-tasks/src/core/v2/integration.ts @@ -0,0 +1,165 @@ +/** MCP Tasks V2 guards, capability integration, and subscription helpers. */ +import type { z } from "zod/v4"; +import { type JsonValue } from "../index.js"; +import { + CreateTaskResultV2Schema, + DetailedTaskV2Schema, + GetTaskRequestV2Schema, + TaskStatusNotificationV2Schema, + TaskV2Schema, + UpdateTaskRequestV2Schema, + CancelTaskRequestV2Schema, +} from "./schemas.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 "./schemas.js"; +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); +export const isDetailedTaskV2: (value: unknown) => value is DetailedTaskV2 = ( + value: unknown, +): value is DetailedTaskV2 => + parsed(DetailedTaskV2Schema, value); +export const isCreateTaskResultV2: ( + value: unknown, +) => value is CreateTaskResultV2 = ( + value: unknown, +): value is CreateTaskResultV2 => + parsed(CreateTaskResultV2Schema, value); +export const isGetTaskRequestV2: ( + value: unknown, +) => value is GetTaskRequestV2 = (value: unknown): value is GetTaskRequestV2 => + parsed(GetTaskRequestV2Schema, value); +export const isUpdateTaskRequestV2: ( + value: unknown, +) => value is UpdateTaskRequestV2 = ( + value: unknown, +): value is UpdateTaskRequestV2 => + parsed(UpdateTaskRequestV2Schema, value); +export const isCancelTaskRequestV2: ( + value: unknown, +) => value is CancelTaskRequestV2 = ( + value: unknown, +): value is CancelTaskRequestV2 => + parsed(CancelTaskRequestV2Schema, value); +export const isTaskStatusNotificationV2: ( + value: unknown, +) => value is TaskStatusNotificationV2 = ( + value: unknown, +): value is TaskStatusNotificationV2 => + parsed(TaskStatusNotificationV2Schema, value); + +/** + * Recognizes a decoded task-creation result only when it belongs to `tools/call`. + */ +export function isToolCallTaskResultV2( + method: string, + value: unknown, +): value is CreateTaskResultV2 { + 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 { + 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. + */ +export function hasTaskServerCapabilityV2( + value: unknown, +): value is ServerTaskCapabilityEnvelopeV2 { + const serverCapabilities = asObjectRecord(value); + return hasOwnTaskExtension(serverCapabilities?.extensions); +} + +/** + * 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> { + const existingMetadata = asObjectRecord(params._meta) ?? {}; + const capability = { extensions: { [TASKS_EXTENSION_ID_V2]: {} } }; + return { + ...params, + _meta: { + ...existingMetadata, + [CLIENT_CAPABILITIES_META_KEY_V2]: capability, + }, + }; +} + +/** + * Returns a filter with deduplicated task IDs, preserving existing object-shaped + * notification fields and replacing malformed notification data. + */ +export function contributeTaskFilterV2< + T extends Readonly>, +>( + filter: T, + taskIds: readonly string[], +): T & { + readonly notifications: Readonly> & { + readonly taskIds: readonly string[]; + }; +} { + const existingNotifications = asObjectRecord(filter.notifications) ?? {}; + return { + ...filter, + notifications: { + ...existingNotifications, + 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[] { + 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 new file mode 100644 index 0000000..c3b57f7 --- /dev/null +++ b/packages/ext-tasks/src/core/v2/schemas.ts @@ -0,0 +1,464 @@ +/** MCP Tasks V2 Zod schemas and schema-inferred wire declarations. */ +import { z } from "zod/v4"; + +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; + +const JsonObjectSchema = z.custom>>( + (value) => + value !== null && + typeof value === "object" && + !Array.isArray(value) && + isJsonValue(value), + "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)); + return z.unknown().transform((value, context) => { + const parsed = validator.safeParse(value); + if (parsed.success) { + const data = parsed.data; + copyUndeclaredJsonKeys( + data, + value as Readonly>, + declaredKeys, + ); + return 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 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([ + TextContentBlockV2Schema, + ImageContentBlockV2Schema, + AudioContentBlockV2Schema, + 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(), + 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 CompleteCallToolResultV2Schema = 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 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: 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), +}); +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, + 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 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"), + params: z.object({ + taskId: z.string(), + inputResponses: InputResponsesV2Schema, + }), +}); +const CancelTaskRequestV2Schema = taskIdRequestV2Schema("tasks/cancel"); +const GetTaskResultV2Schema = z.intersection( + DetailedTaskV2Schema, + z.object({ + resultType: CompleteResultTypeSchema, + _meta: MetaSchema.optional(), + }), +); +const CompleteOperationResultShape = { + resultType: CompleteResultTypeSchema, + _meta: MetaSchema.optional(), +}; +const completeOperationResultV2Schema = () => + openObject(CompleteOperationResultShape); +const UpdateTaskResultV2Schema = completeOperationResultV2Schema(); +const CancelTaskResultV2Schema = completeOperationResultV2Schema(); + +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, + InputRequiredCallToolResultV2Schema, + 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 InputRequiredCallToolResultV2 = z.infer< + typeof InputRequiredCallToolResultV2Schema +>; +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/receiver/index.ts b/packages/ext-tasks/src/receiver/index.ts new file mode 100644 index 0000000..4f65573 --- /dev/null +++ b/packages/ext-tasks/src/receiver/index.ts @@ -0,0 +1,492 @@ +/** 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; + +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +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 remainingMs = record.expiresAt - now(); + if (remainingMs <= 0) { + remove(record, "expiry"); + return; + } + const timer = setTimeout( + () => { + record.expiryTimer = undefined; + armExpiry(record); + }, + Math.min(remainingMs, MAX_TIMER_DELAY_MS), + ); + 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..6e305a2 --- /dev/null +++ b/packages/ext-tasks/src/receiver/receiver.test.ts @@ -0,0 +1,486 @@ +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("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), { + 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/fake-port.ts b/packages/ext-tasks/test-support/client/fake-port.ts new file mode 100644 index 0000000..76b7325 --- /dev/null +++ b/packages/ext-tasks/test-support/client/fake-port.ts @@ -0,0 +1,104 @@ +import type { JsonValue } from "../../src/core/index.js"; +import { z } from "zod/v4"; +import type { + ConnectedMcpSessionPort, + DispatchOptions, + IncomingServerRequest, + JsonRpcResponse, + SessionTaskCapabilities, +} from "../../src/client/index.js"; + +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 => { + 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)); + +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?: DispatchOptions, + ) => 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?: DispatchOptions, + ): Promise { + this.requests.push(request); + this.dispatchOptions.push(options); + 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/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, + }; + } +} diff --git a/packages/ext-tasks/tsconfig.eslint.json b/packages/ext-tasks/tsconfig.eslint.json new file mode 100644 index 0000000..8791751 --- /dev/null +++ b/packages/ext-tasks/tsconfig.eslint.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.package.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false, + "isolatedDeclarations": false, + "noEmit": true + }, + "include": ["src/**/*.ts", "test-support/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/ext-tasks/tsconfig.json b/packages/ext-tasks/tsconfig.json new file mode 100644 index 0000000..06215f4 --- /dev/null +++ b/packages/ext-tasks/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.package.json", + "compilerOptions": { + "isolatedDeclarations": false, + "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..b71f450 --- /dev/null +++ b/packages/ext-tasks/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.package.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false, + "isolatedDeclarations": false, + "noEmit": true + }, + "include": ["src/**/*.ts", "test-support/**/*.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 + } +} 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.