diff --git a/.changeset/everything-sdk-v2.md b/.changeset/everything-sdk-v2.md new file mode 100644 index 0000000000..07fe4f38ae --- /dev/null +++ b/.changeset/everything-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-everything": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x (#4856). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in #4852. Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. diff --git a/.changeset/filesystem-sdk-v2.md b/.changeset/filesystem-sdk-v2.md new file mode 100644 index 0000000000..523a805d57 --- /dev/null +++ b/.changeset/filesystem-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-filesystem": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/memory-sdk-v2.md b/.changeset/memory-sdk-v2.md new file mode 100644 index 0000000000..e1ab5b57df --- /dev/null +++ b/.changeset/memory-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-memory": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/sequentialthinking-sdk-v2.md b/.changeset/sequentialthinking-sdk-v2.md new file mode 100644 index 0000000000..9e76a69e93 --- /dev/null +++ b/.changeset/sequentialthinking-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-sequential-thinking": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. diff --git a/.claude/skills/client-smoke/SKILL.md b/.claude/skills/client-smoke/SKILL.md index df76bc1b1d..a650902175 100644 --- a/.claude/skills/client-smoke/SKILL.md +++ b/.claude/skills/client-smoke/SKILL.md @@ -172,9 +172,10 @@ CLI) or **Server Settings → Protocol Era** (the web UI): | `modern` | Pins 2026-07-28 through `server/discover`, with no fallback | | `auto` | Probes `server/discover`, and falls back to `initialize` | -⚠️ **Only the legacy era can be exercised against these servers today.** All -seven are on a v1 SDK (`@modelcontextprotocol/sdk` 1.x, `mcp` 1.x), whose -newest protocol version is 2025-11-25. None implements `server/discover`. So: +⚠️ **Only the legacy era can be exercised against these servers today.** The +Python servers are on `mcp` 1.x, whose newest protocol version is 2025-11-25. +The TypeScript servers are on the TS SDK v2 packages (#4856) but still serve +only the `initialize` handshake. None implements `server/discover`. So: - `--protocol-era modern` fails, by design, with `Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via @@ -259,7 +260,7 @@ Claude Code, run this way, has no era switch and does not print the protocol version it negotiated. `/pr-flow` asks for the LLM client in both eras as well; where the client gives no way to choose, record the client and its version and say that the era could not be selected, rather than labelling the run with an -era it was not shown to use. Against a v1-SDK server the connection is legacy +era it was not shown to use. Against a server without modern-era support the connection is legacy whatever the client prefers, for the reason given under Protocol eras. ## What to write down diff --git a/.claude/skills/pre-push-gate/SKILL.md b/.claude/skills/pre-push-gate/SKILL.md index c7198ad3a9..ddb54b47ca 100644 --- a/.claude/skills/pre-push-gate/SKILL.md +++ b/.claude/skills/pre-push-gate/SKILL.md @@ -273,6 +273,31 @@ of the server's stderr. Re-run a subset with - **A new server fails `test:scripts`** with "every server directory in the repo has a smoke entry": add it to `SERVERS` in `scripts/smoke-servers.mjs`. +### `interface-diff` + +The last stage diffs the `everything` server's MCP interface (capabilities, +tools, prompts, resources, resource templates) in this checkout's build +against the same server built from the merge-base with `origin/v2/main`, and +prints the report. **A changed interface does not fail it**: the report is +what the PR's sticky comment will show, so read it, and if a change is not +one you meant, that is the finding. It fails only when the diff cannot run, +with `❌ The interface diff could not run` and the reason: + +- **"origin/v2/main is not available"**: `git fetch origin v2/main`. +- **"src/everything/dist/index.js does not exist"**: you ran it on its own + before building: `npm run build -w src/everything`. +- **An `npm ci` or `npm run build` failure**: the _base_ did not install or + build. Offline with a cold npm cache is the usual cause; otherwise name a + base that builds with `npm run interface-diff -- --base `. +- **"Base server probe failed" / "Target probe failed"**: a server did not + answer the probe. A target failure is this checkout's server and is the + real defect; the smoke stage before it usually fails first. + +Re-run it on its own with `npm run interface-diff` (about ten seconds). What +it compares against in CI, and why it is a script rather than an action, is +in [`docs/quality-gate.md`](../../../docs/quality-gate.md) and the header of +`scripts/interface-diff.mjs`. + ## Waiting on the lease A gate that starts with diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index c567f3429d..8f45ccf9b8 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -258,6 +258,24 @@ squash writes one new commit that `v2/main` does not contain, so the two branches stop sharing history and the next milestone merge conflicts with itself. +⚠️ **Do not click "Update branch" on the merge PR.** GitHub always offers it +here, because `main` always holds commits the merge branch lacks (its own +earlier merge commits), so it does not mean the PR is out of date. Clicking it +merges `main` into the merge branch: the PR gains a commit of its own, `main` +receives two merge commits, and the branch head is no longer the commit the +ledger names. If it was clicked, put the branch back on its head from before +the click: the update merge's **first parent**, which is the commit the ledger +verified. Not `origin/v2/main`, which may have moved on to a commit nothing has +verified. Then repeat the `git merge-tree` check above: + +```sh +B="v2/chore/$N-release-$MILESTONE" +git fetch origin +UPDATE=$(git rev-parse "origin/$B") +git log -1 --format='%s' "$UPDATE" # must be "Merge branch 'main' into …" +git push --force-with-lease="$B:$UPDATE" origin "$UPDATE^1:refs/heads/$B" +``` + ⚠️ **Never merge `main` back into `v2/main`** to "sync" them. Everything reaches `main` through `v2/main`, so there is nothing on `main` to bring back except the merge commits themselves. `v2/main` ahead of `main` means a release diff --git a/.claude/skills/security-advisory/SKILL.md b/.claude/skills/security-advisory/SKILL.md index e2b85e39c6..841f6bc476 100644 --- a/.claude/skills/security-advisory/SKILL.md +++ b/.claude/skills/security-advisory/SKILL.md @@ -140,7 +140,8 @@ this order: Servers listed in the README or the Registry are third-party; their reports go to their own maintainers. 2. **Is the code path the server's, or an SDK's?** The TypeScript servers are - built on `@modelcontextprotocol/sdk` + built on the TypeScript SDK's v2 packages (`@modelcontextprotocol/server`, + `@modelcontextprotocol/node` and the frozen `@modelcontextprotocol/server-legacy`) ([typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk)), the Python servers on `mcp` ([python-sdk](https://github.com/modelcontextprotocol/python-sdk)). Transport diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 7ba7883001..1527dbce82 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -72,7 +72,7 @@ per-session setup and teardown the server needs. | Server | Factory | Harness | `connect(…)` takes | | --- | --- | --- | --- | -| `everything` | `createServer()` in `server/index.ts`, returns `{ server, cleanup }` | `__tests__/harness.ts` | `{ capabilities, taskStore, setup, sessionId }`, all optional | +| `everything` | `createServer()` in `server/index.ts`, returns `{ server, cleanup }` | `__tests__/harness.ts` | `{ capabilities, setup, sessionId }`, all optional | | `filesystem` | `createServer(allowedDirectories)` in `server.ts` | `__tests__/helpers.ts` | the allowed directories, then `{ capabilities, listRoots }` | | `memory` | `createServer(memoryFilePath)` in `index.ts` | `__tests__/helpers.ts` | the graph file's path (`makeTempGraph()` makes one) | | `sequentialthinking` | `createServer()` in `index.ts` | `__tests__/helpers.ts` | `{ disableThoughtLogging }`, defaulting to `"true"` | @@ -83,7 +83,7 @@ Each `connect` returns the `client` and a `close()`; call `close()` in ```ts import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/client"; import { connect, contentOf, textOf, type Session } from "./harness.js"; describe("echo, over the protocol", () => { @@ -123,12 +123,12 @@ What `everything`'s harness does that a hand-rolled connection would miss: next test. `connect()` sets a fresh `sessionId` on the server transport (`sessionId: null` keeps it undefined, as stdio does), and `close()` runs the server's `cleanup(sessionId)`, which also stops the timers `createServer()` - starts (the roots sync, the task store). + starts (the roots sync). - **Capabilities.** A tool `everything` registers in `registerConditionalTools` appears only when the client declares the matching capability: pass `capabilities` (`ALL_CAPABILITIES` declares every one), with `setup` to install the client's handlers for the requests the server sends back - (sampling, elicitation, roots) and `taskStore` for the task-augmented ones. + (sampling, elicitation, roots). - **Notifications.** `session.notifications` collects every notification no specific handler consumed; `ofMethod(notifications, method)` filters it. diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml new file mode 100644 index 0000000000..16fe49c17d --- /dev/null +++ b/.github/workflows/everything-mcp-diff.yml @@ -0,0 +1,254 @@ +# Tracks public interface changes to the Everything MCP server. +# The Everything server is a reference implementation demonstrating all MCP features. +# This workflow helps reviewers see how tools, resources, prompts, and capabilities +# evolve over time - useful for SDK compliance validation and catching regressions. +# +# Contributed by @SamMorrowDrums in #3260 and adapted for v2/main in #4860: +# +# - The check is `npm run interface-diff` (scripts/interface-diff.mjs), which +# drives the `mcp-server-diff` CLI, an exact-pinned root devDependency. The +# same script is a `local:gate` stage, and `scripts/lib/workflow-gate.test.mjs` +# requires every npm script a workflow runs to be one, so CI and the gate run +# the same check. No third-party action runs here. +# - The base is chosen per event below rather than by the tool, whose default +# (the merge-base with origin/main) is the wrong base for PRs to v2/main. +# - Untrusted code (the PR's install, build and server) runs only in `diff`, +# which holds a read-only token and no persisted credentials. The sticky +# comment is posted by `comment`, which holds `pull-requests: write` and runs +# none of the PR's code, and only for PRs from this repository: a fork PR's +# token is read-only, so there the report is in the job summary and the +# `mcp-diff-report` artifact. `pull_request_target` is deliberately not used, +# since it would run the fork's build next to a write token. If fork PRs need +# the comment too, the way is a separate `workflow_run` workflow that posts +# the artifact without checking anything out. +# +# A changed interface does not fail the run: the diff is information for the +# reviewer. A diff that cannot run (a base that does not build, a server that +# cannot be probed) does. +# +# See: https://github.com/modelcontextprotocol/inspector/issues/1034 +name: Everything Server MCP Diff + +on: + push: + branches: [main, v2/main] + paths: + - "src/everything/**" + - "package.json" + - "package-lock.json" + - "tsconfig.json" + - "scripts/interface-diff.mjs" + - "scripts/lib/win-shell-args.mjs" + - ".github/workflows/everything-mcp-diff.yml" + # No branch filter: PRs to v2/main and to main both run, and so does a PR + # stacked on another PR's branch. The base is the PR's own base either way. + pull_request: + paths: + - "src/everything/**" + - "package.json" + - "package-lock.json" + - "tsconfig.json" + - "scripts/interface-diff.mjs" + - "scripts/lib/win-shell-args.mjs" + - ".github/workflows/everything-mcp-diff.yml" + # A published Release (a milestone, tagged vX.Y.Z on main; see RELEASING.md) + # surfaces the cumulative interface delta since the previous Release. This + # replaces #3260's `typescript-servers-*` tag trigger: those tags stopped in + # 2024, and no release since has used them. + release: + types: [published] + # Manual trigger: the branch chosen in "Use workflow from" is the head, and + # `compare_ref` any base. #3260 also took a `target_ref`, but a target that + # predates the interface-diff script has no `npm run interface-diff` to run, + # so the head is the dispatched branch instead. + workflow_dispatch: + inputs: + compare_ref: + description: "Base ref to compare against (commit SHA, tag, or branch; defaults to the merge-base with v2/main)" + required: false + default: "" + +permissions: + contents: read + +# Superseded runs on the same PR are cancelled. Every other run gets a group +# of its own: a group holds one running and one pending run, so a shared group +# would drop a push's run when two more queued behind it. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + diff: + name: Diff Everything Server Interface + timeout-minutes: 15 + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + # The base is built from history, so all of it is needed, tags + # included (the release base is the previous Release's tag). + fetch-depth: 0 + # The event's own commit (the merge commit for a PR), not a branch + # ref that a later push could move before this step runs. + ref: ${{ github.sha }} + persist-credentials: false + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + + # A root `npm ci` runs every workspace's `prepare`, which builds it, so + # the head build the diff probes exists after this step. + - name: Install dependencies and build + run: npm ci + + - name: Choose the base to compare against + id: base + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + BEFORE: ${{ github.event.before }} + INPUT_BASE: ${{ inputs.compare_ref }} + TAG: ${{ github.event.release.tag_name }} + PUBLISHED: ${{ github.event.release.published_at }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + case "$EVENT" in + # The checkout is the PR's merge commit, so its base tip is + # exactly what the PR changes. + pull_request) base="$PR_BASE" ;; + # The branch as it was before this push; a new branch has none. + push) + if [ -n "$BEFORE" ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then + base="$BEFORE" + else + base=$(git rev-parse HEAD^) + fi ;; + # The Release published just before this one (so a re-run of an + # older Release still compares against ITS predecessor). Not + # `git describe`: the date-stamped releases tagged commits that + # never reached main, so the nearest ancestor tag is years older + # than the last release. + release) + base=$(gh release list --repo "$REPO" --exclude-drafts --exclude-pre-releases --limit 50 \ + --json tagName,publishedAt \ + --jq "map(select(.tagName != \"$TAG\" and .publishedAt < \"$PUBLISHED\")) | sort_by(.publishedAt) | last | .tagName // empty") + if [ -z "$base" ]; then echo "no earlier Release to compare against" >&2; exit 1; fi ;; + workflow_dispatch) + if [ -n "$INPUT_BASE" ]; then + base="$INPUT_BASE" + else + base=$(git merge-base HEAD origin/v2/main) + fi ;; + *) echo "unexpected event: $EVENT" >&2; exit 1 ;; + esac + echo "Comparing against $base" + echo "ref=$base" >> "$GITHUB_OUTPUT" + + - name: Diff the interface + env: + BASE: ${{ steps.base.outputs.ref }} + run: npm run interface-diff -- --base "$BASE" --report-dir mcp-diff-report + + - name: Write the job summary + if: always() + run: | + { + if [ -f mcp-diff-report/report.md ]; then + # A step summary holds at most 1 MiB. + head -c 900000 mcp-diff-report/report.md + if [ "$(wc -c < mcp-diff-report/report.md)" -gt 900000 ]; then + echo "" + echo "_The report is truncated here; the full report is in the run's \`mcp-diff-report\` artifact._" + fi + else + echo "The interface diff produced no report; see the job log." + fi + echo "" + echo "---" + echo "" + echo "ℹ️ **Interpreting Results**" + echo "" + echo "The Everything server is a reference implementation that exercises all MCP features." + echo "Interface changes here may indicate:" + echo "- New MCP protocol features being demonstrated" + echo "- Updated tool schemas or descriptions" + echo "- New resources, prompts, or capabilities" + echo "" + echo "Review changes to ensure they align with the intended protocol updates." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the report + if: always() + uses: actions/upload-artifact@v7 + with: + name: mcp-diff-report + path: mcp-diff-report/ + if-no-files-found: ignore + + # Post (or update) a sticky PR comment so reviewers can see the diff without + # navigating to the Actions tab. One comment per PR, found again by its + # marker and edited on each push. This job checks nothing out and runs none + # of the PR's code; it only reads the report the `diff` job uploaded. + # Dependabot's PRs are same-repo but get a read-only token, so they are + # skipped like fork PRs and keep the job summary and the artifact. + comment: + name: Post the diff on the PR + needs: diff + if: >- + always() && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]' && + (needs.diff.result == 'success' || needs.diff.result == 'failure') + timeout-minutes: 5 + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Download the report + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: mcp-diff-report + path: mcp-diff-report + + - name: Post / update sticky PR comment + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + marker='' + { + echo "$marker" + if [ -f mcp-diff-report/report.md ]; then + # A comment holds at most 65536 characters. + head -c 60000 mcp-diff-report/report.md + if [ "$(wc -c < mcp-diff-report/report.md)" -gt 60000 ]; then + echo "" + echo "_The report is truncated here; the full report is in the run's \`mcp-diff-report\` artifact._" + fi + else + echo "## \`everything\`: MCP interface diff" + echo "" + echo "❌ The diff job produced no report; see the run's log." + fi + echo "" + echo "Updated by [this run]($RUN_URL) for \`$HEAD_SHA\`." + } > body.md + id=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id" | head -n 1) + if [ -n "$id" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$id" -F body=@body.md > /dev/null + else + gh api "repos/$REPO/issues/$PR/comments" -F body=@body.md > /dev/null + fi diff --git a/AGENTS.md b/AGENTS.md index bba09c93db..7b4abbd327 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ servers/ │ └── time/ Py mcp-server-time PyPI Time and timezone conversion ├── .claude/skills/ On-demand procedures (see the Skills index above) ├── .changeset/ Pending changesets for the TypeScript servers, and the changesets config -├── scripts/ The pre-push gate (gate-lease, smoke-servers, validate-py, coverage-py), its guards (verify-*), +├── scripts/ The pre-push gate (gate-lease, smoke-servers, validate-py, coverage-py, interface-diff), its guards (verify-*), │ the skills tooling, release tooling (npm-publish-guard, prepare-python-release, │ pack-and-verify, release-manifest, release-notes), and the issue-filing sweeps │ (dependency-refresh, dependabot-alerts, sdk-watch) @@ -61,8 +61,8 @@ servers/ │ overview of how the rules, skills, gates and sweeps fit together ├── .github/workflows/ typescript.yml, python.yml (per-package CI), release.yml (publishes on a GitHub Release), │ version-packages.yml (the changesets PR), prepare-python-release.yml (the CalVer PR), -│ claude.yml (@claude mentions), and the scheduled sweeps that file issues: -│ dependency-refresh.yml, dependabot-alerts.yml, sdk-watch.yml +│ claude.yml (@claude mentions), everything-mcp-diff.yml (everything's interface diff on PRs), +│ and the scheduled sweeps that file issues: dependency-refresh.yml, dependabot-alerts.yml, sdk-watch.yml ├── .github/ISSUE_TEMPLATE/ Bug and feature issue forms; config.yml routes security and new servers away ├── .github/pull_request_template.md The "issues, not PRs" banner that turns outside PRs away ├── RELEASING.md How packages are versioned and published, and how to recover a failed publish @@ -129,14 +129,15 @@ workspace's format check, lint, typecheck, build and tests), `coverage` (each TypeScript workspace's per-file coverage gate), `validate:py` (each Python server's locked sync, `ruff check`, `ruff format --check`, pyright, pytest and build), `coverage:py` (each Python server's per-file -coverage gate), `verify:skills:cli`, and `smoke` (every server -booted over each transport it implements). The stage-by-stage reference, and -what CI runs where, is [`docs/quality-gate.md`](./docs/quality-gate.md); +coverage gate), `verify:skills:cli`, `smoke` (every server booted over each +transport it implements), and `interface-diff` (the `everything` server's MCP +interface diffed against the base it branched from). The stage-by-stage +reference, and what CI runs where, is [`docs/quality-gate.md`](./docs/quality-gate.md); diagnosing a red stage is the `pre-push-gate` skill. - **`npm run validate` and the per-package commands above are the inner loop, - not a substitute.** They skip the other language, the pinned skills validator - and the boot smoke. + not a substitute.** They skip the other language, the pinned skills validator, + the boot smoke and the interface diff. - **Format before committing**: `npm run format` at the root (TypeScript), and `uv run --frozen ruff format .` in a Python server you changed. The gate only checks formatting; it never rewrites files. diff --git a/docs/quality-gate.md b/docs/quality-gate.md index 075b72de24..7c197a5b05 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -13,7 +13,7 @@ out; everything else points here. | --- | --- | --- | --- | | Inner loop | `npm run validate -w src/`, `npm run validate:py -- ` | Your machine | While iterating on one server | | Pre-push gate | `npm run local:gate` | Your machine, under a lease | Before every push | -| CI | `.github/workflows/typescript.yml`, `python.yml`; `dco.yml` | GitHub, on every push and pull request; `dco.yml` on pull requests only | Before merge | +| CI | `.github/workflows/typescript.yml`, `python.yml`, `everything-mcp-diff.yml`; `dco.yml` | GitHub, on every push and pull request (`everything-mcp-diff.yml`: when `everything` or what builds it changes); `dco.yml` on pull requests only | Before merge | **`npm run local:gate` runs every check CI runs.** CI splits the same checks into parallel jobs on a fresh install; the gate runs them in sequence on your @@ -49,6 +49,7 @@ in this order, and the first failure stops the run. | 7 | `coverage:py` | Per Python server: `uv sync --locked`, then `uv run --frozen pytest --cov --cov-report=term-missing --cov-report=json`; then **every file** in `coverage.json` must reach **90% on lines and 90% on branches** | `python.yml` → **Coverage \** (one leg each) | | 8 | `verify:skills:cli` | `claude plugin validate` on `.claude/skills`, at the pinned CLI version | `typescript.yml` → **Root guards** | | 9 | `smoke` | Every server boots over each transport it implements and answers one tool call | `typescript.yml` → **Boot smoke** | +| 10 | `interface-diff` | The `everything` server's MCP interface (capabilities, tools, prompts, resources, resource templates) in this checkout's build, diffed against the same server built from the base. It fails only when the diff cannot run; a changed interface is reported, not failed | `everything-mcp-diff.yml` → **Diff Everything Server Interface** (and the PR comment) | Notes on the stages: @@ -91,8 +92,9 @@ Notes on the stages: when a server's environment is missing or behind its lockfile, since `uv sync --locked` then downloads packages (and the pinned interpreter, if `uv` does not have it); `coverage:py` begins with the same sync, which is a - no-op once `validate:py` has run. With warm caches those are the only stages - that can fail offline. + no-op once `validate:py` has run; and `interface-diff` needs it for the + base's `npm ci`. With warm caches those are the only stages that can fail + offline. - **`smoke` launches what a user launches**: the built `dist/index.js` for a TypeScript server, the console script through `uv run --no-sync` for a Python one. stdio for all seven; HTTP+SSE and Streamable HTTP as well for @@ -102,6 +104,16 @@ Notes on the stages: than the default 3001. It runs after `validate` and `validate:py` because it needs the build and each Python server's synced environment; it creates neither. +- **`interface-diff` compares against the merge-base with `origin/v2/main`** + locally (`-- --base ` names another), and against the PR's base, the + commit before the push, or the previous release's tag in CI. It builds the + base from an export of that commit under `node_modules/.cache/` and removes + it afterwards; the head is the build `validate` made. It drives the + `mcp-server-diff` CLI, an exact-pinned root devDependency, which probes with + the 2026-07-28 `server/discover` first and falls back to the `initialize` + handshake, so it covers a server in either era. Its `npm ci` of the base + needs the network when the npm cache is cold, and it needs + `origin/v2/main` fetched. Only `everything` is diffed today (#4860). - `python.yml` also has a **Build \** job per server that re-runs pyright and `uv build` and uploads the built distribution. It checks nothing stage 6 does not. diff --git a/package-lock.json b/package-lock.json index 894e5ac485..20e5533cdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,9 +21,10 @@ "@changesets/changelog-github": "^1.0.1", "@changesets/cli": "^3.0.3", "@eslint/js": "^10.0.1", - "@modelcontextprotocol/sdk": "^1.31.0", + "@modelcontextprotocol/client": "^2.3.1", "eslint": "^10.11.0", "globals": "^17.12.0", + "mcp-server-diff": "3.0.0", "prettier": "3.8.4", "proper-lockfile": "^4.1.2", "semver": "^7.8.4", @@ -31,6 +32,50 @@ "yaml": "^2.9.0" } }, + "node_modules/@actions/core": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", + "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", + "dev": true, + "dependencies": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "dev": true, + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/http-client": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", + "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", + "dev": true, + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "dev": true, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "dev": true + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -544,15 +589,16 @@ } }, "node_modules/@hono/node-server": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", - "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.4.tgz", + "integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" }, "peerDependencies": { - "hono": "^4" + "hono": "^4 || ^5.0.0-0" } }, "node_modules/@humanfs/core": { @@ -715,10 +761,70 @@ "node": ">=20.0.0" } }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.3.1.tgz", + "integrity": "sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.3.1", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.8", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", + "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.1.1.tgz", + "integrity": "sha512-EY92OAXN2xYL7tNS/MVsWJW1v8VHAPwl9B4Ca3pCrjmvqyC+S1C/6UHsMxF2GoFPraS5Yyg56vFz9brVpFojMA==", + "license": "Apache-2.0", + "dependencies": { + "@hono/node-server": "^1.19.9", + "hono": "^4.11.4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.3.0" + } + }, + "node_modules/@modelcontextprotocol/node/node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.32.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "dev": true, "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -755,6 +861,19 @@ } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz", + "integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.3.1", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/server-everything": { "resolved": "src/everything", "link": true @@ -763,6 +882,33 @@ "resolved": "src/filesystem", "link": true }, + "node_modules/@modelcontextprotocol/server-legacy": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.3.1.tgz", + "integrity": "sha512-+cuB+hl7s8DiFXIt13le3cD2wZPOeDYwlycAkRMPljqqNbOLekGRiyslx8WXfqaXSCveS1eDD8/B8rmxz9BZFQ==", + "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.3.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "express-rate-limit": "^8.2.1", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "express": "^4.18.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/server-memory": { "resolved": "src/memory", "link": true @@ -1711,6 +1857,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1727,6 +1874,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -2096,6 +2244,7 @@ "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", @@ -2484,6 +2633,7 @@ "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" @@ -2496,6 +2646,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -2668,6 +2819,7 @@ "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-glob": { @@ -2722,6 +2874,7 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, "funding": [ { "type": "github", @@ -3286,6 +3439,7 @@ "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/istanbul-lib-coverage": { @@ -3338,6 +3492,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -3354,12 +3509,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -3777,6 +3934,36 @@ "node": ">= 0.4" } }, + "node_modules/mcp-server-diff": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mcp-server-diff/-/mcp-server-diff-3.0.0.tgz", + "integrity": "sha512-vtbf+ZSfRFMZQipzIpQDP5l35qMhW/CtemsERMr7DjU5Z+uY6ZtL8gHBAamwZ2sS3VvsWtVqMg1Hk91HbavZrw==", + "dev": true, + "dependencies": { + "@actions/core": "^3.0.1", + "@actions/exec": "^3.0.0", + "@actions/io": "^3.0.2", + "@modelcontextprotocol/sdk": "^1.13.2", + "diff": "^9.0.0", + "undici": "^8.4.1", + "zod": "^4.4.3" + }, + "bin": { + "mcp-server-diff": "dist/cli/index.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/mcp-server-diff/node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -4117,6 +4304,7 @@ "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" @@ -4423,6 +4611,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4631,6 +4820,7 @@ "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" @@ -4643,6 +4833,7 @@ "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" @@ -4947,6 +5138,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5029,6 +5229,15 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -5242,6 +5451,7 @@ "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" @@ -5405,6 +5615,7 @@ "version": "3.25.2", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, "license": "ISC", "peerDependencies": { "zod": "^3.25.28 || ^4" @@ -5415,22 +5626,29 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.31.0", + "@modelcontextprotocol/node": "^2.1.1", + "@modelcontextprotocol/server": "^2.3.1", + "@modelcontextprotocol/server-legacy": "^2.3.1", "cors": "^2.8.5", "express": "^5.2.1", "jszip": "^3.10.1", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "bin": { "mcp-server-everything": "dist/index.js" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", + "@modelcontextprotocol/core": "^2.3.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", "vitest": "^4.1.11" + }, + "engines": { + "node": ">=20" } }, "src/filesystem": { @@ -5438,16 +5656,17 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.31.0", + "@modelcontextprotocol/server": "^2.3.1", "diff": "^8.0.3", "glob": "^13.0.6", "minimatch": "^10.0.1", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "bin": { "mcp-server-filesystem": "dist/index.js" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/diff": "^5.0.9", "@types/minimatch": "^5.1.2", "@types/node": "^22", @@ -5455,6 +5674,9 @@ "shx": "^0.4.0", "typescript": "^5.8.2", "vitest": "^4.1.11" + }, + "engines": { + "node": ">=20" } }, "src/memory": { @@ -5462,18 +5684,22 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.31.0", - "zod": "^4.0.0" + "@modelcontextprotocol/server": "^2.3.1", + "zod": "^4.2.0" }, "bin": { "mcp-server-memory": "dist/index.js" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", "vitest": "^4.1.11" + }, + "engines": { + "node": ">=20" } }, "src/sequentialthinking": { @@ -5481,21 +5707,25 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.31.0", + "@modelcontextprotocol/server": "^2.3.1", "chalk": "^5.3.0", "yargs": "^17.7.2", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "bin": { "mcp-server-sequential-thinking": "dist/index.js" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", "@types/yargs": "^17.0.32", "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", "vitest": "^4.1.11" + }, + "engines": { + "node": ">=20" } } } diff --git a/package.json b/package.json index 1314868a79..2a456a6b55 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,11 @@ "verify:action-pins": "node scripts/verify-action-pins.mjs", "verify:dco": "node scripts/verify-dco.mjs", "smoke": "node scripts/smoke-servers.mjs", + "interface-diff": "node scripts/interface-diff.mjs", "pack:verify": "node scripts/pack-and-verify.mjs", "release:notes": "node scripts/release-notes.mjs", "local:gate": "node scripts/gate-lease.mjs npm run local:gate:stages", - "local:gate:stages": "npm run verify:install-fresh && npm run verify:dco && npm run validate && npm run coverage && npm run validate:py && npm run coverage:py && npm run verify:skills:cli && npm run smoke" + "local:gate:stages": "npm run verify:install-fresh && npm run verify:dco && npm run validate && npm run coverage && npm run validate:py && npm run coverage:py && npm run verify:skills:cli && npm run smoke && npm run interface-diff" }, "dependencies": { "@modelcontextprotocol/server-everything": "*", @@ -58,9 +59,10 @@ "@changesets/changelog-github": "^1.0.1", "@changesets/cli": "^3.0.3", "@eslint/js": "^10.0.1", - "@modelcontextprotocol/sdk": "^1.31.0", + "@modelcontextprotocol/client": "^2.3.1", "eslint": "^10.11.0", "globals": "^17.12.0", + "mcp-server-diff": "3.0.0", "prettier": "3.8.4", "proper-lockfile": "^4.1.2", "semver": "^7.8.4", diff --git a/scripts/interface-diff.mjs b/scripts/interface-diff.mjs new file mode 100644 index 0000000000..59403326c4 --- /dev/null +++ b/scripts/interface-diff.mjs @@ -0,0 +1,374 @@ +#!/usr/bin/env node +// The `everything` server's MCP interface diff (#4860), adapted from +// @SamMorrowDrums's workflow in #3260. +// +// It snapshots the public interface of two builds of `everything` (the server +// capabilities from the handshake, and its tools, prompts, resources and +// resource templates) with `mcp-server-diff`, and reports what changed between +// them: the build in this checkout (the head) against the same server built +// from a base commit. The spec refactor (#4857) leans on it: an SDK migration +// that is meant to be transparent proves it with an empty diff, and each new +// 2026-07-28 feature shows up as a reviewable one. +// +// Why a script, and not the `mcp-server-diff` GitHub Action #3260 used: +// +// - `local:gate` must run every check CI runs, and +// `scripts/lib/workflow-gate.test.mjs` derives that from the npm scripts a +// workflow invokes. A check behind an action is invisible to it; a check +// behind `npm run interface-diff` is enforced by it. CI +// (`everything-mcp-diff.yml`) and the gate run this same file. +// - The tool comes in as an exact-pinned root devDependency, so it is held +// by `package-lock.json`'s integrity hash, the way every other npm +// dependency here is, and no third-party action runs in CI at all. +// - The action picks the base itself, as the merge-base with `origin/main`. +// PRs here target `v2/main`, so that base is wrong; the caller names it. +// +// The base is built from a plain export of the base commit's tree (no git +// worktree is registered) under `node_modules/.cache/`, which git, Prettier and +// ESLint all ignore, and removed afterwards. It sits inside the checkout rather +// than under the system temp directory because `mcp-server-diff` splits a +// server's start command on whitespace, and a relative path from the checkout +// cannot contain any, where a temp directory under a Windows profile can. +// +// The head is NOT built here: inside `local:gate` the `validate` stage has +// already built it, and CI's root `npm ci` builds it. A missing build is an +// error, as it is for the boot smoke. +// +// Outcome: +// - unchanged / changed: exit 0. A changed interface is information for the +// reviewer, not a failure (#3260 ran with `fail_on_diff: false` too). +// - error: exit 1. A server that cannot be probed, or a base that does not +// build, is a broken check, so it fails the gate and the CI job. +// +// Usage: +// npm run interface-diff # base: merge-base with origin/v2/main +// npm run interface-diff -- --base # any commit, tag or branch +// npm run interface-diff -- --report-dir # also write report.md and result.json + +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { winShellArgs } from "./lib/win-shell-args.mjs"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +/** The server this diff covers. Start with `everything` only (#4860). */ +export const SERVER_DIR = "src/everything"; + +/** The entry point a client launches, relative to a checkout's root. */ +const ENTRY = `${SERVER_DIR}/dist/index.js`; + +/** The base when none is named: where this branch left the develop line. */ +export const DEFAULT_BASE_BRANCH = "origin/v2/main"; + +/** + * Parse the command line. + * + * @param {string[]} argv + * @returns {{ base: string | null, reportDir: string | null }} + */ +export function parseArgs(argv) { + const opts = { base: null, reportDir: null }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const [flag, inline] = arg.startsWith("--") ? arg.split(/=(.*)/s) : [arg]; + const value = () => { + const v = inline ?? argv[++i]; + if (v === undefined || v === "") throw new Error(`${flag} needs a value`); + return v; + }; + if (flag === "--base") opts.base = value(); + else if (flag === "--report-dir") opts.reportDir = value(); + else throw new Error(`unknown argument: ${arg}`); + } + return opts; +} + +/** + * Turn `mcp-server-diff`'s JSON output into this diff's verdict. + * + * The CLI exits 1 both when the interfaces differ and when a probe fails, so + * its exit code cannot tell a change from a broken check; the JSON can. + * + * @param {string} stdout what `mcp-server-diff -o json -q` printed + * @param {string} [stderr] what it printed to stderr: a fatal error before the + * report goes there, so it is the reason when no report came out + * @returns {{ status: "unchanged" | "changed" | "error", error?: string, + * diffs: { endpoint: string, diff: string }[], + * baseCounts?: object, headCounts?: object }} + */ +export function classify(stdout, stderr = "") { + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + return { + status: "error", + error: `mcp-server-diff printed no JSON report:\n${[stderr, stdout] + .map((s) => s.trim()) + .filter(Boolean) + .join("\n") + .slice(0, 2000)}`, + diffs: [], + }; + } + const result = parsed?.results?.[0]; + if (!result || typeof result !== "object") + return { + status: "error", + error: "mcp-server-diff reported no comparison", + diffs: [], + }; + const diffs = Array.isArray(result.diffs) ? result.diffs : []; + if (result.error) { + // `error` is the bare exception; the entry in `diffs` says which build + // failed ("Base server probe failed: …" / "Target probe failed: …"). + const context = diffs.find((d) => d.endpoint === "error")?.diff; + return { status: "error", error: String(context ?? result.error), diffs }; + } + // A report without a verdict is not a pass. + if (typeof result.hasDifferences !== "boolean") + return { + status: "error", + error: "mcp-server-diff's report has no hasDifferences verdict", + diffs, + }; + return { + status: result.hasDifferences ? "changed" : "unchanged", + diffs, + baseCounts: result.baseCounts, + headCounts: result.targetCounts, + }; +} + +/** A Markdown code fence longer than any run of backticks in `text`. */ +export function fenceFor(text) { + const longest = Math.max( + 0, + ...[...text.matchAll(/`+/g)].map((m) => m[0].length), + ); + return "`".repeat(Math.max(3, longest + 1)); +} + +/** + * The human-readable report: the verdict first, then the counts, then each + * part of the interface that changed as a diff. + * + * @param {ReturnType} verdict + * @param {{ base: string, head: string }} labels + */ +export function renderReport(verdict, { base, head }) { + const lines = ["## `everything`: MCP interface diff", ""]; + lines.push(`Base: ${base} `, `Head: ${head}`, ""); + if (verdict.status === "unchanged") + lines.push("✅ **No interface changes detected.**"); + else if (verdict.status === "changed") + lines.push( + `⚠️ **Interface changes detected** in ${verdict.diffs.length} part(s) of the interface. Review them below to confirm they are intended.`, + ); + else lines.push("❌ **The interface diff could not run.**"); + lines.push(""); + + if (verdict.status === "error") { + const fence = fenceFor(verdict.error ?? ""); + lines.push(fence, verdict.error ?? "", fence, ""); + return lines.join("\n"); + } + + const row = (name, c = {}) => + `| ${name} | ${c.tools ?? "?"} | ${c.prompts ?? "?"} | ${c.resources ?? "?"} | ${c.resourceTemplates ?? "?"} |`; + lines.push( + "| | Tools | Prompts | Resources | Resource templates |", + "| --- | --- | --- | --- | --- |", + row("Base", verdict.baseCounts), + row("Head", verdict.headCounts), + "", + ); + + for (const { endpoint, diff } of verdict.diffs) { + const fence = fenceFor(diff); + lines.push(`### ${endpoint}`, "", `${fence}diff`, diff, fence, ""); + } + return lines.join("\n"); +} + +/** The last lines of a stream, for an error message; empty stays empty. */ +const tail = (text) => { + const lines = (text ?? "").trim(); + return lines ? `${lines.split("\n").slice(-25).join("\n")}\n` : ""; +}; + +/** + * Run a command to completion; throw with its output when it fails. Only + * `npm` needs a shell, and only on Windows (it is a `.cmd` shim there); its + * arguments are then quoted for `cmd.exe`. `git` is spawned without one. + */ +function run(command, args, cwd, env) { + const shell = process.platform === "win32" && command === "npm"; + const res = spawnSync(command, shell ? winShellArgs(args) : args, { + cwd, + env: env ?? process.env, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + shell, + }); + if (res.error) throw res.error; + if (res.status !== 0) + throw new Error( + `\`${command} ${args.join(" ")}\` exited ${res.status}:\n${tail(res.stdout)}${tail(res.stderr)}`, + ); + return res.stdout; +} + +const git = (...args) => run("git", args, repoRoot).trim(); + +/** The commit to compare against, as a full SHA. */ +function resolveBase(base) { + if (base) return git("rev-parse", "--verify", `${base}^{commit}`); + try { + git("rev-parse", "--verify", DEFAULT_BASE_BRANCH); + } catch { + throw new Error( + `${DEFAULT_BASE_BRANCH} is not available to compare against. Run \`git fetch origin v2/main\`, or name a base with --base .`, + ); + } + return git("merge-base", "HEAD", DEFAULT_BASE_BRANCH); +} + +/** + * Build `everything` as it was at `sha` into `dir`: export the commit's tree + * through a throwaway index (no worktree registered, the real index + * untouched), install that workspace's locked dependencies, build it. + */ +function buildBase(sha, dir) { + const index = `${dir}.index`; + const env = { ...process.env, GIT_INDEX_FILE: index }; + try { + run("git", ["read-tree", sha], repoRoot, env); + run( + "git", + ["checkout-index", "--all", `--prefix=${dir}${path.sep}`], + repoRoot, + env, + ); + } finally { + rmSync(index, { force: true }); + } + if (!existsSync(path.join(dir, SERVER_DIR, "package.json"))) + throw new Error(`${SERVER_DIR} does not exist at the base ${sha}`); + run( + "npm", + [ + "ci", + "--workspace", + SERVER_DIR, + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefer-offline", + ], + dir, + ); + run("npm", ["run", "build", "--workspace", SERVER_DIR], dir); +} + +/** Where the pinned `mcp-server-diff` CLI lives. */ +function cliPath() { + const require = createRequire(import.meta.url); + const manifest = require.resolve("mcp-server-diff/package.json"); + const { bin } = require("mcp-server-diff/package.json"); + const rel = typeof bin === "string" ? bin : bin["mcp-server-diff"]; + return path.join(path.dirname(manifest), rel); +} + +/** A short, readable label for a commit. */ +function describe(sha) { + const subject = git("log", "-1", "--format=%s", sha); + return `\`${sha.slice(0, 12)}\` (${subject.replaceAll("`", "'")})`; +} + +export async function main(argv = process.argv.slice(2)) { + let opts; + try { + opts = parseArgs(argv); + } catch (err) { + console.error(`interface-diff: ${err.message}`); + return 2; + } + + let verdict; + let labels = { base: "(unresolved)", head: "(unresolved)" }; + const cacheRoot = path.join(repoRoot, "node_modules", ".cache"); + let work; + try { + if (!existsSync(path.join(repoRoot, ENTRY))) + throw new Error( + `${ENTRY} does not exist — build first (npm run build -w ${SERVER_DIR}).`, + ); + const sha = resolveBase(opts.base); + const headSha = git("rev-parse", "HEAD"); + labels = { + base: describe(sha), + head: `the build in this checkout, at ${describe(headSha)}`, + }; + console.error(`interface-diff: building the base ${sha.slice(0, 12)} …`); + + mkdirSync(cacheRoot, { recursive: true }); + work = mkdtempSync(path.join(cacheRoot, "servers-interface-diff-")); + buildBase(sha, work); + + // Both start commands are relative to the checkout, so they hold no + // whitespace for mcp-server-diff to split on (see the header). + const baseEntry = path + .relative(repoRoot, path.join(work, ENTRY)) + .split(path.sep) + .join("/"); + console.error("interface-diff: probing the base and the head …"); + const res = spawnSync( + process.execPath, + [ + cliPath(), + "--base", + `node ${baseEntry} stdio`, + "--target", + `node ${ENTRY} stdio`, + "--output", + "json", + "--quiet", + ], + { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, + ); + if (res.error) throw res.error; + verdict = classify(res.stdout, res.stderr); + } catch (err) { + verdict = { status: "error", error: err.message, diffs: [] }; + } finally { + if (work) rmSync(work, { recursive: true, force: true }); + } + + const report = renderReport(verdict, labels); + console.log(report); + if (opts.reportDir) { + mkdirSync(opts.reportDir, { recursive: true }); + writeFileSync(path.join(opts.reportDir, "report.md"), `${report}\n`); + writeFileSync( + path.join(opts.reportDir, "result.json"), + `${JSON.stringify({ status: verdict.status, diffCount: verdict.diffs.length }, null, 2)}\n`, + ); + } + return verdict.status === "error" ? 1 : 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + process.exitCode = await main(); diff --git a/scripts/interface-diff.test.mjs b/scripts/interface-diff.test.mjs new file mode 100644 index 0000000000..90d6fc25ab --- /dev/null +++ b/scripts/interface-diff.test.mjs @@ -0,0 +1,138 @@ +// Tests for the `everything` interface diff (#4860). The pure halves are +// exercised here: the argument parser, the verdict drawn from +// `mcp-server-diff`'s JSON (the one thing that decides whether the gate and +// the CI job fail), and the report. Building a base and probing two servers is +// what `npm run interface-diff` itself does as a gate stage, so it is not +// repeated here. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + classify, + fenceFor, + main, + parseArgs, + renderReport, +} from "./interface-diff.mjs"; + +const counts = { tools: 13, prompts: 4, resources: 7, resourceTemplates: 2 }; +const cliJson = (result) => + JSON.stringify({ timestamp: "t", results: [result], summary: {} }); + +test("parseArgs reads both flags, in either spelling", () => { + assert.deepEqual(parseArgs([]), { base: null, reportDir: null }); + assert.deepEqual(parseArgs(["--base", "abc", "--report-dir", "out"]), { + base: "abc", + reportDir: "out", + }); + assert.deepEqual(parseArgs(["--base=v1.0.0", "--report-dir=a=b"]), { + base: "v1.0.0", + reportDir: "a=b", + }); +}); + +test("parseArgs refuses an unknown flag or a missing value", () => { + assert.throws(() => parseArgs(["--fail-on-diff"]), /unknown argument/); + assert.throws(() => parseArgs(["--base"]), /--base needs a value/); + assert.throws(() => parseArgs(["--base="]), /--base needs a value/); +}); + +test("main exits 2 on a bad command line, before building anything", async (t) => { + // The usage error it prints is expected; keep it out of the test output. + const error = t.mock.method(console, "error", () => {}); + assert.equal(await main(["--nope"]), 2); + assert.match(String(error.mock.calls[0]?.arguments[0]), /unknown argument/); +}); + +test("an identical interface is unchanged", () => { + const v = classify( + cliJson({ + hasDifferences: false, + baseCounts: counts, + targetCounts: counts, + diffs: [], + }), + ); + assert.equal(v.status, "unchanged"); + assert.deepEqual(v.headCounts, counts); +}); + +test("a difference is a change, not an error", () => { + const diffs = [{ endpoint: "tools", diff: "- a\n+ b" }]; + const v = classify( + cliJson({ + hasDifferences: true, + baseCounts: counts, + targetCounts: counts, + diffs, + }), + ); + assert.equal(v.status, "changed"); + assert.deepEqual(v.diffs, diffs); +}); + +test("a probe failure is an error, though the CLI reports it as a difference", () => { + // mcp-server-diff sets hasDifferences on a failed probe too, and exits 1 + // for both; only the `error` field tells them apart. + const v = classify( + cliJson({ + hasDifferences: true, + error: "boom", + diffs: [{ endpoint: "error", diff: "Target probe failed: boom" }], + }), + ); + assert.equal(v.status, "error"); + // The context (which build failed) comes from the diffs entry. + assert.equal(v.error, "Target probe failed: boom"); +}); + +test("output that is not the JSON report is an error, never a pass", () => { + assert.equal(classify("").status, "error"); + assert.equal(classify("Fatal error: x").status, "error"); + assert.equal(classify("{}").status, "error"); + assert.equal(classify(JSON.stringify({ results: [] })).status, "error"); + assert.equal(classify(JSON.stringify({ results: [{}] })).status, "error"); +}); + +test("a CLI crash before the report carries its stderr as the reason", () => { + const v = classify("", "Fatal error: Error: spawn node ENOENT"); + assert.equal(v.status, "error"); + assert.match(v.error, /no JSON report[\s\S]*spawn node ENOENT/); +}); + +test("fenceFor outruns any backticks in the content", () => { + assert.equal(fenceFor("plain"), "```"); + assert.equal(fenceFor("a ``` b"), "````"); + assert.equal(fenceFor("`````"), "``````"); +}); + +test("the report leads with the verdict and shows each changed part", () => { + const labels = { base: "`base`", head: "`head`" }; + assert.match( + renderReport( + { + status: "unchanged", + diffs: [], + baseCounts: counts, + headCounts: counts, + }, + labels, + ), + /No interface changes detected[\s\S]*\| Head \| 13 \| 4 \| 7 \| 2 \|/, + ); + const changed = renderReport( + { + status: "changed", + diffs: [{ endpoint: "tools", diff: "- x ``` y" }], + baseCounts: counts, + headCounts: counts, + }, + labels, + ); + assert.match(changed, /Interface changes detected/); + assert.match(changed, /### tools\n\n````diff\n- x ``` y\n````/); + assert.match( + renderReport({ status: "error", error: "boom", diffs: [] }, labels), + /could not run[\s\S]*boom/, + ); +}); diff --git a/scripts/lib/workflow-gate.test.mjs b/scripts/lib/workflow-gate.test.mjs index 6dee0ca4fd..c08ae56186 100644 --- a/scripts/lib/workflow-gate.test.mjs +++ b/scripts/lib/workflow-gate.test.mjs @@ -392,6 +392,7 @@ describe("the gate's name", () => { "coverage:py", "verify:skills:cli", "smoke", + "interface-diff", ]) { it(`runs \`${stage}\``, () => { assert.ok( diff --git a/scripts/smoke-servers.mjs b/scripts/smoke-servers.mjs index ed21b3f0ff..f0e98f67d5 100755 --- a/scripts/smoke-servers.mjs +++ b/scripts/smoke-servers.mjs @@ -62,10 +62,12 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { + Client, + SSEClientTransport, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -310,7 +312,7 @@ async function exercise(client, server, ctx) { throw new Error( `tools/list does not include \`${call.name}\` (got: ${tools.map((t) => t.name).join(", ")})`, ); - const result = await client.callTool(call, undefined, options); + const result = await client.callTool(call, options); const text = textOf(result); if (result.isError) throw new Error(`\`${call.name}\` returned an error result: ${text}`); diff --git a/src/everything/README.md b/src/everything/README.md index f061e946a2..8fa2d2ebdd 100644 --- a/src/everything/README.md +++ b/src/everything/README.md @@ -13,6 +13,12 @@ This MCP server attempts to exercise all the features of the MCP protocol. It is A complete list of the registered MCP primitives and other protocol features demonstrated can be found in the [Server Features](docs/features.md) document. +The task tools (`simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async`) are not available for now: they were built on the TypeScript SDK's experimental tasks layer, which SDK v2 removed. They return on the MCP tasks extension in [#4852](https://github.com/modelcontextprotocol/servers/issues/4852). + +## Requirements + +Node.js 20 or later, when the server runs through `npx` or from source. + ## Usage with Claude Desktop (uses [stdio Transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio)) Add to your `claude_desktop_config.json`: diff --git a/src/everything/__tests__/harness.ts b/src/everything/__tests__/harness.ts index b87cbd39fe..326058244b 100644 --- a/src/everything/__tests__/harness.ts +++ b/src/everything/__tests__/harness.ts @@ -17,22 +17,18 @@ * runs the server's `cleanup(sessionId)` the way the HTTP transports do. */ import { randomUUID } from "node:crypto"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import type { TaskStore } from "@modelcontextprotocol/sdk/experimental/tasks"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { ClientCapabilities, ContentBlock, ServerNotification, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/client"; import { createServer } from "../server/index.js"; /** Options for one in-process session. */ export type ConnectOptions = { /** The capabilities the client declares in `initialize`. */ capabilities?: ClientCapabilities; - /** A client-side task store, for servers that send task-augmented requests. */ - taskStore?: TaskStore; /** Install request handlers on the client before it connects. */ setup?: (client: Client) => void; /** The server transport's session id; `null` leaves it undefined. */ @@ -62,10 +58,7 @@ export async function connect(options: ConnectOptions = {}): Promise { const client = new Client( { name: "everything-test-client", version: "0.0.0" }, - { - capabilities: options.capabilities ?? {}, - ...(options.taskStore ? { taskStore: options.taskStore } : {}), - }, + { capabilities: options.capabilities ?? {} }, ); const notifications: ServerNotification[] = []; client.fallbackNotificationHandler = async (notification) => { diff --git a/src/everything/__tests__/in-memory-event-store.test.ts b/src/everything/__tests__/in-memory-event-store.test.ts index 6d59c296a7..1f65ac7be1 100644 --- a/src/everything/__tests__/in-memory-event-store.test.ts +++ b/src/everything/__tests__/in-memory-event-store.test.ts @@ -5,7 +5,7 @@ // unknown id before replaying). import { describe, it, expect } from "vitest"; -import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/server"; import { InMemoryEventStore } from "../transports/in-memory-event-store.js"; const message = (method: string): JSONRPCMessage => ({ diff --git a/src/everything/__tests__/registrations.test.ts b/src/everything/__tests__/registrations.test.ts index de73827f3a..f828ce4ffa 100644 --- a/src/everything/__tests__/registrations.test.ts +++ b/src/everything/__tests__/registrations.test.ts @@ -3,7 +3,7 @@ // imports they replace ran inside the 5 s test timeout, and a cold start // (transforming the whole tool tree) could take longer than that. import { describe, it, expect, vi } from "vitest"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerConditionalTools, registerTools } from "../tools/index.js"; import { registerPrompts } from "../prompts/index.js"; import { readInstructions, registerResources } from "../resources/index.js"; @@ -52,7 +52,7 @@ describe("Registration Index Files", () => { }); it("should register conditional tools based on capabilities", () => { - // Server with all capabilities including experimental tasks API + // Server with all capabilities const mockServerWithCapabilities = { registerTool: vi.fn(), server: { @@ -62,18 +62,13 @@ describe("Registration Index Files", () => { sampling: {}, })), }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, } as unknown as McpServer; registerConditionalTools(mockServerWithCapabilities); // Should register 4 conditional tools via registerTool when all capabilities - // are present. Task-based tools register via registerToolTask (counted separately), - // so they are not included in this registerTool count. + // are present. The task tools went with SDK v2's experimental tasks layer; + // Part 5 (#4852) brings them back. expect(mockServerWithCapabilities.registerTool).toHaveBeenCalledTimes(4); const registeredTools = vi @@ -83,11 +78,6 @@ describe("Registration Index Files", () => { expect(registeredTools).toContain("trigger-elicitation-request"); expect(registeredTools).toContain("trigger-url-elicitation"); expect(registeredTools).toContain("trigger-sampling-request"); - - // Task-based tools are registered via experimental.tasks.registerToolTask - expect( - mockServerWithCapabilities.experimental.tasks.registerToolTask, - ).toHaveBeenCalled(); }); it("should not register conditional tools before capabilities are known", () => { @@ -98,11 +88,6 @@ describe("Registration Index Files", () => { server: { getClientCapabilities: vi.fn(() => undefined), }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, } as unknown as McpServer; // partial mock: McpServer's private members rule out a structural literal registerConditionalTools(mockServerBeforeInit); @@ -116,11 +101,6 @@ describe("Registration Index Files", () => { server: { getClientCapabilities: vi.fn(() => ({})), }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, } as unknown as McpServer; registerConditionalTools(mockServerNoCapabilities); @@ -140,11 +120,6 @@ describe("Registration Index Files", () => { tasks: { requests: { elicitation: { create: {} } } }, })), }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, } as unknown as McpServer; // partial mock: McpServer's private members rule out a structural literal registerConditionalTools(mockServerUrlOnly); @@ -202,22 +177,13 @@ describe("Registration Index Files", () => { server: { getClientCapabilities: vi.fn(() => capabilities), }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, } as unknown as McpServer; // partial mock: McpServer's private members rule out a structural literal registerConditionalTools(mockServer); - const viaRegisterTool = vi + return vi .mocked(mockServer.registerTool) .mock.calls.map((call) => call[0]); - const viaRegisterToolTask = vi - .mocked(mockServer.experimental.tasks.registerToolTask) - .mock.calls.map((call) => call[0]); - return [...viaRegisterTool, ...viaRegisterToolTask]; }; // A tool is capability-gated if declaring the capabilities makes it appear. diff --git a/src/everything/__tests__/resources.test.ts b/src/everything/__tests__/resources.test.ts index 6fdabcba4b..4e01f2e0bb 100644 --- a/src/everything/__tests__/resources.test.ts +++ b/src/everything/__tests__/resources.test.ts @@ -84,7 +84,7 @@ describe("dynamic resource templates", () => { it("rejects a URI that matches no resource or template", async () => { await expect( session.client.readResource({ uri: "demo://resource/nowhere" }), - ).rejects.toThrow("Resource demo://resource/nowhere not found"); + ).rejects.toThrow("Resource not found: demo://resource/nowhere"); }); }); diff --git a/src/everything/__tests__/roots.test.ts b/src/everything/__tests__/roots.test.ts index 9c98aba1b2..88cada7c13 100644 --- a/src/everything/__tests__/roots.test.ts +++ b/src/everything/__tests__/roots.test.ts @@ -6,10 +6,7 @@ * all driven by a test client that answers the server's `roots/list`. */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - ListRootsRequestSchema, - type Root, -} from "@modelcontextprotocol/sdk/types.js"; +import type { Root } from "@modelcontextprotocol/server"; import { connect, contentOf, @@ -32,7 +29,7 @@ async function connectWithRoots(answer: () => Root[]) { const s = await connect({ capabilities: { roots: { listChanged: true } }, setup: (client) => - client.setRequestHandler(ListRootsRequestSchema, async () => { + client.setRequestHandler("roots/list", async () => { requests++; return { roots: answer() }; }), @@ -110,7 +107,7 @@ describe("roots sync after initialize", () => { session = s; await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledWith( - `Failed to request roots from client ${s.sessionId}: MCP error -32603: no roots for you`, + `Failed to request roots from client ${s.sessionId}: no roots for you`, ), ); expect(logData(s)).toEqual([]); diff --git a/src/everything/__tests__/sampling-elicitation.test.ts b/src/everything/__tests__/sampling-elicitation.test.ts index bc5e36f8b7..cbb46e370c 100644 --- a/src/everything/__tests__/sampling-elicitation.test.ts +++ b/src/everything/__tests__/sampling-elicitation.test.ts @@ -7,16 +7,13 @@ * server asked and what the tool then returned. */ import { afterEach, describe, expect, it } from "vitest"; -import { - CreateMessageRequestSchema, - ElicitRequestSchema, - ErrorCode, - McpError, - type ClientCapabilities, - type CreateMessageRequest, - type ElicitRequest, - type ElicitResult, -} from "@modelcontextprotocol/sdk/types.js"; +import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server"; +import type { + ClientCapabilities, + CreateMessageRequest, + ElicitRequest, + ElicitResult, +} from "@modelcontextprotocol/server"; import { __resetIssuedErrorPathElicitations } from "../tools/trigger-url-elicitation.js"; import { connect, @@ -40,7 +37,7 @@ describe("trigger-sampling-request", () => { session = await connect({ capabilities: { sampling: {} }, setup: (client) => - client.setRequestHandler(CreateMessageRequestSchema, async (req) => { + client.setRequestHandler("sampling/createMessage", async (req) => { asked.push(req.params); return { role: "assistant", @@ -91,7 +88,7 @@ describe("trigger-sampling-request", () => { session = await connect({ capabilities: { sampling: {} }, setup: (client) => - client.setRequestHandler(CreateMessageRequestSchema, async (req) => { + client.setRequestHandler("sampling/createMessage", async (req) => { maxTokens = req.params.maxTokens; return { role: "assistant", @@ -111,8 +108,11 @@ describe("trigger-sampling-request", () => { session = await connect({ capabilities: { sampling: {} }, setup: (client) => - client.setRequestHandler(CreateMessageRequestSchema, async () => { - throw new McpError(ErrorCode.InvalidRequest, "user rejected"); + client.setRequestHandler("sampling/createMessage", async () => { + throw new ProtocolError( + ProtocolErrorCode.InvalidRequest, + "user rejected", + ); }), }); const result = await session.client.callTool({ @@ -125,7 +125,7 @@ describe("trigger-sampling-request", () => { content: [ { type: "text", - text: "MCP error -32600: MCP error -32600: user rejected", + text: "user rejected", }, ], }); @@ -143,7 +143,7 @@ async function connectElicitation( ...options, capabilities, setup: (client) => - client.setRequestHandler(ElicitRequestSchema, async (req) => { + client.setRequestHandler("elicitation/create", async (req) => { asked.push(req.params); return answer(req); }), @@ -476,12 +476,13 @@ describe("trigger-url-elicitation", () => { const { s, asked } = await connectElicitation(() => ({ action: "accept" })); session = s; const error = await errorPathRejection(s); - expect(error).toBeInstanceOf(McpError); - const mcpError = error as McpError; + expect(error).toBeInstanceOf(ProtocolError); + const mcpError = error as ProtocolError; expect(mcpError.code).toBe(-32042); - // The SDK prefixes the code twice on the client side. + // SDK v2 passes the server's message through verbatim (SDK 1.x prefixed + // "MCP error -32042: " twice on the client side). expect(mcpError.message).toBe( - "MCP error -32042: MCP error -32042: This request requires browser-based authorization.", + "This request requires browser-based authorization.", ); expect(mcpError.data).toEqual({ elicitations: [ @@ -510,7 +511,7 @@ describe("trigger-url-elicitation", () => { // The one-shot marker was consumed, so a third call errors again. expect(await errorPathRejection(s, { elicitationId: "x" })).toBeInstanceOf( - McpError, + ProtocolError, ); }); @@ -521,7 +522,7 @@ describe("trigger-url-elicitation", () => { // A different requested id is a different call: it errors. expect( await errorPathRejection(s, { elicitationId: "other" }), - ).toBeInstanceOf(McpError); + ).toBeInstanceOf(ProtocolError); // The original call (no requested id) is recognized as the retry. const retry = await callUrlTool(s, { errorPath: true }); expect(retry.isError).toBeUndefined(); diff --git a/src/everything/__tests__/server-version.test.ts b/src/everything/__tests__/server-version.test.ts index 579b42897d..4335b8c524 100644 --- a/src/everything/__tests__/server-version.test.ts +++ b/src/everything/__tests__/server-version.test.ts @@ -5,8 +5,7 @@ import { describe, it, expect, vi } from "vitest"; import { createRequire } from "node:module"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { createServer } from "../server/index.js"; import { resolvePackageVersion, SERVER_VERSION } from "../version.js"; diff --git a/src/everything/__tests__/server.test.ts b/src/everything/__tests__/server.test.ts index 2135944a4f..a864088ae6 100644 --- a/src/everything/__tests__/server.test.ts +++ b/src/everything/__tests__/server.test.ts @@ -6,9 +6,8 @@ * for a server with no initialized client. */ import { describe, expect, it } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server"; +import { Client } from "@modelcontextprotocol/client"; import { createServer } from "../server/index.js"; import { registerSessionResource } from "../resources/session.js"; import { syncRoots } from "../server/roots.js"; diff --git a/src/everything/__tests__/sse.test.ts b/src/everything/__tests__/sse.test.ts index 62c7c4f12f..886cd30c66 100644 --- a/src/everything/__tests__/sse.test.ts +++ b/src/everything/__tests__/sse.test.ts @@ -8,8 +8,7 @@ import type { Server } from "node:http"; import { once } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Client, SSEClientTransport } from "@modelcontextprotocol/client"; import { createApp } from "../transports/sse.js"; let http: Server; diff --git a/src/everything/__tests__/stdio.test.ts b/src/everything/__tests__/stdio.test.ts index a5ff839568..b1b9847d2c 100644 --- a/src/everything/__tests__/stdio.test.ts +++ b/src/everything/__tests__/stdio.test.ts @@ -7,8 +7,8 @@ */ import { PassThrough } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { Client } from "@modelcontextprotocol/client"; import { main, startStdioServer, diff --git a/src/everything/__tests__/streamable-http.test.ts b/src/everything/__tests__/streamable-http.test.ts index d374bfac3a..fe735c97b9 100644 --- a/src/everything/__tests__/streamable-http.test.ts +++ b/src/everything/__tests__/streamable-http.test.ts @@ -11,9 +11,11 @@ import type { Server } from "node:http"; import { once } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; import { createApp, type StreamableHttpApp, @@ -328,7 +330,7 @@ describe("Streamable HTTP transport", () => { it("maps a POST that throws before answering to a JSON-RPC internal error", async () => { vi.spyOn( - StreamableHTTPServerTransport.prototype, + NodeStreamableHTTPServerTransport.prototype, "handleRequest", ).mockRejectedValueOnce(new Error("boom")); const response = await post({ jsonrpc: "2.0", id: 1, method: "ping" }); @@ -346,7 +348,7 @@ describe("Streamable HTTP transport", () => { it("leaves a POST alone when it throws after answering", async () => { const { sessionId } = await rawInitialize(); vi.spyOn( - StreamableHTTPServerTransport.prototype, + NodeStreamableHTTPServerTransport.prototype, "handleRequest", ).mockImplementationOnce(async (_req, res) => { res.writeHead(200).end("partial"); @@ -363,7 +365,7 @@ describe("Streamable HTTP transport", () => { it("maps a DELETE that throws to a JSON-RPC internal error, unless it already answered", async () => { const { sessionId } = await rawInitialize(); const handle = vi.spyOn( - StreamableHTTPServerTransport.prototype, + NodeStreamableHTTPServerTransport.prototype, "handleRequest", ); handle.mockRejectedValueOnce(new Error("boom")); @@ -444,7 +446,7 @@ describe("shutdown", () => { const { client, sessionId } = await connectHttp(); const failure = new Error("close failed"); vi.spyOn( - StreamableHTTPServerTransport.prototype, + NodeStreamableHTTPServerTransport.prototype, "close", ).mockRejectedValueOnce(failure); await app.shutdown(); diff --git a/src/everything/__tests__/surface.test.ts b/src/everything/__tests__/surface.test.ts index 0f589341d8..4086b323f2 100644 --- a/src/everything/__tests__/surface.test.ts +++ b/src/everything/__tests__/surface.test.ts @@ -31,7 +31,6 @@ const BASE_TOOLS = [ "get-sum", "get-tiny-image", "gzip-file-as-resource", - "simulate-research-query", "toggle-simulated-logging", "toggle-subscriber-updates", "trigger-long-running-operation", @@ -52,11 +51,6 @@ describe("initialize", () => { resources: { subscribe: true, listChanged: true }, logging: {}, completions: {}, - tasks: { - list: {}, - cancel: {}, - requests: { tools: { call: {} } }, - }, }); expect(client.getInstructions()).toMatch( /^# Everything Server – Server Instructions/, @@ -75,9 +69,7 @@ describe("initialize", () => { const gated = [ "get-roots-list", "trigger-elicitation-request", - "trigger-elicitation-request-async", "trigger-sampling-request", - "trigger-sampling-request-async", "trigger-url-elicitation", ]; @@ -116,9 +108,7 @@ describe("tools/list", () => { ...BASE_TOOLS, "get-roots-list", "trigger-elicitation-request", - "trigger-elicitation-request-async", "trigger-sampling-request", - "trigger-sampling-request-async", "trigger-url-elicitation", ].sort(), ); @@ -135,6 +125,20 @@ describe("tools/list", () => { { elicitation: { form: {}, url: {} } }, ["trigger-elicitation-request", "trigger-url-elicitation"], ], + ])( + "gates tools on the client's declared capabilities: %j", + async (capabilities, extra) => { + session = await connect({ capabilities }); + const { tools } = await session.client.listTools(); + expect(tools.map((t) => t.name).sort()).toEqual( + [...BASE_TOOLS, ...extra].sort(), + ); + }, + ); + + // The async task tools went with SDK v2's experimental tasks layer + // (SEP-2663). Part 5 (#4852) brings them back on the tasks extension. + it.skip.each([ [ { sampling: {}, @@ -166,7 +170,7 @@ describe("tools/list", () => { [{ tasks: { requests: { sampling: { createMessage: {} } } } }, []], [{ tasks: { requests: { elicitation: { create: {} } } } }, []], ])( - "gates tools on the client's declared capabilities: %j", + "gates the async task tools on the client's declared capabilities: %j", async (capabilities, extra) => { session = await connect({ capabilities }); const { tools } = await session.client.listTools(); @@ -177,7 +181,9 @@ describe("tools/list", () => { ); it("sends tools/list_changed after initialize, when the conditional tools are registered", async () => { - session = await connect(); + // A client with no capabilities gets no conditional tools, so nothing is + // registered after initialize and no list_changed is sent. + session = await connect({ capabilities: ALL_CAPABILITIES }); await session.client.listTools(); expect( ofMethod(session.notifications, "notifications/tools/list_changed") @@ -210,9 +216,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Echo Tool", }, @@ -223,9 +227,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Annotated Message Tool", }, @@ -236,9 +238,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Print Environment Tool", }, @@ -249,9 +249,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Resource Links Tool", }, @@ -262,9 +260,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Resource Reference Tool", }, @@ -275,9 +271,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Roots List Tool", }, @@ -288,9 +282,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": true, "title": "Get Structured Content Tool", }, @@ -301,9 +293,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Sum Tool", }, @@ -314,9 +304,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Tiny Image Tool", }, @@ -327,25 +315,10 @@ describe("tools/list", () => { "openWorldHint": true, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "GZip File as Resource Tool", }, - "simulate-research-query": { - "annotations": { - "destructiveHint": false, - "idempotentHint": false, - "openWorldHint": false, - "readOnlyHint": false, - }, - "execution": { - "taskSupport": "required", - }, - "hasOutputSchema": false, - "title": "Simulate Research Query", - }, "toggle-simulated-logging": { "annotations": { "destructiveHint": false, @@ -353,9 +326,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Toggle Simulated Logging", }, @@ -366,9 +337,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Toggle Subscriber Updates", }, @@ -379,25 +348,10 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Trigger Elicitation Request Tool", }, - "trigger-elicitation-request-async": { - "annotations": { - "destructiveHint": false, - "idempotentHint": false, - "openWorldHint": false, - "readOnlyHint": false, - }, - "execution": { - "taskSupport": "forbidden", - }, - "hasOutputSchema": false, - "title": "Trigger Async Elicitation Request Tool", - }, "trigger-long-running-operation": { "annotations": { "destructiveHint": false, @@ -405,9 +359,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Trigger Long Running Operation Tool", }, @@ -418,25 +370,10 @@ describe("tools/list", () => { "openWorldHint": true, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Trigger Sampling Request Tool", }, - "trigger-sampling-request-async": { - "annotations": { - "destructiveHint": false, - "idempotentHint": false, - "openWorldHint": true, - "readOnlyHint": false, - }, - "execution": { - "taskSupport": "forbidden", - }, - "hasOutputSchema": false, - "title": "Trigger Async Sampling Request Tool", - }, "trigger-url-elicitation": { "annotations": { "destructiveHint": false, @@ -444,9 +381,7 @@ describe("tools/list", () => { "openWorldHint": true, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Trigger URL Elicitation Tool", }, @@ -465,7 +400,7 @@ describe("tools/list", () => { expect(schemas).toMatchInlineSnapshot(` { "echo": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "message": { "description": "Message to echo", @@ -478,7 +413,7 @@ describe("tools/list", () => { "type": "object", }, "get-annotated-message": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "includeImage": { "default": false, @@ -501,12 +436,12 @@ describe("tools/list", () => { "type": "object", }, "get-env": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "get-resource-links": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "count": { "default": 3, @@ -519,7 +454,7 @@ describe("tools/list", () => { "type": "object", }, "get-resource-reference": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "resourceId": { "default": 1, @@ -538,12 +473,12 @@ describe("tools/list", () => { "type": "object", }, "get-roots-list": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "get-structured-content": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "location": { "description": "Choose city", @@ -561,7 +496,7 @@ describe("tools/list", () => { "type": "object", }, "get-sum": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "a": { "description": "First number", @@ -579,12 +514,12 @@ describe("tools/list", () => { "type": "object", }, "get-tiny-image": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "gzip-file-as-resource": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "data": { "default": "https://raw.githubusercontent.com/modelcontextprotocol/servers/refs/heads/main/README.md", @@ -609,46 +544,23 @@ describe("tools/list", () => { }, "type": "object", }, - "simulate-research-query": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "ambiguous": { - "default": false, - "description": "Simulate an ambiguous query that requires clarification (triggers input_required status)", - "type": "boolean", - }, - "topic": { - "description": "The research topic to investigate", - "type": "string", - }, - }, - "required": [ - "topic", - ], - "type": "object", - }, "toggle-simulated-logging": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "toggle-subscriber-updates": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "trigger-elicitation-request": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": {}, - "type": "object", - }, - "trigger-elicitation-request-async": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "trigger-long-running-operation": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "duration": { "default": 10, @@ -664,25 +576,7 @@ describe("tools/list", () => { "type": "object", }, "trigger-sampling-request": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "maxTokens": { - "default": 100, - "description": "Maximum number of tokens to generate", - "type": "number", - }, - "prompt": { - "description": "The prompt to send to the LLM", - "type": "string", - }, - }, - "required": [ - "prompt", - ], - "type": "object", - }, - "trigger-sampling-request-async": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "maxTokens": { "default": 100, @@ -700,7 +594,7 @@ describe("tools/list", () => { "type": "object", }, "trigger-url-elicitation": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "elicitationId": { "description": "Optional explicit elicitation ID. Defaults to a random UUID.", @@ -737,7 +631,7 @@ describe("tools/list", () => { const tool = tools.find((t) => t.name === "get-structured-content"); expect(tool?.outputSchema).toMatchInlineSnapshot(` { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "conditions": { @@ -771,12 +665,15 @@ describe("prompts/list", () => { expect(prompts).toMatchInlineSnapshot(` [ { + "_meta": undefined, "arguments": undefined, "description": "A prompt with no arguments", + "icons": undefined, "name": "simple-prompt", "title": "Simple Prompt", }, { + "_meta": undefined, "arguments": [ { "description": "Name of the city", @@ -784,16 +681,18 @@ describe("prompts/list", () => { "required": true, }, { - "description": undefined, + "description": "Name of the state", "name": "state", "required": false, }, ], "description": "A prompt with two arguments, one required and one optional", + "icons": undefined, "name": "args-prompt", "title": "Arguments Prompt", }, { + "_meta": undefined, "arguments": [ { "description": "Choose the department.", @@ -807,10 +706,12 @@ describe("prompts/list", () => { }, ], "description": "First argument choice narrows values for second argument.", + "icons": undefined, "name": "completable-prompt", "title": "Team Management", }, { + "_meta": undefined, "arguments": [ { "description": "Type of resource to fetch", @@ -824,6 +725,7 @@ describe("prompts/list", () => { }, ], "description": "A prompt that includes an embedded resource reference", + "icons": undefined, "name": "resource-prompt", "title": "Resource Prompt", }, diff --git a/src/everything/__tests__/tasks.test.ts b/src/everything/__tests__/tasks.test.ts index 6a6fa61e88..d90c266f3f 100644 --- a/src/everything/__tests__/tasks.test.ts +++ b/src/everything/__tests__/tasks.test.ts @@ -12,18 +12,100 @@ * * Every wait in these tools is a `setTimeout` (one-second stages and polls), * so the tests fake `setTimeout` and advance the clock. + * + * SDK v2 removed the experimental tasks layer these tools and tests were + * built on (SEP-2663), so the three tools are gone and every test here is + * skipped until Part 5 (#4852) re-implements them on the + * `io.modelcontextprotocol/tasks` extension. The tests are kept, not deleted, + * as the record of the behavior Part 5 restores. The v1 client surface they + * drive (`client.experimental.tasks`, `InMemoryTaskStore`, task-returning + * request handlers) is typed by the stand-ins below so the bodies still + * compile; calling any of them throws. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks"; -import { - CallToolResultSchema, - CreateMessageRequestSchema, - ElicitRequestSchema, - type ElicitRequest, - type ElicitResult, -} from "@modelcontextprotocol/sdk/types.js"; +import type { + CallToolResult, + Client, + ElicitRequest, + ElicitResult, + GetTaskResult, +} from "@modelcontextprotocol/client"; +import { CallToolResultSchema } from "@modelcontextprotocol/core"; import { connect, contentOf, textOf, type Session } from "./harness.js"; +/** A task, as the v1 tasks API reported it. */ +type Task = GetTaskResult; + +/** One message of v1's `callToolStream`. */ +type TaskStreamMessage = + | { type: "taskCreated"; task: Task } + | { type: "taskStatus"; task: Task } + | { type: "result"; result: CallToolResult } + | { type: "error"; error: Error }; + +const REMOVED = + "SDK v2 removed the experimental tasks API (SEP-2663); see #4852"; + +/** Stand-in for v1's `client.experimental.tasks`. */ +function experimentalTasks(_client: Client): { + callToolStream: ( + params: { name: string; arguments: Record }, + resultSchema: unknown, + ) => AsyncGenerator; + listTasks: () => Promise<{ tasks: Task[] }>; + cancelTask: (taskId: string) => Promise; + getTask: (taskId: string) => Promise; +} { + throw new Error(REMOVED); +} + +/** Stand-in for v1's client-side `InMemoryTaskStore`. */ +class InMemoryTaskStore { + constructor() { + throw new Error(REMOVED); + } + createTask( + _params: { ttl?: number }, + _requestId: number, + _request: unknown, + ): Promise { + throw new Error(REMOVED); + } + getTask(_taskId: string): Promise { + throw new Error(REMOVED); + } + storeTaskResult( + _taskId: string, + _status: "completed" | "failed", + _result: Record, + ): Promise { + throw new Error(REMOVED); + } + updateTaskStatus( + _taskId: string, + _status: Task["status"], + _statusMessage?: string, + ): Promise { + throw new Error(REMOVED); + } +} + +/** + * Stand-in for a v1 client request handler that answers a task-augmented + * request with a `CreateTaskResult`, which v2's typed handlers do not allow. + */ +function setTaskRequestHandler< + M extends "sampling/createMessage" | "elicitation/create", +>( + _client: Client, + _method: M, + _handler: (request: { + params: { task?: { ttl?: number } }; + }) => Promise<{ task: Task }>, +): void { + throw new Error(REMOVED); +} + let session: Session | undefined; beforeEach(() => { @@ -65,7 +147,7 @@ describe("simulate-research-query", () => { await s.client.listTools(); // lets callToolStream see taskSupport return settle( collect( - s.client.experimental.tasks.callToolStream( + experimentalTasks(s.client).callToolStream( { name: "simulate-research-query", arguments: args }, CallToolResultSchema, ), @@ -85,7 +167,7 @@ describe("simulate-research-query", () => { return textOf(contentOf(last.result)[0]); } - it("creates a task that works through four stages and completes with a report", async () => { + it.skip("creates a task that works through four stages and completes with a report", async () => { session = await connect(); const messages = await research({ topic: "tides" }); @@ -140,7 +222,7 @@ describe("simulate-research-query", () => { `); }); - it("is rejected as a tool error when called without a task", async () => { + it.skip("is rejected as a tool error when called without a task", async () => { session = await connect(); const result = await session.client.callTool({ name: "simulate-research-query", @@ -157,7 +239,7 @@ describe("simulate-research-query", () => { }); }); - it("ignores 'ambiguous' for a client without elicitation", async () => { + it.skip("ignores 'ambiguous' for a client without elicitation", async () => { session = await connect(); const messages = await research({ topic: "python", ambiguous: true }); expect(statusMessages(messages)).not.toContain( @@ -174,7 +256,7 @@ describe("simulate-research-query", () => { session = await connect({ capabilities: { elicitation: { form: {} } }, setup: (client) => - client.setRequestHandler(ElicitRequestSchema, async (req) => { + client.setRequestHandler("elicitation/create", async (req) => { asked.push(req.params); return answer(req); }), @@ -182,7 +264,7 @@ describe("simulate-research-query", () => { return asked; } - it("asks for a clarification through tasks/result, then completes with it", async () => { + it.skip("asks for a clarification through tasks/result, then completes with it", async () => { const asked = await connectAnswering(() => ({ action: "accept", content: { interpretation: "programming" }, @@ -221,7 +303,7 @@ describe("simulate-research-query", () => { ); }); - it("offers generic interpretations for other topics", async () => { + it.skip("offers generic interpretations for other topics", async () => { const asked = await connectAnswering(() => ({ action: "decline" })); await research({ topic: "Mercury", ambiguous: true }); const params = asked[0]; @@ -236,7 +318,7 @@ describe("simulate-research-query", () => { ); }); - it.each([ + it.skip.each([ [{ action: "decline" }, "User declined - using default interpretation"], [{ action: "cancel" }, "User cancelled - using default interpretation"], [{ action: "accept", content: {} }, "User accepted without selection"], @@ -253,7 +335,7 @@ describe("simulate-research-query", () => { }, ); - it("falls back to a default interpretation when the elicitation fails", async () => { + it.skip("falls back to a default interpretation when the elicitation fails", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); await connectAnswering(() => { throw new Error("no UI"); @@ -273,12 +355,12 @@ describe("simulate-research-query", () => { }); }); - it("lists its task, and stops working on it once the client cancels it", async () => { + it.skip("lists its task, and stops working on it once the client cancels it", async () => { const errors = vi.spyOn(console, "error").mockImplementation(() => {}); session = await connect(); const { client } = session; await client.listTools(); - const stream = client.experimental.tasks.callToolStream( + const stream = experimentalTasks(client).callToolStream( { name: "simulate-research-query", arguments: { topic: "tides" } }, CallToolResultSchema, ); @@ -289,10 +371,10 @@ describe("simulate-research-query", () => { const { taskId } = first.value.task; await stream.return(undefined); - const { tasks } = await client.experimental.tasks.listTasks(); + const { tasks } = await experimentalTasks(client).listTasks(); expect(tasks.map((t) => t.taskId)).toContain(taskId); - await client.experimental.tasks.cancelTask(taskId); + await experimentalTasks(client).cancelTask(taskId); // The next stage's status update fails on the cancelled task; the // failure is logged, marking it failed fails too, and it stays cancelled. await vi.advanceTimersByTimeAsync(5000); @@ -300,7 +382,7 @@ describe("simulate-research-query", () => { `Research task ${taskId} failed:`, expect.any(Error), ); - expect(await client.experimental.tasks.getTask(taskId)).toMatchObject({ + expect(await experimentalTasks(client).getTask(taskId)).toMatchObject({ status: "cancelled", statusMessage: "Client cancelled task execution.", }); @@ -346,12 +428,11 @@ async function connectTaskClient( }, }, }, - taskStore, setup: (client) => { - client.setRequestHandler(CreateMessageRequestSchema, async (req) => + setTaskRequestHandler(client, "sampling/createMessage", async (req) => createTask("sampling", req, req.params.task?.ttl), ); - client.setRequestHandler(ElicitRequestSchema, async (req) => + setTaskRequestHandler(client, "elicitation/create", async (req) => createTask("elicitation", req, req.params.task?.ttl), ); }, @@ -361,9 +442,12 @@ async function connectTaskClient( async function callAsync(name: string, args: Record = {}) { const result = await settle( - session!.client.callTool({ name, arguments: args }, undefined, { - timeout: 3_600_000, - }), + session!.client.callTool( + { name, arguments: args }, + { + timeout: 3_600_000, + }, + ), 700_000, ); return contentOf(result).map(textOf); @@ -381,7 +465,7 @@ const SAMPLE = { }; describe("trigger-sampling-request-async", () => { - it("sends a task-augmented sampling request, polls the client's task, and returns its result", async () => { + it.skip("sends a task-augmented sampling request, polls the client's task, and returns its result", async () => { let created = ""; const { taskStore } = await connectTaskClient((_method, taskId) => { created = taskId!; @@ -408,7 +492,7 @@ describe("trigger-sampling-request-async", () => { ); }); - it("reports a failed client task with its status message", async () => { + it.skip("reports a failed client task with its status message", async () => { const { taskStore } = await connectTaskClient((_m, taskId) => { later(() => taskStore.updateTaskStatus(taskId!, "failed", "model overloaded"), @@ -422,7 +506,7 @@ describe("trigger-sampling-request-async", () => { ); }); - it("reports the status message of a client task that is already finished when it is created", async () => { + it.skip("reports the status message of a client task that is already finished when it is created", async () => { // A task that fails before it is returned is never polled, so its status // message comes from the CreateTaskResult. const { taskStore } = await connectTaskClient((_m, taskId) => { @@ -436,7 +520,7 @@ describe("trigger-sampling-request-async", () => { ); }); - it("reports a cancelled client task with no message", async () => { + it.skip("reports a cancelled client task with no message", async () => { const { taskStore } = await connectTaskClient((_m, taskId) => { later(() => taskStore.updateTaskStatus(taskId!, "cancelled")); }); @@ -446,7 +530,7 @@ describe("trigger-sampling-request-async", () => { expect(text).toMatch(/^\[CANCELLED\] No message\n\nProgress:\n/); }); - it("gives up after 60 polls of a task that never finishes", async () => { + it.skip("gives up after 60 polls of a task that never finishes", async () => { await connectTaskClient(() => {}); const [text] = await callAsync("trigger-sampling-request-async", { prompt: "p", @@ -459,7 +543,7 @@ describe("trigger-sampling-request-async", () => { ); }); - it("returns a synchronous answer from a client that ignores the task request", async () => { + it.skip("returns a synchronous answer from a client that ignores the task request", async () => { session = await connect({ capabilities: { sampling: {}, @@ -481,7 +565,7 @@ describe("trigger-sampling-request-async", () => { }); describe("trigger-elicitation-request-async", () => { - it("sends a task-augmented elicitation and summarizes the accepted answer", async () => { + it.skip("sends a task-augmented elicitation and summarizes the accepted answer", async () => { const asked: string[] = []; const { taskStore } = await connectTaskClient((method, taskId) => { asked.push(method); @@ -505,7 +589,7 @@ describe("trigger-elicitation-request-async", () => { ); }); - it("summarizes an accepted answer with no fields", async () => { + it.skip("summarizes an accepted answer with no fields", async () => { const { taskStore } = await connectTaskClient((_m, taskId) => { later(() => taskStore.storeTaskResult(taskId!, "completed", { @@ -521,7 +605,7 @@ describe("trigger-elicitation-request-async", () => { ]); }); - it.each([ + it.skip.each([ [ "decline", "[DECLINED] User declined to provide the requested information.", @@ -535,7 +619,7 @@ describe("trigger-elicitation-request-async", () => { expect(texts[0]).toBe(expected); }); - it("reports only progress and the raw result for an answer of another shape", async () => { + it.skip("reports only progress and the raw result for an answer of another shape", async () => { const { taskStore } = await connectTaskClient((_m, taskId) => { later(() => taskStore.storeTaskResult(taskId!, "completed", { action: "accept" }), @@ -546,7 +630,7 @@ describe("trigger-elicitation-request-async", () => { expect(texts[0]).toMatch(/^\nProgress:\n/); }); - it("logs the first poll, every tenth, and every status change while input is required", async () => { + it.skip("logs the first poll, every tenth, and every status change while input is required", async () => { let taskId = ""; const { taskStore } = await connectTaskClient((_m, id) => { taskId = id!; @@ -568,7 +652,7 @@ describe("trigger-elicitation-request-async", () => { expect(polls).toHaveLength(4); }); - it("reports the status message of a client task that is already finished when it is created", async () => { + it.skip("reports the status message of a client task that is already finished when it is created", async () => { // A task that fails before it is returned is never polled, so its status // message comes from the CreateTaskResult. const { taskStore } = await connectTaskClient((_m, taskId) => { @@ -582,7 +666,7 @@ describe("trigger-elicitation-request-async", () => { ]); }); - it("reports a failed client task", async () => { + it.skip("reports a failed client task", async () => { const { taskStore } = await connectTaskClient((_m, taskId) => { later(() => taskStore.updateTaskStatus(taskId!, "failed")); }); @@ -597,7 +681,7 @@ describe("trigger-elicitation-request-async", () => { }); } - it("gives up 5 seconds before the 10-minute TTL of a task that never finishes", async () => { + it.skip("gives up 5 seconds before the 10-minute TTL of a task that never finishes", async () => { fakeClock(); // A client that keeps its task longer than the 10-minute TTL asked for. await connectTaskClient(() => {}, 60_000); @@ -607,7 +691,7 @@ describe("trigger-elicitation-request-async", () => { ); }); - it("times out before a client that honors the 10-minute TTL expires the task", async () => { + it.skip("times out before a client that honors the 10-minute TTL expires the task", async () => { fakeClock(); let created = ""; const { taskStore } = await connectTaskClient((_m, taskId) => { @@ -625,7 +709,7 @@ describe("trigger-elicitation-request-async", () => { expect((await taskStore.getTask(created))?.ttl).toBe(600000); }); - it("times out without polling when its wait ends past the deadline", async () => { + it.skip("times out without polling when its wait ends past the deadline", async () => { // A timer that fires late: the first reading sets the deadline, and every // later one is already past it. let readings = 0; @@ -641,7 +725,7 @@ describe("trigger-elicitation-request-async", () => { ]); }); - it("returns a synchronous answer from a client that ignores the task request", async () => { + it.skip("returns a synchronous answer from a client that ignores the task request", async () => { session = await connect({ capabilities: { elicitation: {}, diff --git a/src/everything/__tests__/tools.test.ts b/src/everything/__tests__/tools.test.ts index 56650c47b6..66aef69288 100644 --- a/src/everything/__tests__/tools.test.ts +++ b/src/everything/__tests__/tools.test.ts @@ -44,7 +44,7 @@ describe("echo", () => { const result = await call("echo", {}); expect(result.isError).toBe(true); expect(textOf(contentOf(result)[0])).toMatch( - /^MCP error -32602: Input validation error: Invalid arguments for tool echo/, + /^Input validation error: Invalid arguments for tool echo/, ); }); }); @@ -175,7 +175,6 @@ describe("trigger-long-running-operation", () => { name: "trigger-long-running-operation", arguments: { duration: 0.03, steps: 3 }, }, - undefined, { onprogress: (p) => progress.push(p) }, ); expect(textOf(contentOf(result)[0])).toBe( @@ -308,11 +307,10 @@ describe("get-resource-reference", () => { }); describe("an unknown tool", () => { - it("is reported as a tool error", async () => { - const result = await call("no-such-tool"); - expect(result.isError).toBe(true); - expect(textOf(contentOf(result)[0])).toBe( - "MCP error -32602: Tool no-such-tool not found", - ); + it("is rejected with -32602", async () => { + await expect(call("no-such-tool")).rejects.toMatchObject({ + code: -32602, + message: "Tool no-such-tool not found", + }); }); }); diff --git a/src/everything/docs/features.md b/src/everything/docs/features.md index 84327a6987..12f8435130 100644 --- a/src/everything/docs/features.md +++ b/src/everything/docs/features.md @@ -25,9 +25,6 @@ - `trigger-elicitation-request` (tools/trigger-elicitation-request.ts): Issues an `elicitation/create` request using form-mode fields (strings, numbers, booleans, enums, and format validation) and returns the resulting action/content. Requires client form-mode elicitation: `elicitation.form`, or an `elicitation` object that declares neither mode. - `trigger-url-elicitation` (tools/trigger-url-elicitation.ts): Issues an `elicitation/create` request in URL mode (`mode: "url"`) with an `elicitationId`, or throws MCP error `-32042` (`UrlElicitationRequiredError`) when `errorPath=true`. On the error path the prerequisite elicitation it returns points at a different URL than the failing request (`https://modelcontextprotocol.io`); when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path, so the client does not loop on the same error. The retry marker is one-shot per `(session, url, elicitationId)`: it is cleared on the recognized retry, so re-running the error path with identical arguments without an intervening prerequisite is treated as a retry and proceeds. Requires client capability `elicitation.url`. - `trigger-sampling-request` (tools/trigger-sampling-request.ts): Issues a `sampling/createMessage` request to the client/LLM using provided `prompt` and optional generation controls; returns the LLM's response payload. -- `simulate-research-query` (tools/simulate-research-query.ts): Demonstrates MCP Tasks (SEP-1686) with a simulated multi-stage research operation. Accepts `topic` and `ambiguous` parameters. Returns a task that progresses through stages with status updates. If `ambiguous` is true and client supports elicitation, sends an elicitation request directly to gather clarification before completing. -- `trigger-sampling-request-async` (tools/trigger-sampling-request-async.ts): Demonstrates bidirectional tasks where the server sends a sampling request that the client executes as a background task. Server polls for status and retrieves the LLM result when complete. Requires client to support `tasks.requests.sampling.createMessage`. -- `trigger-elicitation-request-async` (tools/trigger-elicitation-request-async.ts): Demonstrates bidirectional tasks where the server sends an elicitation request that the client executes as a background task. Server polls while waiting for user input. Requires client form-mode elicitation (as for `trigger-elicitation-request`) and `tasks.requests.elicitation.create`. ## Prompts @@ -56,50 +53,11 @@ - Use the `toggle-simulated-logging` tool to start/stop periodic log messages of varying levels (debug, info, notice, warning, error, critical, alert, emergency) per session. - Clients can control the minimum level they receive via the standard MCP `logging/setLevel` request. -## Tasks (SEP-1686) +## Tasks -The server advertises support for MCP Tasks, enabling long-running operations with status tracking: - -- **Capabilities advertised**: `tasks.list`, `tasks.cancel`, `tasks.requests.tools.call` -- **Task Store**: Uses `InMemoryTaskStore` from SDK experimental for task lifecycle management -- **Message Queue**: Uses `InMemoryTaskMessageQueue` for task-related messaging - -### Task Lifecycle - -1. Client calls `tools/call` with `task: true` parameter -2. Server returns `CreateTaskResult` with `taskId` instead of immediate result -3. Client polls `tasks/get` to check status and receive `statusMessage` updates -4. When status is `completed`, client calls `tasks/result` to retrieve the final result - -### Task Statuses - -- `working`: Task is actively processing -- `input_required`: Task needs additional input (server sends elicitation request directly) -- `completed`: Task finished successfully -- `failed`: Task encountered an error -- `cancelled`: Task was cancelled by client - -### Demo Tools - -**Server-side tasks (client calls server):** -Use the `simulate-research-query` tool to exercise the full task lifecycle. Set `ambiguous: true` to trigger elicitation - the server will send an `elicitation/create` request directly and await the response before completing. - -**Client-side tasks (server calls client):** -Use `trigger-sampling-request-async` or `trigger-elicitation-request-async` to demonstrate bidirectional tasks where the server sends requests that the client executes as background tasks. These require the client to advertise `sampling` with `tasks.requests.sampling.createMessage`, or form-mode `elicitation` with `tasks.requests.elicitation.create`, respectively. - -### Bidirectional Task Flow - -MCP Tasks are bidirectional - both server and client can be task executors: - -| Direction | Request Type | Task Executor | Demo Tool | -| ---------------- | ------------------------ | ------------- | ----------------------------------- | -| Client -> Server | `tools/call` | Server | `simulate-research-query` | -| Server -> Client | `sampling/createMessage` | Client | `trigger-sampling-request-async` | -| Server -> Client | `elicitation/create` | Client | `trigger-elicitation-request-async` | - -For client-side tasks: - -1. Server sends request with task metadata (e.g., `params.task.ttl`) -2. Client creates task and returns `CreateTaskResult` with `taskId` -3. Server polls `tasks/get` for status updates -4. When complete, server calls `tasks/result` to retrieve the result +The server does not support MCP Tasks at the moment. Its three task tools +(`simulate-research-query`, `trigger-sampling-request-async` and +`trigger-elicitation-request-async`) were built on the TypeScript SDK's +experimental tasks layer, which SDK v2 removed (SEP-2663). They return on the +`io.modelcontextprotocol/tasks` extension in +[#4852](https://github.com/modelcontextprotocol/servers/issues/4852). diff --git a/src/everything/docs/instructions.md b/src/everything/docs/instructions.md index 4e6a1e0ac4..2abe5f916a 100644 --- a/src/everything/docs/instructions.md +++ b/src/everything/docs/instructions.md @@ -23,8 +23,6 @@ Registered only if your client declared the capability shown. Otherwise they are - `trigger-sampling-request`: `sampling` - `trigger-elicitation-request`: `elicitation.form`, or `elicitation` with no mode - `trigger-url-elicitation`: `elicitation.url` -- `trigger-sampling-request-async`: `sampling` and `tasks.requests.sampling.createMessage` -- `trigger-elicitation-request-async`: form-mode `elicitation` and `tasks.requests.elicitation.create` ## Operational Patterns diff --git a/src/everything/docs/structure.md b/src/everything/docs/structure.md index 24530e03af..2464b1c7c8 100644 --- a/src/everything/docs/structure.md +++ b/src/everything/docs/structure.md @@ -48,14 +48,11 @@ src/everything │ ├── get-sum.ts │ ├── get-tiny-image.ts │ ├── gzip-file-as-resource.ts - │ ├── simulate-research-query.ts │ ├── toggle-simulated-logging.ts │ ├── toggle-subscriber-updates.ts │ ├── trigger-elicitation-request.ts - │ ├── trigger-elicitation-request-async.ts │ ├── trigger-long-running-operation.ts │ ├── trigger-sampling-request.ts - │ ├── trigger-sampling-request-async.ts │ └── trigger-url-elicitation.ts └── transports ├── in-memory-event-store.ts @@ -82,7 +79,7 @@ src/everything - Package metadata and scripts: - `build`: TypeScript compile to `dist/`, copies `docs/` into `dist/` and marks the compiled entry scripts as executable. - `start:stdio`, `start:sse`, `start:streamableHttp`: Run built transports from `dist/`. -- Declares dependencies on `@modelcontextprotocol/sdk`, `express`, `cors`, `zod`, etc. +- Declares dependencies on the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, `@modelcontextprotocol/server-legacy`), `express`, `cors`, `zod`, etc. ### `docs/` @@ -163,18 +160,12 @@ src/everything - `GZIP_MAX_FETCH_SIZE` (bytes, default 10 MiB) - `GZIP_MAX_FETCH_TIME_MILLIS` (ms, default 30000) - `GZIP_ALLOWED_DOMAINS` (comma-separated allowlist; empty means all domains allowed) -- `simulate-research-query.ts` - - Registers a `simulate-research-query` task-based tool that demonstrates the MCP Tasks feature (SEP-1686). Simulates a multi-stage research operation with progress updates. If the query is marked as ambiguous and the client supports elicitation, it pauses mid-execution to request clarification via `elicitation/create`. Uses `server.experimental.tasks.registerToolTask()` with `execution: { taskSupport: "required" }`. - `trigger-elicitation-request.ts` - Registers a `trigger-elicitation-request` tool that sends an `elicitation/create` request to the client/LLM and returns the elicitation result. - `trigger-url-elicitation.ts` - Registers a `trigger-url-elicitation` tool that either sends an out-of-band URL-mode `elicitation/create` request (`mode: "url"`) including an `elicitationId` (request path) or throws `UrlElicitationRequiredError` (`-32042`) for client-handled URL elicitation (error path). On the error path the carried prerequisite elicitation points at a different URL than the failing one (`https://modelcontextprotocol.io`), and when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path — so the client does not loop on the same error. -- `trigger-elicitation-request-async.ts` - - Registers a `trigger-elicitation-request-async` tool that demonstrates bidirectional MCP tasks for elicitation. Sends an elicitation request with task metadata, then polls the client's `tasks/get` endpoint for completion status before fetching the final result. - `trigger-sampling-request.ts` - Registers a `trigger-sampling-request` tool that sends a `sampling/createMessage` request to the client/LLM and returns the sampling result. -- `trigger-sampling-request-async.ts` - - Registers a `trigger-sampling-request-async` tool that demonstrates bidirectional MCP tasks for sampling. Sends a sampling request with task metadata, then polls the client's `tasks/get` endpoint for completion status before fetching the final result. - `get-structured-content.ts` - Registers a `get-structured-content` tool that demonstrates structuredContent block responses. - `get-sum.ts` @@ -203,10 +194,10 @@ src/everything - `GET /sse` to establish an SSE connection per session. - `POST /message` for client messages. - Manages multiple connected clients via a transport map. - - Starts an `SSEServerTransport`, created the server via `createServer()`, and connects it to a new transport. + - Starts an `SSEServerTransport` (from the frozen `@modelcontextprotocol/server-legacy/sse` bridge), created the server via `createServer()`, and connects it to a new transport. - On server disconnect, calls `cleanup()` to remove any live intervals. - `streamableHttp.ts` - - Express server exposing a single `/mcp` endpoint for POST (JSON‑RPC), GET (SSE stream), and DELETE (session termination) using `StreamableHTTPServerTransport`. + - Express server exposing a single `/mcp` endpoint for POST (JSON‑RPC), GET (SSE stream), and DELETE (session termination) using `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`. - Uses an `InMemoryEventStore` for resumable sessions and tracks transports by `sessionId`. - Answers a request for an unknown or ended `Mcp-Session-Id` with `404 Not Found`, and closes every open session on `SIGINT`. - Connects a fresh server instance on initialization POST and reuses the transport for subsequent requests. diff --git a/src/everything/package.json b/src/everything/package.json index ec91330d8d..ecdb17ce3f 100644 --- a/src/everything/package.json +++ b/src/everything/package.json @@ -12,6 +12,9 @@ "url": "https://github.com/modelcontextprotocol/servers.git" }, "type": "module", + "engines": { + "node": ">=20" + }, "bin": { "mcp-server-everything": "dist/index.js" }, @@ -34,13 +37,17 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.31.0", + "@modelcontextprotocol/node": "^2.1.1", + "@modelcontextprotocol/server": "^2.3.1", + "@modelcontextprotocol/server-legacy": "^2.3.1", "cors": "^2.8.5", "express": "^5.2.1", "jszip": "^3.10.1", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", + "@modelcontextprotocol/core": "^2.3.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@vitest/coverage-v8": "^4.1.11", diff --git a/src/everything/prompts/args.ts b/src/everything/prompts/args.ts index bbd3741799..d3113d3687 100644 --- a/src/everything/prompts/args.ts +++ b/src/everything/prompts/args.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; /** * Register a prompt with arguments @@ -10,10 +10,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; */ export const registerArgumentsPrompt = (server: McpServer) => { // Prompt arguments - const promptArgsSchema = { + const promptArgsSchema = z.object({ city: z.string().describe("Name of the city"), state: z.string().describe("Name of the state").optional(), - }; + }); // Register the prompt server.registerPrompt( diff --git a/src/everything/prompts/completions.ts b/src/everything/prompts/completions.ts index f7c76d6b8e..aff3c77f77 100644 --- a/src/everything/prompts/completions.ts +++ b/src/everything/prompts/completions.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; +import { McpServer, completable } from "@modelcontextprotocol/server"; /** * Register a prompt with completable arguments @@ -12,7 +11,7 @@ import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; */ export const registerPromptWithCompletions = (server: McpServer) => { // Prompt arguments - const promptArgsSchema = { + const promptArgsSchema = z.object({ department: completable( z.string().describe("Choose the department."), (value) => { @@ -39,7 +38,7 @@ export const registerPromptWithCompletions = (server: McpServer) => { return []; }, ), - }; + }); // Register the prompt server.registerPrompt( diff --git a/src/everything/prompts/index.ts b/src/everything/prompts/index.ts index 6efa7b7297..22d6a22baf 100644 --- a/src/everything/prompts/index.ts +++ b/src/everything/prompts/index.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerSimplePrompt } from "./simple.js"; import { registerArgumentsPrompt } from "./args.js"; import { registerPromptWithCompletions } from "./completions.js"; diff --git a/src/everything/prompts/resource.ts b/src/everything/prompts/resource.ts index e825941673..5b96aba194 100644 --- a/src/everything/prompts/resource.ts +++ b/src/everything/prompts/resource.ts @@ -1,4 +1,5 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; import { resourceTypeCompleter, resourceIdForPromptCompleter, @@ -22,10 +23,10 @@ import { */ export const registerEmbeddedResourcePrompt = (server: McpServer) => { // Prompt arguments - const promptArgsSchema = { + const promptArgsSchema = z.object({ resourceType: resourceTypeCompleter, resourceId: resourceIdForPromptCompleter, - }; + }); // Register the prompt server.registerPrompt( diff --git a/src/everything/prompts/simple.ts b/src/everything/prompts/simple.ts index e194a8361f..13d20d6667 100644 --- a/src/everything/prompts/simple.ts +++ b/src/everything/prompts/simple.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; /** * Register a simple prompt with no arguments diff --git a/src/everything/resources/files.ts b/src/everything/resources/files.ts index abd6dac7f8..8e051ef362 100644 --- a/src/everything/resources/files.ts +++ b/src/everything/resources/files.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { readdirSync, readFileSync, statSync } from "fs"; diff --git a/src/everything/resources/index.ts b/src/everything/resources/index.ts index 30c6f7dcf8..b7ec741376 100644 --- a/src/everything/resources/index.ts +++ b/src/everything/resources/index.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerResourceTemplates } from "./templates.js"; import { registerFileResources } from "./files.js"; import { fileURLToPath } from "url"; diff --git a/src/everything/resources/session.ts b/src/everything/resources/session.ts index db0e02c259..d3c7dbc05c 100644 --- a/src/everything/resources/session.ts +++ b/src/everything/resources/session.ts @@ -1,8 +1,9 @@ import { McpServer, RegisteredResource, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Resource, ResourceLink } from "@modelcontextprotocol/sdk/types.js"; + Resource, + ResourceLink, +} from "@modelcontextprotocol/server"; /** * Tracks registered session resources per server, by URI, to allow updating/removing on diff --git a/src/everything/resources/subscriptions.ts b/src/everything/resources/subscriptions.ts index 295e186e4a..510254ed00 100644 --- a/src/everything/resources/subscriptions.ts +++ b/src/everything/resources/subscriptions.ts @@ -1,8 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - SubscribeRequestSchema, - UnsubscribeRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; // Track subscriber session id lists by URI const subscriptions: Map> = new Map< @@ -36,13 +32,13 @@ const subsUpdateIntervals: Map = export const setSubscriptionHandlers = (server: McpServer) => { // Set the subscription handler server.server.setRequestHandler( - SubscribeRequestSchema, - async (request, extra) => { + "resources/subscribe", + async (request, ctx) => { // Get the URI to subscribe to const { uri } = request.params; // Get the session id (can be undefined for stdio) - const sessionId = extra.sessionId as string; + const sessionId = ctx.sessionId as string; // Acknowledge the subscribe request await server.sendLoggingMessage( @@ -67,13 +63,13 @@ export const setSubscriptionHandlers = (server: McpServer) => { // Set the unsubscription handler server.server.setRequestHandler( - UnsubscribeRequestSchema, - async (request, extra) => { + "resources/unsubscribe", + async (request, ctx) => { // Get the URI to subscribe to const { uri } = request.params; // Get the session id (can be undefined for stdio) - const sessionId = extra.sessionId as string; + const sessionId = ctx.sessionId as string; // Acknowledge the subscribe request await server.sendLoggingMessage( diff --git a/src/everything/resources/templates.ts b/src/everything/resources/templates.ts index ea1c484e8e..7a97aa0a48 100644 --- a/src/everything/resources/templates.ts +++ b/src/everything/resources/templates.ts @@ -3,8 +3,8 @@ import { CompleteResourceTemplateCallback, McpServer, ResourceTemplate, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; + completable, +} from "@modelcontextprotocol/server"; // Resource types export const RESOURCE_TYPE_TEXT = "Text" as const; diff --git a/src/everything/server/index.ts b/src/everything/server/index.ts index 658077e3be..fcc0526aea 100644 --- a/src/everything/server/index.ts +++ b/src/everything/server/index.ts @@ -1,8 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - InMemoryTaskStore, - InMemoryTaskMessageQueue, -} from "@modelcontextprotocol/sdk/experimental/tasks"; +import { McpServer } from "@modelcontextprotocol/server"; import { setSubscriptionHandlers, stopSimulatedResourceUpdates, @@ -38,10 +34,6 @@ export const createServer: () => ServerFactoryResponse = () => { // Read the server instructions const instructions = readInstructions(); - // Create task store and message queue for task support - const taskStore = new InMemoryTaskStore(); - const taskMessageQueue = new InMemoryTaskMessageQueue(); - let initializeTimeout: NodeJS.Timeout | null = null; // Create the server @@ -64,19 +56,11 @@ export const createServer: () => ServerFactoryResponse = () => { listChanged: true, }, logging: {}, - tasks: { - list: {}, - cancel: {}, - requests: { - tools: { - call: {}, - }, - }, - }, + // No `tasks` capability: SDK v2 removed the experimental tasks layer + // (SEP-2663). Part 5 (#4852) brings tasks back on the + // `io.modelcontextprotocol/tasks` extension. }, instructions, - taskStore, - taskMessageQueue, }, ); @@ -113,8 +97,6 @@ export const createServer: () => ServerFactoryResponse = () => { stopSimulatedLogging(sessionId); stopSimulatedResourceUpdates(sessionId); removeSubscriber(sessionId); - // Clean up task store timers - taskStore.cleanup(); if (initializeTimeout) clearTimeout(initializeTimeout); }, } satisfies ServerFactoryResponse; diff --git a/src/everything/server/logging.ts b/src/everything/server/logging.ts index 75d801c0f0..3415912982 100644 --- a/src/everything/server/logging.ts +++ b/src/everything/server/logging.ts @@ -1,5 +1,4 @@ -import { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { LoggingLevel, McpServer } from "@modelcontextprotocol/server"; // Map session ID to the interval for sending logging messages to the client const logsUpdateIntervals: Map = diff --git a/src/everything/server/roots.ts b/src/everything/server/roots.ts index 83c616c35c..cd4824c030 100644 --- a/src/everything/server/roots.ts +++ b/src/everything/server/roots.ts @@ -1,8 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - Root, - RootsListChangedNotificationSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, Root } from "@modelcontextprotocol/server"; // Track roots by session id export const roots: Map = new Map< @@ -66,7 +62,7 @@ export const syncRoots = async (server: McpServer, sessionId?: string) => { } catch (error) { console.error( `Failed to request roots from client ${sessionId}: ${ - /* v8 ignore start -- the SDK always rejects listRoots() with an Error (McpError) */ + /* v8 ignore start -- the SDK always rejects listRoots() with an Error (ProtocolError or SdkError) */ error instanceof Error ? error.message : String(error) /* v8 ignore stop */ }`, @@ -79,7 +75,7 @@ export const syncRoots = async (server: McpServer, sessionId?: string) => { if (!roots.has(sessionId)) { // Set the list changed notification handler server.server.setNotificationHandler( - RootsListChangedNotificationSchema, + "notifications/roots/list_changed", requestRoots, ); diff --git a/src/everything/tools/echo.ts b/src/everything/tools/echo.ts index 0658e83195..8ae7943ad5 100644 --- a/src/everything/tools/echo.ts +++ b/src/everything/tools/echo.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { z } from "zod"; // Tool input schema diff --git a/src/everything/tools/get-annotated-message.ts b/src/everything/tools/get-annotated-message.ts index eaff07b196..b339998286 100644 --- a/src/everything/tools/get-annotated-message.ts +++ b/src/everything/tools/get-annotated-message.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { z } from "zod"; import { MCP_TINY_IMAGE } from "./get-tiny-image.js"; diff --git a/src/everything/tools/get-env.ts b/src/everything/tools/get-env.ts index cd67ab6720..4d16a2a954 100644 --- a/src/everything/tools/get-env.ts +++ b/src/everything/tools/get-env.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; // Tool configuration const name = "get-env"; diff --git a/src/everything/tools/get-resource-links.ts b/src/everything/tools/get-resource-links.ts index 7684cb64a5..e35bf83631 100644 --- a/src/everything/tools/get-resource-links.ts +++ b/src/everything/tools/get-resource-links.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { textResource, textResourceUri, diff --git a/src/everything/tools/get-resource-reference.ts b/src/everything/tools/get-resource-reference.ts index 5300aaf34a..f88cb5d887 100644 --- a/src/everything/tools/get-resource-reference.ts +++ b/src/everything/tools/get-resource-reference.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { textResource, textResourceUri, diff --git a/src/everything/tools/get-roots-list.ts b/src/everything/tools/get-roots-list.ts index d7ad7fc46d..a18d208143 100644 --- a/src/everything/tools/get-roots-list.ts +++ b/src/everything/tools/get-roots-list.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { syncRoots } from "../server/roots.js"; // Tool configuration @@ -44,9 +43,9 @@ export const registerGetRootsListTool = (server: McpServer) => { server.registerTool( name, config, - async (args, extra): Promise => { + async (args, ctx): Promise => { // Get the current rootsFetch the current roots list from the client if need be - const currentRoots = await syncRoots(server, extra.sessionId); + const currentRoots = await syncRoots(server, ctx.sessionId); // Respond if client supports roots but doesn't have any configured if ( diff --git a/src/everything/tools/get-structured-content.ts b/src/everything/tools/get-structured-content.ts index f9bde5c9e4..11f323e14b 100644 --- a/src/everything/tools/get-structured-content.ts +++ b/src/everything/tools/get-structured-content.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { + McpServer, CallToolResult, ContentBlock, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/server"; // Tool input schema const GetStructuredContentInputSchema = { diff --git a/src/everything/tools/get-sum.ts b/src/everything/tools/get-sum.ts index 470293bf6f..d8bd04234e 100644 --- a/src/everything/tools/get-sum.ts +++ b/src/everything/tools/get-sum.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; // Tool input schema const GetSumSchema = z.object({ diff --git a/src/everything/tools/get-tiny-image.ts b/src/everything/tools/get-tiny-image.ts index f0f2cf517e..cb931cf941 100644 --- a/src/everything/tools/get-tiny-image.ts +++ b/src/everything/tools/get-tiny-image.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; // A tiny encoded MCP logo image export const MCP_TINY_IMAGE = diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts index c0cc0c56f8..99c9da1bb9 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -1,6 +1,9 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult, Resource } from "@modelcontextprotocol/sdk/types.js"; +import { + McpServer, + CallToolResult, + Resource, +} from "@modelcontextprotocol/server"; import { gzipSync } from "node:zlib"; import { getSessionResourceURI, diff --git a/src/everything/tools/index.ts b/src/everything/tools/index.ts index 0272126eea..3e71f17740 100644 --- a/src/everything/tools/index.ts +++ b/src/everything/tools/index.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerGetAnnotatedMessageTool } from "./get-annotated-message.js"; import { registerEchoTool } from "./echo.js"; import { registerGetEnvTool } from "./get-env.js"; @@ -14,9 +14,6 @@ import { registerToggleSubscriberUpdatesTool } from "./toggle-subscriber-updates import { registerTriggerElicitationRequestTool } from "./trigger-elicitation-request.js"; import { registerTriggerLongRunningOperationTool } from "./trigger-long-running-operation.js"; import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.js"; -import { registerTriggerSamplingRequestAsyncTool } from "./trigger-sampling-request-async.js"; -import { registerTriggerElicitationRequestAsyncTool } from "./trigger-elicitation-request-async.js"; -import { registerSimulateResearchQueryTool } from "./simulate-research-query.js"; import { registerTriggerUrlElicitationTool } from "./trigger-url-elicitation.js"; /** @@ -47,9 +44,8 @@ export const registerConditionalTools = (server: McpServer) => { registerTriggerElicitationRequestTool(server); registerTriggerUrlElicitationTool(server); registerTriggerSamplingRequestTool(server); - // Task-based research tool (uses experimental tasks API) - registerSimulateResearchQueryTool(server); - // Bidirectional task tools - server sends requests that client executes as tasks - registerTriggerSamplingRequestAsyncTool(server); - registerTriggerElicitationRequestAsyncTool(server); + // The task tools (simulate-research-query, trigger-sampling-request-async, + // trigger-elicitation-request-async) were removed with SDK v2, which drops + // the experimental tasks layer (SEP-2663). Part 5 (#4852) re-implements them + // on the `io.modelcontextprotocol/tasks` extension. }; diff --git a/src/everything/tools/simulate-research-query.ts b/src/everything/tools/simulate-research-query.ts deleted file mode 100644 index 7055d3db5e..0000000000 --- a/src/everything/tools/simulate-research-query.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - CallToolResult, - GetTaskResult, - Task, - ElicitResult, - ElicitResultSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { CreateTaskResult } from "@modelcontextprotocol/sdk/experimental/tasks"; - -// Tool input schema -const SimulateResearchQuerySchema = z.object({ - topic: z.string().describe("The research topic to investigate"), - ambiguous: z - .boolean() - .default(false) - .describe( - "Simulate an ambiguous query that requires clarification (triggers input_required status)", - ), -}); - -// Research stages -const STAGES = [ - "Gathering sources", - "Analyzing content", - "Synthesizing findings", - "Generating report", -]; - -// Duration per stage in milliseconds -const STAGE_DURATION = 1000; - -// Internal state for tracking research tasks -interface ResearchState { - topic: string; - ambiguous: boolean; - currentStage: number; - clarification?: string; - completed: boolean; - result?: CallToolResult; -} - -// Map to store research state per task -const researchStates = new Map(); - -/** - * Runs the background research process. - * Updates task status as it progresses through stages. - * If clarification is needed, sends elicitation via sendRequest with relatedTask, - * which queues the request in the task message queue. The SDK delivers it through - * the tasks/result stream when the client calls tasks/result (per spec input_required flow). - * This works on all transports (STDIO, SSE, Streamable HTTP). - */ -async function runResearchProcess( - taskId: string, - args: z.infer, - taskStore: { - updateTaskStatus: ( - taskId: string, - status: Task["status"], - message?: string, - ) => Promise; - storeTaskResult: ( - taskId: string, - status: "completed" | "failed", - result: CallToolResult, - ) => Promise; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendRequest: any, -): Promise { - const state = researchStates.get(taskId); - /* v8 ignore next -- createTask stores the state immediately before starting this process */ - if (!state) return; - - // Process each stage - for (let i = state.currentStage; i < STAGES.length; i++) { - state.currentStage = i; - - // Check if task was cancelled externally - if (state.completed) return; - - // Update status message for current stage - await taskStore.updateTaskStatus(taskId, "working", `${STAGES[i]}...`); - - // At synthesis stage (index 2), check if clarification is needed - if (i === 2 && state.ambiguous && !state.clarification) { - // Update status to show we're requesting input (spec SHOULD) - await taskStore.updateTaskStatus( - taskId, - "input_required", - `Found multiple interpretations for "${state.topic}". Requesting clarification...`, - ); - - try { - // relatedTask queues elicitation via task message queue → delivered through tasks/result on all transports - const elicitResult: ElicitResult = await sendRequest( - { - method: "elicitation/create", - params: { - message: `The research query "${state.topic}" could have multiple interpretations. Please clarify what you're looking for:`, - requestedSchema: { - type: "object", - properties: { - interpretation: { - type: "string", - title: "Clarification", - description: - "Which interpretation of the topic do you mean?", - oneOf: getInterpretationsForTopic(state.topic), - }, - }, - required: ["interpretation"], - }, - }, - }, - ElicitResultSchema, - { relatedTask: { taskId } }, - ); - - // Process elicitation response - if (elicitResult.action === "accept" && elicitResult.content) { - state.clarification = - (elicitResult.content as { interpretation?: string }) - .interpretation || "User accepted without selection"; - } else if (elicitResult.action === "decline") { - state.clarification = "User declined - using default interpretation"; - } else { - state.clarification = "User cancelled - using default interpretation"; - } - } catch (error) { - // Elicitation failed - use default interpretation and continue - console.warn( - `Elicitation failed for task ${taskId}:`, - error instanceof Error ? error.message : String(error), - ); - state.clarification = "technical (default - elicitation unavailable)"; - } - - // Resume with working status (spec SHOULD) - await taskStore.updateTaskStatus( - taskId, - "working", - `Continuing with interpretation: "${state.clarification}"...`, - ); - - // Continue processing (no return - just keep going through the loop) - } - - // Simulate work for this stage - await new Promise((resolve) => setTimeout(resolve, STAGE_DURATION)); - } - - // All stages complete - generate result - state.completed = true; - const result = generateResearchReport(state); - state.result = result; - - await taskStore.storeTaskResult(taskId, "completed", result); -} - -/** - * Generates the final research report with educational content about tasks. - */ -function generateResearchReport(state: ResearchState): CallToolResult { - const topic = state.clarification - ? `${state.topic} (${state.clarification})` - : state.topic; - - const report = `# Research Report: ${topic} - -## Research Parameters -- **Topic**: ${state.topic} -${state.clarification ? `- **Clarification**: ${state.clarification}` : ""} - -## Synthesis -This research query was processed through ${STAGES.length} stages: -${STAGES.map((s, i) => `- Stage ${i + 1}: ${s} ✓`).join("\n")} - ---- - -## About This Demo (SEP-1686: Tasks) - -This tool demonstrates MCP's task-based execution pattern for long-running operations: - -**Task Lifecycle Demonstrated:** -1. \`tools/call\` with \`task\` parameter → Server returns \`CreateTaskResult\` (not the final result) -2. Client polls \`tasks/get\` → Server returns current status and \`statusMessage\` -3. Status progressed: \`working\` → ${ - state.clarification ? `\`input_required\` → \`working\` → ` : "" - }\`completed\` -4. Client calls \`tasks/result\` → Server returns this final result - -${ - state.clarification - ? `**Elicitation Flow:** -When the query was ambiguous, the server sent an \`elicitation/create\` request -to the client. The task status changed to \`input_required\` while awaiting user input. -${ - state.clarification.includes("unavailable") - ? `**Note:** Elicitation failed and a default interpretation was used.` - : `After receiving clarification ("${state.clarification}"), the task resumed processing and completed.` -} -` - : "" -} -**Key Concepts:** -- Tasks enable "call now, fetch later" patterns -- \`statusMessage\` provides human-readable progress updates -- Tasks have TTL (time-to-live) for automatic cleanup -- \`pollInterval\` suggests how often to check status -- Elicitation requests use \`relatedTask\` to queue via tasks/result (works on all transports) - -*This is a simulated research report from the Everything MCP Server.* -`; - - return { - content: [ - { - type: "text", - text: report, - }, - ], - }; -} - -/** - * Registers the 'simulate-research-query' tool as a task-based tool. - * - * This tool demonstrates the MCP Tasks feature (SEP-1686) with a real-world scenario: - * a research tool that gathers and synthesizes information from multiple sources. - * If the query is ambiguous, it pauses to ask for clarification before completing. - * - * @param {McpServer} server - The McpServer instance where the tool will be registered. - */ -export const registerSimulateResearchQueryTool = (server: McpServer) => { - // Check if client supports elicitation (needed for input_required flow) - const clientCapabilities = server.server.getClientCapabilities() || {}; - const clientSupportsElicitation: boolean = - clientCapabilities.elicitation !== undefined; - - server.experimental.tasks.registerToolTask( - "simulate-research-query", - { - title: "Simulate Research Query", - description: - "Simulates a deep research operation that gathers, analyzes, and synthesizes information. " + - "Demonstrates MCP task-based operations with progress through multiple stages. " + - "If 'ambiguous' is true and client supports elicitation, sends an elicitation request for clarification.", - inputSchema: SimulateResearchQuerySchema, - execution: { taskSupport: "required" }, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, - }, - }, - { - /** - * Creates a new research task and starts background processing. - */ - createTask: async (args, extra): Promise => { - const validatedArgs = SimulateResearchQuerySchema.parse(args); - - // Create the task in the store - const task = await extra.taskStore.createTask({ - ttl: 300000, // 5 minutes - pollInterval: 1000, - }); - - // Initialize research state - const state: ResearchState = { - topic: validatedArgs.topic, - ambiguous: validatedArgs.ambiguous && clientSupportsElicitation, - currentStage: 0, - completed: false, - }; - researchStates.set(task.taskId, state); - - // Start background research (don't await - runs asynchronously) - // Pass sendRequest for elicitation (queued via task message queue, works on all transports) - runResearchProcess( - task.taskId, - validatedArgs, - extra.taskStore, - extra.sendRequest, - ).catch((error) => { - console.error(`Research task ${task.taskId} failed:`, error); - extra.taskStore - .updateTaskStatus(task.taskId, "failed", String(error)) - .catch(console.error); - }); - - return { task }; - }, - - /* v8 ignore start -- SDK 1.x never calls a tool task's getTask/getTaskResult: tasks/get and tasks/result are answered from the task store (Protocol), so these handlers are unreachable over the wire */ - /** - * Returns the current status of the research task. - */ - getTask: async (args, extra): Promise => { - return await extra.taskStore.getTask(extra.taskId); - }, - - /** - * Returns the task result. - * Elicitation is now handled directly in the background process. - */ - getTaskResult: async (args, extra): Promise => { - // Return the stored result - const result = await extra.taskStore.getTaskResult(extra.taskId); - - // Clean up state - researchStates.delete(extra.taskId); - - return result as CallToolResult; - }, - /* v8 ignore stop */ - }, - ); -}; - -/** - * Returns contextual interpretation options based on the topic. - */ -function getInterpretationsForTopic( - topic: string, -): Array<{ const: string; title: string }> { - const lowerTopic = topic.toLowerCase(); - - // Example: contextual interpretations for "python" - if (lowerTopic.includes("python")) { - return [ - { const: "programming", title: "Python programming language" }, - { const: "snake", title: "Python snake species" }, - { const: "comedy", title: "Monty Python comedy group" }, - ]; - } - - // Default generic interpretations - return [ - { const: "technical", title: "Technical/scientific perspective" }, - { const: "historical", title: "Historical perspective" }, - { const: "current", title: "Current events/news perspective" }, - ]; -} diff --git a/src/everything/tools/toggle-simulated-logging.ts b/src/everything/tools/toggle-simulated-logging.ts index 7551da0967..a847e6459a 100644 --- a/src/everything/tools/toggle-simulated-logging.ts +++ b/src/everything/tools/toggle-simulated-logging.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { beginSimulatedLogging, stopSimulatedLogging, @@ -38,8 +37,8 @@ export const registerToggleSimulatedLoggingTool = (server: McpServer) => { server.registerTool( name, config, - async (_args, extra): Promise => { - const sessionId = extra?.sessionId; + async (_args, ctx): Promise => { + const sessionId = ctx?.sessionId; let response: string; if (clients.has(sessionId)) { diff --git a/src/everything/tools/toggle-subscriber-updates.ts b/src/everything/tools/toggle-subscriber-updates.ts index 4b7f3b1c1c..2b53fded57 100644 --- a/src/everything/tools/toggle-subscriber-updates.ts +++ b/src/everything/tools/toggle-subscriber-updates.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { beginSimulatedResourceUpdates, stopSimulatedResourceUpdates, @@ -41,8 +40,8 @@ export const registerToggleSubscriberUpdatesTool = (server: McpServer) => { server.registerTool( name, config, - async (_args, extra): Promise => { - const sessionId = extra?.sessionId; + async (_args, ctx): Promise => { + const sessionId = ctx?.sessionId; let response: string; if (clients.has(sessionId)) { diff --git a/src/everything/tools/trigger-elicitation-request-async.ts b/src/everything/tools/trigger-elicitation-request-async.ts deleted file mode 100644 index 9578007d9f..0000000000 --- a/src/everything/tools/trigger-elicitation-request-async.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; - -// Tool configuration -const name = "trigger-elicitation-request-async"; -const config = { - title: "Trigger Async Elicitation Request Tool", - description: - "Trigger an async elicitation request that the CLIENT executes as a background task. " + - "Demonstrates bidirectional MCP tasks where the server sends an elicitation request and " + - "the client handles user input asynchronously, allowing the server to poll for completion.", - inputSchema: {}, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, - }, -}; - -// Poll interval in milliseconds -const POLL_INTERVAL = 1000; - -// Time-to-live requested for the client's task (10 minutes for user input) -const TASK_TTL = 600000; - -// Stop polling this long before the TTL runs out, so the last tasks/get -// reaches the client while it still holds the task -const TTL_SAFETY_MARGIN = 5000; - -/** - * Registers the 'trigger-elicitation-request-async' tool. - * - * This tool demonstrates bidirectional MCP tasks for elicitation: - * - Server sends elicitation request to client with task metadata - * - Client creates a task and returns CreateTaskResult - * - Client prompts user for input (task status: input_required) - * - Server polls client's tasks/get endpoint for status - * - Server fetches final result from client's tasks/result endpoint - * - * @param {McpServer} server - The McpServer instance where the tool will be registered. - */ -export const registerTriggerElicitationRequestAsyncTool = ( - server: McpServer, -) => { - // Check client capabilities - const clientCapabilities = server.server.getClientCapabilities() || {}; - - // Client must support form-mode elicitation AND tasks.requests.elicitation. - // The request is form mode, so a URL-only client cannot answer it (#4985): - // form mode is `elicitation.form`, or `elicitation` with neither mode. - const elicitation = clientCapabilities.elicitation; - const clientSupportsElicitation = - elicitation !== undefined && - (elicitation.form !== undefined || elicitation.url === undefined); - const clientTasksCapability = clientCapabilities.tasks as - | { - requests?: { elicitation?: { create?: object } }; - } - | undefined; - const clientSupportsAsyncElicitation = - clientTasksCapability?.requests?.elicitation?.create !== undefined; - - if (clientSupportsElicitation && clientSupportsAsyncElicitation) { - server.registerTool( - name, - config, - async (args, extra): Promise => { - // Polling must end before the client may expire the task. The client - // starts the TTL when it creates the task, after this point, so a - // deadline measured from here is conservative. performance.now() is - // monotonic, so a wall-clock adjustment cannot stretch the deadline. - const pollDeadline = performance.now() + TASK_TTL - TTL_SAFETY_MARGIN; - - // Create the elicitation request WITH task metadata - // Using z.any() schema to avoid complex type matching with _meta - const request = { - method: "elicitation/create" as const, - params: { - task: { - ttl: TASK_TTL, // 10 minutes (user input may take a while) - }, - message: - "Please provide inputs for the following fields (async task demo):", - requestedSchema: { - type: "object" as const, - properties: { - name: { - title: "Your Name", - type: "string" as const, - description: "Your full name", - }, - favoriteColor: { - title: "Favorite Color", - type: "string" as const, - description: "What is your favorite color?", - enum: ["Red", "Blue", "Green", "Yellow", "Purple"], - }, - agreeToTerms: { - title: "Terms Agreement", - type: "boolean" as const, - description: "Do you agree to the terms and conditions?", - }, - }, - required: ["name"], - }, - }, - }; - - // Send the elicitation request - // Client may return either: - // - ElicitResult (synchronous execution) - // - CreateTaskResult (task-based execution with { task } object) - const elicitResponse = await extra.sendRequest( - request as Parameters[0], - z.union([ - // CreateTaskResult - client created a task - z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - pollInterval: z.number().optional(), - statusMessage: z.string().optional(), - }), - }), - // ElicitResult - synchronous execution - z.object({ - action: z.string(), - content: z.any().optional(), - }), - ]), - ); - - // Check if client returned CreateTaskResult (has task object) - const isTaskResult = "task" in elicitResponse && elicitResponse.task; - if (!isTaskResult) { - // Client executed synchronously - return the direct response - return { - content: [ - { - type: "text", - text: `[SYNC] Client executed synchronously:\n${JSON.stringify( - elicitResponse, - null, - 2, - )}`, - }, - ], - }; - } - - const taskId = elicitResponse.task.taskId; - const statusMessages: string[] = []; - statusMessages.push(`Task created: ${taskId}`); - - // Poll for task completion - let attempts = 0; - let taskStatus = elicitResponse.task.status; - // A task that is already finished when created is never polled, so - // its status message has to come from the CreateTaskResult - let taskStatusMessage = elicitResponse.task.statusMessage; - let timedOut = false; - - while ( - taskStatus !== "completed" && - taskStatus !== "failed" && - taskStatus !== "cancelled" - ) { - // Wait before polling - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); - - // Give up rather than poll past the TTL the task was created with. - // Checked after the wait, so a timer that fires late cannot slip a - // poll in after the deadline. - if (performance.now() > pollDeadline) { - timedOut = true; - break; - } - attempts++; - - // Get task status from client - const pollResult = await extra.sendRequest( - { - method: "tasks/get", - params: { taskId }, - }, - z.looseObject({ - status: z.string(), - statusMessage: z.string().optional(), - }), - ); - - taskStatus = pollResult.status; - taskStatusMessage = pollResult.statusMessage; - - // Only log status changes or every 10 polls to avoid spam - if ( - attempts === 1 || - attempts % 10 === 0 || - taskStatus !== "input_required" - ) { - statusMessages.push( - `Poll ${attempts}: ${taskStatus}${ - taskStatusMessage ? ` - ${taskStatusMessage}` : "" - }`, - ); - } - } - - // Check for timeout - if (timedOut) { - return { - content: [ - { - type: "text", - text: `[TIMEOUT] Task timed out after ${attempts} poll attempts, before its ${ - TASK_TTL / 60000 - }-minute TTL expired\n\nProgress:\n${statusMessages.join( - "\n", - )}`, - }, - ], - }; - } - - // Check for failure/cancellation - if (taskStatus === "failed" || taskStatus === "cancelled") { - return { - content: [ - { - type: "text", - text: `[${taskStatus.toUpperCase()}] ${ - taskStatusMessage || "No message" - }\n\nProgress:\n${statusMessages.join("\n")}`, - }, - ], - }; - } - - // Fetch the final result - const result = await extra.sendRequest( - { - method: "tasks/result", - params: { taskId }, - }, - z.any(), - ); - - // Format the elicitation result - const content: CallToolResult["content"] = []; - - if (result.action === "accept" && result.content) { - content.push({ - type: "text", - text: `[COMPLETED] User provided the requested information!`, - }); - - const userData = result.content as Record; - const lines = []; - if (userData.name) lines.push(`- Name: ${userData.name}`); - if (userData.favoriteColor) - lines.push(`- Favorite Color: ${userData.favoriteColor}`); - if (userData.agreeToTerms !== undefined) - lines.push(`- Agreed to terms: ${userData.agreeToTerms}`); - - content.push({ - type: "text", - text: `User inputs:\n${lines.join("\n")}`, - }); - } else if (result.action === "decline") { - content.push({ - type: "text", - text: `[DECLINED] User declined to provide the requested information.`, - }); - } else if (result.action === "cancel") { - content.push({ - type: "text", - text: `[CANCELLED] User cancelled the elicitation dialog.`, - }); - } - - // Include progress and raw result for debugging - content.push({ - type: "text", - text: `\nProgress:\n${statusMessages.join( - "\n", - )}\n\nRaw result: ${JSON.stringify(result, null, 2)}`, - }); - - return { content }; - }, - ); - } -}; diff --git a/src/everything/tools/trigger-elicitation-request.ts b/src/everything/tools/trigger-elicitation-request.ts index 23854db1b9..2df4500d93 100644 --- a/src/everything/tools/trigger-elicitation-request.ts +++ b/src/everything/tools/trigger-elicitation-request.ts @@ -1,8 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - ElicitResultSchema, - CallToolResult, -} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; // Tool configuration const name = "trigger-elicitation-request"; @@ -53,8 +49,8 @@ export const registerTriggerElicitationRequestTool = (server: McpServer) => { server.registerTool( name, config, - async (args, extra): Promise => { - const elicitationResult = await extra.sendRequest( + async (args, ctx): Promise => { + const elicitationResult = await ctx.mcpReq.send( { method: "elicitation/create", params: { @@ -179,7 +175,6 @@ export const registerTriggerElicitationRequestTool = (server: McpServer) => { }, }, }, - ElicitResultSchema, { timeout: 10 * 60 * 1000 /* 10 minutes */ }, ); diff --git a/src/everything/tools/trigger-long-running-operation.ts b/src/everything/tools/trigger-long-running-operation.ts index 5bbb8fc517..b1151d25c2 100644 --- a/src/everything/tools/trigger-long-running-operation.ts +++ b/src/everything/tools/trigger-long-running-operation.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; // Tool input schema const TriggerLongRunningOperationSchema = z.object({ @@ -43,11 +42,11 @@ export const registerTriggerLongRunningOperationTool = (server: McpServer) => { server.registerTool( name, config, - async (args, extra): Promise => { + async (args, ctx): Promise => { const validatedArgs = TriggerLongRunningOperationSchema.parse(args); const { duration, steps } = validatedArgs; const stepDuration = duration / steps; - const progressToken = extra._meta?.progressToken; + const progressToken = ctx.mcpReq._meta?.progressToken; for (let i = 1; i < steps + 1; i++) { await new Promise((resolve) => @@ -64,7 +63,7 @@ export const registerTriggerLongRunningOperationTool = (server: McpServer) => { progressToken, }, }, - { relatedRequestId: extra.requestId }, + { relatedRequestId: ctx.mcpReq.id }, ); } } diff --git a/src/everything/tools/trigger-sampling-request-async.ts b/src/everything/tools/trigger-sampling-request-async.ts deleted file mode 100644 index 4236328209..0000000000 --- a/src/everything/tools/trigger-sampling-request-async.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - CallToolResult, - CreateMessageRequest, -} from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; - -// Tool input schema -const TriggerSamplingRequestAsyncSchema = z.object({ - prompt: z.string().describe("The prompt to send to the LLM"), - maxTokens: z - .number() - .default(100) - .describe("Maximum number of tokens to generate"), -}); - -// Tool configuration -const name = "trigger-sampling-request-async"; -const config = { - title: "Trigger Async Sampling Request Tool", - description: - "Trigger an async sampling request that the CLIENT executes as a background task. " + - "Demonstrates bidirectional MCP tasks where the server sends a request and the client " + - "executes it asynchronously, allowing the server to poll for progress and results.", - inputSchema: TriggerSamplingRequestAsyncSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true, - }, -}; - -// Poll interval in milliseconds -const POLL_INTERVAL = 1000; - -// Maximum poll attempts before timeout -const MAX_POLL_ATTEMPTS = 60; - -/** - * Registers the 'trigger-sampling-request-async' tool. - * - * This tool demonstrates bidirectional MCP tasks: - * - Server sends sampling request to client with task metadata - * - Client creates a task and returns CreateTaskResult - * - Server polls client's tasks/get endpoint for status - * - Server fetches final result from client's tasks/result endpoint - * - * @param {McpServer} server - The McpServer instance where the tool will be registered. - */ -export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => { - // Check client capabilities - const clientCapabilities = server.server.getClientCapabilities() || {}; - - // Client must support sampling AND tasks.requests.sampling - const clientSupportsSampling = clientCapabilities.sampling !== undefined; - const clientTasksCapability = clientCapabilities.tasks as - | { - requests?: { sampling?: { createMessage?: object } }; - } - | undefined; - const clientSupportsAsyncSampling = - clientTasksCapability?.requests?.sampling?.createMessage !== undefined; - - if (clientSupportsSampling && clientSupportsAsyncSampling) { - server.registerTool( - name, - config, - async (args, extra): Promise => { - const validatedArgs = TriggerSamplingRequestAsyncSchema.parse(args); - const { prompt, maxTokens } = validatedArgs; - - // Create the sampling request WITH task metadata - // The params.task field signals to the client that this should be executed as a task - const request: CreateMessageRequest & { - params: { task?: { ttl: number } }; - } = { - method: "sampling/createMessage", - params: { - task: { - ttl: 300000, // 5 minutes - }, - messages: [ - { - role: "user", - content: { - type: "text", - text: `Resource ${name} context: ${prompt}`, - }, - }, - ], - systemPrompt: "You are a helpful test server.", - maxTokens, - temperature: 0.7, - }, - }; - - // Send the sampling request - // Client may return either: - // - CreateMessageResult (synchronous execution) - // - CreateTaskResult (task-based execution with { task } object) - const samplingResponse = await extra.sendRequest( - request, - z.union([ - // CreateTaskResult - client created a task - z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - pollInterval: z.number().optional(), - statusMessage: z.string().optional(), - }), - }), - // CreateMessageResult - synchronous execution - z.object({ - role: z.string(), - content: z.any(), - model: z.string(), - stopReason: z.string().optional(), - }), - ]), - ); - - // Check if client returned CreateTaskResult (has task object) - const isTaskResult = - "task" in samplingResponse && samplingResponse.task; - if (!isTaskResult) { - // Client executed synchronously - return the direct response - return { - content: [ - { - type: "text", - text: `[SYNC] Client executed synchronously:\n${JSON.stringify( - samplingResponse, - null, - 2, - )}`, - }, - ], - }; - } - - const taskId = samplingResponse.task.taskId; - const statusMessages: string[] = []; - statusMessages.push(`Task created: ${taskId}`); - - // Poll for task completion - let attempts = 0; - let taskStatus = samplingResponse.task.status; - // A task that is already finished when created is never polled, so - // its status message has to come from the CreateTaskResult - let taskStatusMessage = samplingResponse.task.statusMessage; - - while ( - taskStatus !== "completed" && - taskStatus !== "failed" && - taskStatus !== "cancelled" && - attempts < MAX_POLL_ATTEMPTS - ) { - // Wait before polling - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); - attempts++; - - // Get task status from client - const pollResult = await extra.sendRequest( - { - method: "tasks/get", - params: { taskId }, - }, - z.looseObject({ - status: z.string(), - statusMessage: z.string().optional(), - }), - ); - - taskStatus = pollResult.status; - taskStatusMessage = pollResult.statusMessage; - statusMessages.push( - `Poll ${attempts}: ${taskStatus}${ - taskStatusMessage ? ` - ${taskStatusMessage}` : "" - }`, - ); - } - - // Check for timeout - if (attempts >= MAX_POLL_ATTEMPTS) { - return { - content: [ - { - type: "text", - text: `[TIMEOUT] Task timed out after ${MAX_POLL_ATTEMPTS} poll attempts\n\nProgress:\n${statusMessages.join( - "\n", - )}`, - }, - ], - }; - } - - // Check for failure/cancellation - if (taskStatus === "failed" || taskStatus === "cancelled") { - return { - content: [ - { - type: "text", - text: `[${taskStatus.toUpperCase()}] ${ - taskStatusMessage || "No message" - }\n\nProgress:\n${statusMessages.join("\n")}`, - }, - ], - }; - } - - // Fetch the final result - const result = await extra.sendRequest( - { - method: "tasks/result", - params: { taskId }, - }, - z.any(), - ); - - // Return the result with status history - return { - content: [ - { - type: "text", - text: `[COMPLETED] Async sampling completed!\n\n**Progress:**\n${statusMessages.join( - "\n", - )}\n\n**Result:**\n${JSON.stringify(result, null, 2)}`, - }, - ], - }; - }, - ); - } -}; diff --git a/src/everything/tools/trigger-sampling-request.ts b/src/everything/tools/trigger-sampling-request.ts index e67a1ff0b7..28ed5fb4bd 100644 --- a/src/everything/tools/trigger-sampling-request.ts +++ b/src/everything/tools/trigger-sampling-request.ts @@ -1,9 +1,8 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { + McpServer, CallToolResult, CreateMessageRequest, - CreateMessageResultSchema, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/server"; import { z } from "zod"; // Tool input schema @@ -53,7 +52,7 @@ export const registerTriggerSamplingRequestTool = (server: McpServer) => { server.registerTool( name, config, - async (args, extra): Promise => { + async (args, ctx): Promise => { const validatedArgs = TriggerSamplingRequestSchema.parse(args); const { prompt, maxTokens } = validatedArgs; @@ -77,10 +76,7 @@ export const registerTriggerSamplingRequestTool = (server: McpServer) => { }; // Send the sampling request to the client - const result = await extra.sendRequest( - request, - CreateMessageResultSchema, - ); + const result = await ctx.mcpReq.send(request); // Return the result to the client return { diff --git a/src/everything/tools/trigger-url-elicitation.ts b/src/everything/tools/trigger-url-elicitation.ts index cc3c4cf789..9faa1e557d 100644 --- a/src/everything/tools/trigger-url-elicitation.ts +++ b/src/everything/tools/trigger-url-elicitation.ts @@ -1,11 +1,10 @@ import { randomUUID } from "node:crypto"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { + McpServer, CallToolResult, ElicitRequestURLParams, - ElicitResultSchema, UrlElicitationRequiredError, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/server"; import { z } from "zod"; // Tool input schema @@ -107,7 +106,7 @@ export const registerTriggerUrlElicitationTool = (server: McpServer) => { server.registerTool( name, config, - async (args, extra): Promise => { + async (args, ctx): Promise => { const { url, message, @@ -116,7 +115,7 @@ export const registerTriggerUrlElicitationTool = (server: McpServer) => { } = args; const elicitationId = requestedElicitationId ?? randomUUID(); - const sessionId = extra.sessionId ?? "default"; + const sessionId = ctx.sessionId ?? "default"; // Key the one-shot error-path marker on inputs the client resends // verbatim when it retries the original tool call. A real client retries @@ -170,12 +169,11 @@ export const registerTriggerUrlElicitationTool = (server: McpServer) => { } // Request path: send elicitation/create and await the user's response - const elicitationResult = await extra.sendRequest( + const elicitationResult = await ctx.mcpReq.send( { method: "elicitation/create", params: elicitationParams, }, - ElicitResultSchema, { timeout: 10 * 60 * 1000 /* 10 minutes */ }, ); diff --git a/src/everything/transports/in-memory-event-store.ts b/src/everything/transports/in-memory-event-store.ts index 88dec7b5be..80a270c58f 100644 --- a/src/everything/transports/in-memory-event-store.ts +++ b/src/everything/transports/in-memory-event-store.ts @@ -11,12 +11,12 @@ // tested directly. import { randomUUID } from "node:crypto"; -import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; import type { + JSONRPCMessage, EventId, EventStore, StreamId, -} from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +} from "@modelcontextprotocol/server"; export class InMemoryEventStore implements EventStore { private events: Map< diff --git a/src/everything/transports/sse.ts b/src/everything/transports/sse.ts index 1db484683a..a6a88761cb 100644 --- a/src/everything/transports/sse.ts +++ b/src/everything/transports/sse.ts @@ -5,7 +5,7 @@ // test can serve it on a free port and drive it with a real client, and each // app has its own session map. `startSseServer()` is what the launcher runs. -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { SSEServerTransport } from "@modelcontextprotocol/server-legacy/sse"; import express, { type Express, type Response } from "express"; import type { Server } from "node:http"; import { createServer } from "../server/index.js"; diff --git a/src/everything/transports/stdio.ts b/src/everything/transports/stdio.ts index 61a36d71d6..312a89c110 100644 --- a/src/everything/transports/stdio.ts +++ b/src/everything/transports/stdio.ts @@ -9,7 +9,7 @@ // launches the binary. import type { Readable, Writable } from "node:stream"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { createServer } from "../server/index.js"; /** What a started stdio server hands back: the server and its SIGINT handler. */ diff --git a/src/everything/transports/streamableHttp.ts b/src/everything/transports/streamableHttp.ts index 1057302a9e..9b475b3c20 100644 --- a/src/everything/transports/streamableHttp.ts +++ b/src/everything/transports/streamableHttp.ts @@ -7,8 +7,8 @@ // a real client, and call its shutdown handler directly; each app has its own // session map. `startStreamableHttpServer()` is what the launcher runs. -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js"; +import { isJSONRPCRequest } from "@modelcontextprotocol/server"; +import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; import express, { Express, Request, Response } from "express"; import type { Server } from "node:http"; import { createServer } from "../server/index.js"; @@ -84,9 +84,9 @@ export function createApp(): StreamableHttpApp { ); // Map sessionId to server transport for each client - const transports: Map = new Map< + const transports: Map = new Map< string, - StreamableHTTPServerTransport + NodeStreamableHTTPServerTransport >(); // Handle POST requests for client messages @@ -96,7 +96,7 @@ export function createApp(): StreamableHttpApp { // Check for existing session ID const sessionId = req.headers["mcp-session-id"] as string | undefined; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports.has(sessionId)) { // Reuse existing transport @@ -106,7 +106,7 @@ export function createApp(): StreamableHttpApp { // New initialization request const eventStore = new InMemoryEventStore(); - transport = new StreamableHTTPServerTransport({ + transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, // Enable resumability onsessioninitialized: (sessionId: string) => { diff --git a/src/fetch/README.md b/src/fetch/README.md index 65457d4501..295512bbbd 100644 --- a/src/fetch/README.md +++ b/src/fetch/README.md @@ -6,7 +6,7 @@ A Model Context Protocol server that provides web content fetching capabilities. Source: https://github.com/modelcontextprotocol/servers/tree/main/src/fetch -Requires MCP Python SDK 1.x (`mcp>=1.29.0,<2`). SDK 2.0 renamed APIs this server uses. The port to v2 is in progress. +Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). > [!CAUTION] > This server can access local/internal IP addresses and may represent a security risk. Exercise caution when using this MCP server to ensure this does not expose any sensitive data. diff --git a/src/fetch/pyproject.toml b/src/fetch/pyproject.toml index 3e6a4cf7a0..c629084d5a 100644 --- a/src/fetch/pyproject.toml +++ b/src/fetch/pyproject.toml @@ -18,7 +18,8 @@ classifiers = [ dependencies = [ "httpx[socks]>=0.27", "markdownify>=0.13.1", - "mcp>=1.29.0,<2", + "jsonschema>=4.20.0", + "mcp>=2.2,<3", "protego>=0.6.2", "pydantic>=2.0.0", "readabilipy>=0.2.0", diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index 94914f6b65..41824a2099 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -1,20 +1,40 @@ +# The fetch MCP server: one `fetch` tool and one `fetch` prompt that retrieve a +# URL and convert HTML to Markdown for a model. The network policy lives here +# beside the handlers because every request path (tool, prompt and robots.txt +# check) must go through it: the private-address guard on each request and +# redirect hop, robots.txt for autonomous fetches, and proxy normalization. +# +# Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no +# longer validates tool arguments or folds tool exceptions into `isError` +# results, so `call_tool` does both with SDK v1's messages, keeping tool +# results as they were on v1. `serve()` runs the legacy (2025-11-25) handshake +# loop only; serving 2026-07-28 is #4853. + import asyncio import ipaddress import os import socket -from typing import Annotated, Tuple +from typing import Annotated, Any, Tuple from importlib.metadata import version from urllib.parse import urlparse, urlunparse import httpx +import jsonschema import markdownify import readabilipy.simple_json -from mcp.shared.exceptions import McpError -from mcp.server import Server +from mcp.shared.exceptions import MCPError +from mcp.server import Server, ServerRequestContext +from mcp.server.runner import serve_loop from mcp.server.stdio import stdio_server from mcp.types import ( - ErrorData, + CallToolRequestParams, + CallToolResult, + ContentBlock, + GetPromptRequestParams, GetPromptResult, + ListPromptsResult, + ListToolsResult, + PaginatedRequestParams, Prompt, PromptArgument, PromptMessage, @@ -133,13 +153,11 @@ async def _refuse_private_destination(request: httpx.Request) -> None: return for address in addresses: if not _is_public_address(address): - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Refused to fetch {request.url}: {host} resolves to {address}, " - "which is not a public address. Start the server with " - "--allow-private-ips to allow private, loopback and link-local addresses.", - ) + raise MCPError( + code=INVALID_PARAMS, + message=f"Refused to fetch {request.url}: {host} resolves to {address}, " + "which is not a public address. Start the server with " + "--allow-private-ips to allow private, loopback and link-local addresses.", ) @@ -228,13 +246,11 @@ def make_client(proxy_url: str | None) -> httpx.AsyncClient: try: return httpx.AsyncClient(proxy=proxy_url) except (ValueError, ImportError) as e: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"Failed to set up the HTTP client, check the proxy " - f"configuration (--proxy-url or the HTTP_PROXY, HTTPS_PROXY and " - f"ALL_PROXY environment variables): {e}", - ) + raise MCPError( + code=INTERNAL_ERROR, + message=f"Failed to set up the HTTP client, check the proxy " + f"configuration (--proxy-url or the HTTP_PROXY, HTTPS_PROXY and " + f"ALL_PROXY environment variables): {e}", ) @@ -246,7 +262,7 @@ async def check_may_autonomously_fetch_url( ) -> None: """ Check if the URL can be fetched by the user agent according to the robots.txt file. - Raises a McpError if not. + Raises an MCPError if not. """ from httpx import HTTPError @@ -262,18 +278,14 @@ async def check_may_autonomously_fetch_url( headers={"User-Agent": user_agent}, ) except HTTPError: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue", - ) + raise MCPError( + code=INTERNAL_ERROR, + message=f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue", ) if response.status_code in (401, 403): - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"When fetching robots.txt ({robot_txt_url}), received status {response.status_code} so assuming that autonomous fetching is not allowed, the user can try manually fetching by using the fetch prompt", - ) + raise MCPError( + code=INTERNAL_ERROR, + message=f"When fetching robots.txt ({robot_txt_url}), received status {response.status_code} so assuming that autonomous fetching is not allowed, the user can try manually fetching by using the fetch prompt", ) elif 400 <= response.status_code < 500: return @@ -283,16 +295,14 @@ async def check_may_autonomously_fetch_url( ) robot_parser = Protego.parse(processed_robot_txt) if not robot_parser.can_fetch(str(url), user_agent): - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, " - f"{user_agent}\n" - f"{url}" - f"\n{robot_txt}\n\n" - f"The assistant must let the user know that it failed to view the page. The assistant may provide further guidance based on the above information.\n" - f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI.", - ) + raise MCPError( + code=INTERNAL_ERROR, + message=f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, " + f"{user_agent}\n" + f"{url}" + f"\n{robot_txt}\n\n" + f"The assistant must let the user know that it failed to view the page. The assistant may provide further guidance based on the above information.\n" + f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI.", ) @@ -319,15 +329,11 @@ async def fetch_url( timeout=30, ) except HTTPError as e: - raise McpError( - ErrorData(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}") - ) + raise MCPError(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}") if response.status_code >= 400: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"Failed to fetch {url} - status code {response.status_code}", - ) + raise MCPError( + code=INTERNAL_ERROR, + message=f"Failed to fetch {url} - status code {response.status_code}", ) page_raw = response.text @@ -379,6 +385,13 @@ class Fetch(BaseModel): ] +def tool_error(message: str) -> CallToolResult: + """A tool call that failed, as an `isError` result the model can read.""" + return CallToolResult( + content=[TextContent(type="text", text=message)], is_error=True + ) + + async def serve( custom_user_agent: str | None = None, ignore_robots_txt: bool = False, @@ -397,53 +410,54 @@ async def serve( if proxy_url: proxy_url = normalize_proxy_url(proxy_url) normalize_proxy_env() - server = Server("mcp-fetch", version=SERVER_VERSION) user_agent_autonomous = custom_user_agent or DEFAULT_USER_AGENT_AUTONOMOUS user_agent_manual = custom_user_agent or DEFAULT_USER_AGENT_MANUAL - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="fetch", - description="""Fetches a URL from the internet and optionally extracts its contents as markdown. + tool = Tool( + name="fetch", + description="""Fetches a URL from the internet and optionally extracts its contents as markdown. Although originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.""", - inputSchema=Fetch.model_json_schema(), - # Reads only, changes nothing, and repeating a request has no - # further side effect; it reaches arbitrary hosts on the - # internet (#3572). - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=True, - ), - ) - ] - - @server.list_prompts() - async def list_prompts() -> list[Prompt]: - return [ - Prompt( - name="fetch", - description="Fetch a URL and extract its contents as markdown", - arguments=[ - PromptArgument( - name="url", description="URL to fetch", required=True - ) - ], - ) - ] + input_schema=Fetch.model_json_schema(), + # Reads only, changes nothing, and repeating a request has no further + # side effect; it reaches arbitrary hosts on the internet (#3572). + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), + ) - @server.call_tool() - async def call_tool(name, arguments: dict) -> list[TextContent]: + async def list_tools( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListToolsResult: + return ListToolsResult(tools=[tool]) + + async def list_prompts( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListPromptsResult: + return ListPromptsResult( + prompts=[ + Prompt( + name="fetch", + description="Fetch a URL and extract its contents as markdown", + arguments=[ + PromptArgument( + name="url", description="URL to fetch", required=True + ) + ], + ) + ] + ) + + async def run_tool(name: str, arguments: dict[str, Any]) -> list[ContentBlock]: if name != "fetch": raise ValueError(f"Unknown tool: {name}") try: args = Fetch(**arguments) except ValueError as e: - raise McpError(ErrorData(code=INVALID_PARAMS, message=str(e))) + raise MCPError(code=INVALID_PARAMS, message=str(e)) url = str(args.url) @@ -478,14 +492,31 @@ async def call_tool(name, arguments: dict) -> list[TextContent]: content += f"\n\nContent truncated. Call the fetch tool with a start_index of {next_start} to get more content." return [TextContent(type="text", text=f"{prefix}Contents of {url}:\n{content}")] - @server.get_prompt() - async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult: + async def call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams + ) -> CallToolResult: + # SDK v2's low-level server neither validates arguments nor turns a + # handler exception into an isError result, both of which SDK v1 did. + # Both are done here, with v1's messages, so tool results are unchanged. + arguments = params.arguments or {} + if params.name == tool.name: + try: + jsonschema.validate(instance=arguments, schema=tool.input_schema) + except jsonschema.ValidationError as e: + return tool_error(f"Input validation error: {e.message}") + try: + return CallToolResult(content=await run_tool(params.name, arguments)) + except Exception as e: + return tool_error(str(e)) + + async def get_prompt( + ctx: ServerRequestContext, params: GetPromptRequestParams + ) -> GetPromptResult: + name, arguments = params.name, params.arguments if name != "fetch": - raise McpError( - ErrorData(code=INVALID_PARAMS, message=f"Unknown prompt: {name}") - ) + raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}") if not arguments or "url" not in arguments: - raise McpError(ErrorData(code=INVALID_PARAMS, message="URL is required")) + raise MCPError(code=INVALID_PARAMS, message="URL is required") url = arguments["url"] @@ -497,7 +528,7 @@ async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult: allow_private_ips=allow_private_ips, ) # TODO: after SDK bug is addressed, don't catch the exception - except McpError as e: + except MCPError as e: return GetPromptResult( description=f"Failed to fetch {url}", messages=[ @@ -516,6 +547,26 @@ async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult: ], ) + server = Server( + "mcp-fetch", + version=SERVER_VERSION, + on_list_tools=list_tools, + on_list_prompts=list_prompts, + on_call_tool=call_tool, + on_get_prompt=get_prompt, + ) + options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, options, raise_exceptions=False) + # Legacy era only. Server.run() would also serve 2026-07-28 (its + # dual-era loop answers server/discover and per-request envelopes); + # adopting that era is #4853, so this port (#4851) serves the + # handshake loop alone, the only era it served on SDK v1. + async with server.lifespan(server) as lifespan_state: + await serve_loop( + server, + read_stream, + write_stream, + lifespan_state=lifespan_state, + init_options=options, + ) diff --git a/src/fetch/tests/harness.py b/src/fetch/tests/harness.py index 6c521769b8..650faa34d1 100644 --- a/src/fetch/tests/harness.py +++ b/src/fetch/tests/harness.py @@ -4,7 +4,7 @@ - ``connect(...)`` runs the real ``serve()`` in-process and yields an initialized ``ClientSession`` linked to it over in-memory streams, so tests - go through the SDK's validation and error mapping exactly as a client would. + go through the server's validation and error mapping exactly as a client would. - ``web`` replaces ``httpx.AsyncClient`` with one backed by an ``httpx.MockTransport``, so no test touches the network. It records every request and the keyword arguments each client was constructed with. It also diff --git a/src/fetch/tests/test_protocol.py b/src/fetch/tests/test_protocol.py index 01269c7002..325e546301 100644 --- a/src/fetch/tests/test_protocol.py +++ b/src/fetch/tests/test_protocol.py @@ -16,18 +16,25 @@ from __future__ import annotations import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from importlib.metadata import version from typing import Any +from unittest.mock import patch +import anyio import httpx import pytest from mcp import ClientSession -from mcp.shared.exceptions import McpError -from mcp.types import INVALID_PARAMS, CallToolResult +from mcp.shared.exceptions import MCPError +from mcp.shared.memory import create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp.types import INVALID_PARAMS, METHOD_NOT_FOUND, CallToolResult, JSONRPCRequest from mcp_server_fetch.server import ( DEFAULT_USER_AGENT_AUTONOMOUS, DEFAULT_USER_AGENT_MANUAL, + serve, ) from .harness import FakeWeb, connect @@ -52,7 +59,12 @@ def wire(model: Any) -> dict[str, Any]: - return model.model_dump(by_alias=True, mode="json", exclude_none=True) + # exclude_unset: SDK v2 result models default fields the 2025-11-25 wire + # does not carry (resultType, ttlMs, cacheScope), so only what the server + # actually sent is compared. + return model.model_dump( + by_alias=True, mode="json", exclude_none=True, exclude_unset=True + ) def text_of(result: CallToolResult) -> str: @@ -197,10 +209,10 @@ async def test_call_with_only_url_applies_defaults(web: FakeWeb) -> None: ({"url": PAGE, "raw": "yes"}, "'yes'"), ], ) -async def test_schema_violations_are_rejected_by_the_sdk( +async def test_schema_violations_are_rejected_by_call_tool( web: FakeWeb, arguments: dict[str, Any], offending: str ) -> None: - # The SDK validates against inputSchema before the handler runs. The rest + # call_tool validates against inputSchema before the tool runs. The rest # of the message is jsonschema's wording, which varies by version. async with connect() as (session, _): result = await call(session, arguments) @@ -213,7 +225,7 @@ async def test_schema_violations_are_rejected_by_the_sdk( @pytest.mark.parametrize("max_length", [1, 999999]) async def test_max_length_bounds_are_inclusive(web: FakeWeb, max_length: int) -> None: - # #1624: the edges of the inclusive range pass both the SDK's schema check + # #1624: the edges of the inclusive range pass both the schema check # and Fetch's own validation, and the page is fetched. web.add(ROBOTS, plain("", status=404)) web.add(PAGE, plain("hello")) @@ -237,8 +249,8 @@ async def test_invalid_url_is_rejected_by_pydantic(web: FakeWeb, url: str) -> No async def test_unknown_tool_name_is_rejected_without_fetching(web: FakeWeb) -> None: - # The SDK skips schema validation for a tool it has not listed, so the - # name check in call_tool is what stops an unknown name from fetching. + # call_tool skips schema validation for a tool it has not listed, so the + # name check in run_tool is what stops an unknown name from fetching. web.add(ROBOTS, plain("", status=404)) web.add(PAGE, plain("fetched anyway")) async with connect() as (session, _): @@ -956,7 +968,7 @@ async def test_get_prompt_without_url_is_a_jsonrpc_error( web: FakeWeb, arguments: dict[str, str] | None ) -> None: async with connect() as (session, _): - with pytest.raises(McpError) as excinfo: + with pytest.raises(MCPError) as excinfo: await session.get_prompt("fetch", arguments) assert wire(excinfo.value.error) == { "code": INVALID_PARAMS, @@ -973,7 +985,7 @@ async def test_get_prompt_with_unknown_name_is_a_jsonrpc_error( # whether or not a URL was given, and nothing is fetched. web.add(PAGE, plain("ok")) async with connect() as (session, _): - with pytest.raises(McpError) as excinfo: + with pytest.raises(MCPError) as excinfo: await session.get_prompt("nope", arguments) assert wire(excinfo.value.error) == { "code": INVALID_PARAMS, @@ -1000,3 +1012,60 @@ async def test_get_prompt_does_not_validate_the_url( assert data["messages"][0]["content"]["text"].startswith( "Failed to fetch not a url: UnsupportedProtocol(" ) + + +# --------------------------------------------------------------------------- +# Protocol era +# --------------------------------------------------------------------------- + + +async def test_modern_era_is_not_served() -> None: + # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. + # Server.run() would also open a 2026-07-28 connection for a request that + # carries the per-request `_meta` envelope; adopting that era is #4853. + # Until then such a request is refused as an unknown method. + # + # Behavior change in the SDK v2 port: SDK v1 answered every unknown + # method with -32602 "Invalid request parameters" (its request union + # failed to parse); SDK v2 answers -32601 "Method not found", the code + # JSON-RPC defines for it. + discover = JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="server/discover", + params={ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + }, + ) + response: SessionMessage | Exception | None = None + async with create_client_server_memory_streams() as ( + (client_read, client_write), + server_streams, + ): + + @asynccontextmanager + async def fake_stdio_server() -> AsyncIterator[Any]: + yield server_streams + + with patch("mcp_server_fetch.server.stdio_server", fake_stdio_server): + async with anyio.create_task_group() as tg: + tg.start_soon(serve) + await client_write.send(SessionMessage(discover)) + with anyio.fail_after(5): + response = await client_read.receive() + tg.cancel_scope.cancel() + + assert isinstance(response, SessionMessage) + assert response.message.model_dump(by_alias=True, exclude_none=True) == { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": METHOD_NOT_FOUND, + "message": "Method not found", + "data": "server/discover", + }, + } diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index 9247e4e2a8..771fcb39f4 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -4,8 +4,9 @@ requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", + "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] [[package]] @@ -481,6 +482,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + [[package]] name = "httpx" version = "0.27.2" @@ -503,12 +517,29 @@ socks = [ ] [[package]] -name = "httpx-sse" -version = "0.4.0" +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -689,15 +720,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.29.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -707,9 +738,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, ] [[package]] @@ -718,6 +749,7 @@ version = "2026.10.10" source = { editable = "." } dependencies = [ { name = "httpx", extra = ["socks"] }, + { name = "jsonschema" }, { name = "markdownify" }, { name = "mcp" }, { name = "protego" }, @@ -738,8 +770,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", extras = ["socks"], specifier = ">=0.27" }, + { name = "jsonschema", specifier = ">=4.20.0" }, { name = "markdownify", specifier = ">=0.13.1" }, - { name = "mcp", specifier = ">=1.29.0,<2" }, + { name = "mcp", specifier = ">=2.2,<3" }, { name = "protego", specifier = ">=0.6.2" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "readabilipy", specifier = ">=0.2.0" }, @@ -755,6 +788,19 @@ dev = [ { name = "ruff", specifier = ">=0.7.3" }, ] +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -764,6 +810,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -933,19 +991,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b5/d4/9dfbe238f45ad8b168f5c96ee49a3df0598ce18a0795a983b419949ce65b/pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0", size = 75646, upload-time = "2024-11-01T11:00:05.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f9/ff95fd7d760af42f647ea87f9b8a383d891cdb5e5dbd4613edaeb094252a/pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87", size = 28595, upload-time = "2024-11-01T11:00:02.64Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -1031,15 +1076,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-multipart" version = "0.0.32" @@ -1369,16 +1405,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "2.1.3" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, - { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678, upload-time = "2024-08-01T08:52:50.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383, upload-time = "2024-08-01T08:52:48.659Z" }, + { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, ] [[package]] @@ -1448,6 +1483,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 0e79522e00..bcedb5d0a9 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -218,6 +218,10 @@ The mapping for filesystem tools is: > Note: `idempotentHint` and `destructiveHint` are meaningful only when `readOnlyHint` is `false`, as defined by the MCP spec. Every tool also sets `openWorldHint: false` — this server only accesses the local filesystem within its allowed directories, never an open or external world. +## Requirements + +Node.js 20 or later, when the server runs through `npx` or from source. + ## Usage with Claude Desktop Add this to your `claude_desktop_config.json`: diff --git a/src/filesystem/__tests__/bin-smoke.test.ts b/src/filesystem/__tests__/bin-smoke.test.ts index c16059188c..520cc0cc21 100644 --- a/src/filesystem/__tests__/bin-smoke.test.ts +++ b/src/filesystem/__tests__/bin-smoke.test.ts @@ -10,8 +10,8 @@ import { createRequire } from "node:module"; import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Client } from "@modelcontextprotocol/client"; import { makeTempDir } from "./helpers.js"; const packageRoot = path.join(import.meta.dirname, ".."); diff --git a/src/filesystem/__tests__/helpers.ts b/src/filesystem/__tests__/helpers.ts index f251c1a426..5e7d6afff2 100644 --- a/src/filesystem/__tests__/helpers.ts +++ b/src/filesystem/__tests__/helpers.ts @@ -8,14 +8,12 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; import { vi } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { - ListRootsRequestSchema, - type CallToolResult, - type ClientCapabilities, - type Root, -} from "@modelcontextprotocol/sdk/types.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { + CallToolResult, + ClientCapabilities, + Root, +} from "@modelcontextprotocol/client"; import { createServer } from "../server.js"; export interface Connected { @@ -42,7 +40,7 @@ export async function connect( ); const { listRoots } = options; if (options.capabilities?.roots && listRoots) { - client.setRequestHandler(ListRootsRequestSchema, async () => ({ + client.setRequestHandler("roots/list", async () => ({ roots: await listRoots(), })); } diff --git a/src/filesystem/__tests__/server-paths.test.ts b/src/filesystem/__tests__/server-paths.test.ts index 62fa077a4d..d6a8995317 100644 --- a/src/filesystem/__tests__/server-paths.test.ts +++ b/src/filesystem/__tests__/server-paths.test.ts @@ -8,7 +8,7 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/client"; import { call, connect, diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index c9944e0f5a..30cddff36a 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -10,7 +10,7 @@ import fs from "fs/promises"; import path from "path"; import { pathToFileURL } from "url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Root } from "@modelcontextprotocol/sdk/types.js"; +import type { Root } from "@modelcontextprotocol/server"; import { allowedDirectoriesOf, call, @@ -153,7 +153,7 @@ describe("initial roots", () => { await vi.waitFor(() => expect(stderr).toHaveBeenCalledWith( "Failed to request initial roots from client:", - "MCP error -32603: client has no roots today", + "client has no roots today", ), ); expect(await allowedDirectoriesOf(client)).toEqual([cliDir]); @@ -486,7 +486,7 @@ describe("roots/list_changed", () => { await vi.waitFor(() => expect(stderr).toHaveBeenCalledWith( "Failed to request roots from client:", - "MCP error -32603: gone", + "gone", ), ); @@ -720,7 +720,7 @@ describe("roots/list_changed", () => { await vi.waitFor(() => expect(stderr).toHaveBeenCalledWith( "Failed to request roots from client:", - "MCP error -32603: gone", + "gone", ), ); expect(await allowedDirectoriesOf(client)).toEqual([rootDir]); diff --git a/src/filesystem/__tests__/server-tools.test.ts b/src/filesystem/__tests__/server-tools.test.ts index ff65d2f53b..9be4855672 100644 --- a/src/filesystem/__tests__/server-tools.test.ts +++ b/src/filesystem/__tests__/server-tools.test.ts @@ -1,7 +1,7 @@ // Characterization suite for every filesystem tool, driven through an SDK // Client over an in-memory transport (#4854). It pins what the server does -// today on SDK 1.x, quirks included, so the SDK v2 migration (#4856) can prove -// it changed nothing on the wire. A test that pins a known bug says so, with +// today, quirks included. It was written on SDK 1.x so the SDK v2 migration +// (#4856) could show what changed on the wire. A test that pins a known bug says so, with // the issue number, so the fix has a test to change. import fs from "fs/promises"; @@ -9,7 +9,7 @@ import path from "path"; import { fileURLToPath } from "url"; import { createRequire } from "node:module"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/client"; import { allowedDirectoriesOf, call, @@ -149,9 +149,9 @@ describe("server identity and tool list", () => { }); }); - // #4841: the SDK 1.x tools/list handler stamps draft-07 on every schema it - // renders, which validators that accept only JSON Schema 2020-12 reject. The - // server declares 2020-12 instead. + // #4841: the SDK 1.x tools/list handler stamped draft-07 on every schema it + // rendered, which validators that accept only JSON Schema 2020-12 reject. The + // server declares 2020-12 instead, as SDK v2 now does by default. it("declares the 2020-12 $schema on every input and output schema (#4841)", async () => { const { tools } = await client.listTools(); expect(tools).toHaveLength(TOOL_NAMES.length); @@ -307,19 +307,18 @@ describe("server identity and tool list", () => { } }); - it("reports an unknown tool as a tool error", async () => { - const result = await call(client, "no_such_tool"); - expect(result.isError).toBe(true); - expect(textOf(result)).toBe( - "MCP error -32602: Tool no_such_tool not found", - ); + it("rejects an unknown tool with -32602", async () => { + await expect(call(client, "no_such_tool")).rejects.toMatchObject({ + code: -32602, + message: "Tool no_such_tool not found", + }); }); it("reports schema-invalid arguments as a tool error", async () => { const result = await call(client, "read_text_file", { path: 42 }); expect(result.isError).toBe(true); expect(textOf(result)).toMatch( - /^MCP error -32602: Input validation error: Invalid arguments for tool read_text_file/, + /^Input validation error: Invalid arguments for tool read_text_file/, ); }); }); diff --git a/src/filesystem/__tests__/stdio.test.ts b/src/filesystem/__tests__/stdio.test.ts index e55b2595d3..9171adcf20 100644 --- a/src/filesystem/__tests__/stdio.test.ts +++ b/src/filesystem/__tests__/stdio.test.ts @@ -3,7 +3,7 @@ // usage text, the exit on no usable directory) and the real // StdioServerTransport over in-memory streams, writing raw JSON-RPC lines the // way a client process would. Absorbs the former startup-validation.test.ts, -// which spawned the build. Pins #4206, #4207 and #4195 as they stand on SDK 1.x. +// which spawned the build. Pins #4206, #4207 and #4195 as they stand today. import fs from "fs/promises"; import os from "os"; @@ -11,7 +11,7 @@ import path from "path"; import { PassThrough } from "node:stream"; import { pathToFileURL } from "url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServer } from "@modelcontextprotocol/server"; import { isEntryPoint, main, resolveAllowedDirectories } from "../index.js"; import { makeTempDir, quietStderr } from "./helpers.js"; @@ -203,7 +203,7 @@ describe("startup", () => { }); describe("the stdio transport", () => { - // #4195: SDK 1.x does not enforce the lifecycle, so a request before + // #4195: the SDK does not enforce the lifecycle, so a request before // initialize is answered normally. it("answers tools/list before initialize (#4195)", async () => { await session.start([dir]); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 0a8edced6a..c112d8f23e 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -12,8 +12,8 @@ import { realpathSync } from "fs"; import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import type { McpServer } from "@modelcontextprotocol/server"; import { normalizePath, expandHome } from "./path-utils.js"; import { createServer } from "./server.js"; diff --git a/src/filesystem/package.json b/src/filesystem/package.json index fa237a9a6f..8adbd53b22 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -12,6 +12,9 @@ "url": "https://github.com/modelcontextprotocol/servers.git" }, "type": "module", + "engines": { + "node": ">=20" + }, "bin": { "mcp-server-filesystem": "dist/index.js" }, @@ -31,13 +34,14 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.31.0", + "@modelcontextprotocol/server": "^2.3.1", "diff": "^8.0.3", "glob": "^13.0.6", "minimatch": "^10.0.1", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/diff": "^5.0.9", "@types/minimatch": "^5.1.2", "@types/node": "^22", diff --git a/src/filesystem/roots-utils.ts b/src/filesystem/roots-utils.ts index eda0796620..e70da4fa01 100644 --- a/src/filesystem/roots-utils.ts +++ b/src/filesystem/roots-utils.ts @@ -2,7 +2,7 @@ import { promises as fs, type Stats } from "fs"; import path from "path"; import os from "os"; import { normalizePath } from "./path-utils.js"; -import type { Root } from "@modelcontextprotocol/sdk/types.js"; +import type { Root } from "@modelcontextprotocol/server"; import { fileURLToPath } from "url"; /** diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index 2a47435d63..bd0fae38b2 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -4,12 +4,8 @@ // lets the tests drive a real Client against it in-process over an in-memory // transport (#4854), and holding the allow-list in this closure rather than in // a module global lets two instances coexist in one process. - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - RootsListChangedNotificationSchema, - type Root, -} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import type { Root } from "@modelcontextprotocol/server"; import fs from "fs/promises"; import { createReadStream } from "fs"; import path from "path"; @@ -32,10 +28,11 @@ import { import { SERVER_VERSION } from "./version.js"; // The JSON Schema dialect every advertised tool schema declares (#4841). The -// SDK 1.x tools/list handler renders zod schemas with zod's draft-07 target and -// stamps "$schema": draft-07 on each one, which validators that accept only +// SDK 1.x tools/list handler rendered zod schemas with zod's draft-07 target and +// stamped "$schema": draft-07 on each one, which validators that accept only // 2020-12 (the dialect MCP assumes for tool schemas) reject outright. Setting -// $schema in the root object's zod metadata overrides that stamp. The schemas +// $schema in the root object's zod metadata overrode that stamp. SDK v2 stamps +// 2020-12 itself, so the label is now the SDK's default as well. The schemas // below use nothing whose draft-07 and 2020-12 renderings differ (no tuples), // so the label is the only change; server-tools.test.ts fails if a draft-07-only // keyword ever appears under it. @@ -816,7 +813,7 @@ export function createServer( // Handles dynamic roots updates during runtime, when client sends "roots/list_changed" notification, server fetches the updated roots and replaces all allowed directories with the new roots. server.server.setNotificationHandler( - RootsListChangedNotificationSchema, + "notifications/roots/list_changed", async () => { const generation = ++rootsGeneration; const settle = installBarrier(); diff --git a/src/git/README.md b/src/git/README.md index 377cdac3eb..45e96b8121 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -8,7 +8,7 @@ A Model Context Protocol server for Git repository interaction and automation. T Source: https://github.com/modelcontextprotocol/servers/tree/main/src/git -Requires MCP Python SDK 1.x (`mcp>=1.29.0,<2`). SDK 2.0 renamed APIs this server uses. The port to v2 is in progress. +Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). Please note that mcp-server-git is currently in early development. The functionality and available tools are subject to change and expansion as we continue to develop and improve the server. diff --git a/src/git/pyproject.toml b/src/git/pyproject.toml index f73e4d2daf..7036291e80 100644 --- a/src/git/pyproject.toml +++ b/src/git/pyproject.toml @@ -18,7 +18,8 @@ classifiers = [ dependencies = [ "click>=8.1.7", "gitpython>=3.1.60", - "mcp>=1.29.0,<2", + "jsonschema>=4.20.0", + "mcp>=2.2,<3", "pydantic>=2.0.0", ] diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 626e79b48c..90262e8b55 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -1,10 +1,28 @@ +# The git MCP server: tools that read and change a Git repository through +# GitPython. Repository confinement (`--repository`, `validate_repo_path`) and +# the flag-injection guards live beside the handlers because every tool call +# must pass them before any git command runs. +# +# Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no +# longer validates tool arguments or folds tool exceptions into `isError` +# results, so `call_tool` does both with SDK v1's messages, keeping tool +# results as they were on v1. `serve()` runs the legacy (2025-11-25) handshake +# loop only; serving 2026-07-28 is #4853. + import logging from importlib.metadata import version from pathlib import Path from typing import Any, Optional -from mcp.server import Server +import jsonschema +from mcp.server import Server, ServerRequestContext +from mcp.server.runner import serve_loop from mcp.server.stdio import stdio_server from mcp.types import ( + CallToolRequestParams, + CallToolResult, + ContentBlock, + ListToolsResult, + PaginatedRequestParams, TextContent, Tool, ToolAnnotations, @@ -465,6 +483,13 @@ def git_branch( return branch_info +def tool_error(message: str) -> CallToolResult: + """A tool call that failed, as an `isError` result the model can read.""" + return CallToolResult( + content=[TextContent(type="text", text=message)], is_error=True + ) + + async def serve(repository: Path | None) -> None: logger = logging.getLogger(__name__) @@ -486,147 +511,148 @@ async def serve(repository: Path | None) -> None: repository = root logger.info(f"Using repository at {repository}") - server = Server("mcp-git", version=SERVER_VERSION) - - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name=GitTools.STATUS, - description="Shows the working tree status", - inputSchema=GitStatus.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + tools = [ + Tool( + name=GitTools.STATUS, + description="Shows the working tree status", + input_schema=GitStatus.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.DIFF_UNSTAGED, - description="Shows changes in the working directory that are not yet staged", - inputSchema=GitDiffUnstaged.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.DIFF_UNSTAGED, + description="Shows changes in the working directory that are not yet staged", + input_schema=GitDiffUnstaged.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.DIFF_STAGED, - description="Shows changes that are staged for commit", - inputSchema=GitDiffStaged.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.DIFF_STAGED, + description="Shows changes that are staged for commit", + input_schema=GitDiffStaged.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.DIFF, - description="Shows differences between branches or commits", - inputSchema=GitDiff.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.DIFF, + description="Shows differences between branches or commits", + input_schema=GitDiff.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.COMMIT, - description="Records changes to the repository", - inputSchema=GitCommit.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=False, - destructiveHint=False, - idempotentHint=False, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.COMMIT, + description="Records changes to the repository", + input_schema=GitCommit.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=False, ), - Tool( - name=GitTools.ADD, - description="Adds file contents to the staging area", - inputSchema=GitAdd.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=False, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.ADD, + description="Adds file contents to the staging area", + input_schema=GitAdd.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.RESET, - description="Unstages all staged changes", - inputSchema=GitReset.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=False, - destructiveHint=True, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.RESET, + description="Unstages all staged changes", + input_schema=GitReset.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=True, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.LOG, - description="Shows the commit logs", - inputSchema=GitLog.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.LOG, + description="Shows the commit logs", + input_schema=GitLog.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.CREATE_BRANCH, - description="Creates a new branch from an optional base branch", - inputSchema=GitCreateBranch.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=False, - destructiveHint=False, - idempotentHint=False, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.CREATE_BRANCH, + description="Creates a new branch from an optional base branch", + input_schema=GitCreateBranch.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=False, ), - Tool( - name=GitTools.CHECKOUT, - description="Switches branches", - inputSchema=GitCheckout.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=False, - destructiveHint=False, - idempotentHint=False, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.CHECKOUT, + description="Switches branches", + input_schema=GitCheckout.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=False, ), - Tool( - name=GitTools.SHOW, - description="Shows the contents of a commit, or of a file or directory given as :", - inputSchema=GitShow.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.SHOW, + description="Shows the contents of a commit, or of a file or directory given as :", + input_schema=GitShow.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.BRANCH, - description="List Git branches", - inputSchema=GitBranch.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.BRANCH, + description="List Git branches", + input_schema=GitBranch.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - ] + ), + ] + schemas = {tool.name: tool.input_schema for tool in tools} - @server.call_tool() - async def call_tool(name: str, arguments: dict) -> list[TextContent]: + async def list_tools( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListToolsResult: + return ListToolsResult(tools=tools) + + async def run_tool(name: str, arguments: dict[str, Any]) -> list[ContentBlock]: # Reject an unknown tool before reading its arguments, so a call # without repo_path still reports "Unknown tool" (#4994). if name not in {tool.value for tool in GitTools}: @@ -720,6 +746,41 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: case _: # pragma: no cover raise ValueError(f"Unknown tool: {name}") + async def call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams + ) -> CallToolResult: + # SDK v2's low-level server neither validates arguments nor turns a + # handler exception into an isError result, both of which SDK v1 did. + # Both are done here, with v1's messages, so tool results are unchanged. + arguments = params.arguments or {} + schema = schemas.get(params.name) + if schema is not None: + try: + jsonschema.validate(instance=arguments, schema=schema) + except jsonschema.ValidationError as e: + return tool_error(f"Input validation error: {e.message}") + try: + return CallToolResult(content=await run_tool(params.name, arguments)) + except Exception as e: + return tool_error(str(e)) + + server = Server( + "mcp-git", + version=SERVER_VERSION, + on_list_tools=list_tools, + on_call_tool=call_tool, + ) options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, options) + # Legacy era only. Server.run() would also serve 2026-07-28 (its + # dual-era loop answers server/discover and per-request envelopes); + # adopting that era is #4853, so this port (#4851) serves the + # handshake loop alone, the only era it served on SDK v1. + async with server.lifespan(server) as lifespan_state: + await serve_loop( + server, + read_stream, + write_stream, + lifespan_state=lifespan_state, + init_options=options, + ) diff --git a/src/git/tests/conftest.py b/src/git/tests/conftest.py index 8b8f984bd1..b68086bdc0 100644 --- a/src/git/tests/conftest.py +++ b/src/git/tests/conftest.py @@ -130,7 +130,12 @@ async def fake_stdio_server() -> AsyncIterator[Any]: def wire(model: BaseModel) -> dict[str, Any]: """A result as it travels on the wire, independent of SDK attribute names.""" - return model.model_dump(by_alias=True, mode="json", exclude_none=True) + # exclude_unset: SDK v2 result models default fields the 2025-11-25 wire + # does not carry (resultType, ttlMs, cacheScope), so only what the server + # actually sent is compared. + return model.model_dump( + by_alias=True, mode="json", exclude_none=True, exclude_unset=True + ) def text_result(text: str, *, is_error: bool = False) -> dict[str, Any]: diff --git a/src/git/tests/test_entrypoints.py b/src/git/tests/test_entrypoints.py index 9c765beeb9..8b99cc4078 100644 --- a/src/git/tests/test_entrypoints.py +++ b/src/git/tests/test_entrypoints.py @@ -51,7 +51,7 @@ async def test_serve_resolves_repository_subdirectory_to_root( with caplog.at_level(logging.INFO, logger="mcp_server_git.server"): async with connect(sub) as session: result = await session.call_tool("git_status", {"repo_path": str(root)}) - assert not result.isError + assert not result.is_error assert f"Resolved --repository {sub} to repository root {root}" in caplog.text assert f"Using repository at {root}" in caplog.text diff --git a/src/git/tests/test_protocol.py b/src/git/tests/test_protocol.py index efc95fb895..c9b8e5b3e6 100644 --- a/src/git/tests/test_protocol.py +++ b/src/git/tests/test_protocol.py @@ -10,17 +10,25 @@ import json import re +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from importlib.metadata import version from pathlib import Path from typing import Any from unittest import mock +from unittest.mock import patch +import anyio import git import pytest -from mcp.shared.context import RequestContext -from mcp.types import ListRootsResult, Root +from mcp.client.session import ClientRequestContext +from mcp.shared.memory import create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp.types import METHOD_NOT_FOUND, JSONRPCRequest, ListRootsResult, Root from pydantic import FileUrl +from mcp_server_git.server import serve + from conftest import connect, make_repo, text_result, wire SNAPSHOTS = Path(__file__).parent / "snapshots" @@ -63,10 +71,9 @@ async def test_initialize_reports_package_version_as_server_version(repo: git.Re "version": version("mcp-server-git"), } assert version("mcp") != version("mcp-server-git") - assert init["capabilities"] == { - "experimental": {}, - "tools": {"listChanged": False}, - } + # Behavior change in the SDK v2 port (#4851): v2 omits the empty + # `experimental: {}` object v1 always sent. It advertised nothing. + assert init["capabilities"] == {"tools": {"listChanged": False}} async def test_list_tools_matches_wire_snapshot(): @@ -381,7 +388,7 @@ async def test_git_add_file_starting_with_dash_is_a_path(repo: git.Repo): async def test_git_add_empty_list_is_rejected(repo: git.Repo): # #4763: `files: []` would run `git add --`, a no-op. The schema's - # minItems makes the SDK reject it before the tool runs. + # minItems makes call_tool reject it before the tool runs. root = root_of(repo) (root / "test.txt").write_text("edited\n", newline="\n") result = await call(None, "git_add", {"repo_path": str(root), "files": []}) @@ -1114,6 +1121,9 @@ def resolve(self: Path, strict: bool = False) -> Path: # -------------------------------------------------------------------------- +# The client side of this test offers Roots, which SDK v2 deprecates as of +# 2026-07-28 (SEP-2577); the warning is about the test's client, not the server. +@pytest.mark.filterwarnings("ignore::mcp.shared.exceptions.MCPDeprecationWarning") async def test_server_never_requests_roots(tmp_path: Path, repo: git.Repo): # The server ignores Roots entirely: it never sends roots/list, not even # after notifications/roots/list_changed, and a repository outside every @@ -1123,7 +1133,7 @@ async def test_server_never_requests_roots(tmp_path: Path, repo: git.Repo): elsewhere.mkdir() calls: list[object] = [] - async def list_roots(context: RequestContext[Any, Any]) -> ListRootsResult: + async def list_roots(context: ClientRequestContext) -> ListRootsResult: calls.append(context) return ListRootsResult(roots=[Root(uri=FileUrl(elsewhere.as_uri()))]) @@ -1144,3 +1154,60 @@ async def test_server_without_client_roots_behaves_the_same(repo: git.Repo): await session.call_tool("git_status", {"repo_path": str(root_of(repo))}) ) assert result["isError"] is False + + +# --------------------------------------------------------------------------- +# Protocol era +# --------------------------------------------------------------------------- + + +async def test_modern_era_is_not_served() -> None: + # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. + # Server.run() would also open a 2026-07-28 connection for a request that + # carries the per-request `_meta` envelope; adopting that era is #4853. + # Until then such a request is refused as an unknown method. + # + # Behavior change in the SDK v2 port: SDK v1 answered every unknown + # method with -32602 "Invalid request parameters" (its request union + # failed to parse); SDK v2 answers -32601 "Method not found", the code + # JSON-RPC defines for it. + discover = JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="server/discover", + params={ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + }, + ) + response: SessionMessage | Exception | None = None + async with create_client_server_memory_streams() as ( + (client_read, client_write), + server_streams, + ): + + @asynccontextmanager + async def fake_stdio_server() -> AsyncIterator[Any]: + yield server_streams + + with patch("mcp_server_git.server.stdio_server", fake_stdio_server): + async with anyio.create_task_group() as tg: + tg.start_soon(serve, None) + await client_write.send(SessionMessage(discover)) + with anyio.fail_after(5): + response = await client_read.receive() + tg.cancel_scope.cancel() + + assert isinstance(response, SessionMessage) + assert response.message.model_dump(by_alias=True, exclude_none=True) == { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": METHOD_NOT_FOUND, + "message": "Method not found", + "data": "server/discover", + }, + } diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index b827ee001a..0a6ddb0e08 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -852,8 +852,12 @@ def test_git_log_date_filtering(test_repository): assert len(valid_result) == 1 -async def test_serve_run_does_not_raise_exceptions(tmp_path: Path): - """Verify that serve() runs server.run without raise_exceptions=True.""" +async def test_serve_loop_does_not_raise_exceptions(tmp_path: Path): + """Verify that serve() runs the server loop without raise_exceptions=True. + + serve() drives SDK v2's legacy-only serve_loop rather than Server.run + (#4851), so that is what is patched. + """ repo_path = tmp_path / "serve_test_repo" git.Repo.init(repo_path).close() @@ -864,7 +868,7 @@ async def test_serve_run_does_not_raise_exceptions(tmp_path: Path): mock_stdio.return_value.__aexit__.return_value = None with mock.patch( - "mcp_server_git.server.Server.run", new_callable=mock.AsyncMock + "mcp_server_git.server.serve_loop", new_callable=mock.AsyncMock ) as mock_run: await serve(repo_path) mock_run.assert_awaited_once() diff --git a/src/git/uv.lock b/src/git/uv.lock index 11a399c276..0ed114edd3 100644 --- a/src/git/uv.lock +++ b/src/git/uv.lock @@ -4,8 +4,9 @@ requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", + "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] [[package]] @@ -49,15 +50,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] -[[package]] -name = "certifi" -version = "2024.8.30" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507, upload-time = "2024-08-30T01:55:04.365Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, -] - [[package]] name = "cffi" version = "2.0.0" @@ -407,41 +399,42 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, ] [[package]] -name = "httpx" -version = "0.27.2" +name = "httpx2" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, - { name = "sniffio" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189, upload-time = "2024-08-27T12:54:01.334Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395, upload-time = "2024-08-27T12:53:59.653Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, ] [[package]] -name = "httpx-sse" -version = "0.4.0" +name = "httpx2-jsfetch" +version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -491,15 +484,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.29.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -509,9 +502,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, ] [[package]] @@ -521,6 +514,7 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "gitpython" }, + { name = "jsonschema" }, { name = "mcp" }, { name = "pydantic" }, ] @@ -538,7 +532,8 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8.1.7" }, { name = "gitpython", specifier = ">=3.1.60" }, - { name = "mcp", specifier = ">=1.29.0,<2" }, + { name = "jsonschema", specifier = ">=4.20.0" }, + { name = "mcp", specifier = ">=2.2,<3" }, { name = "pydantic", specifier = ">=2.0.0" }, ] @@ -551,6 +546,19 @@ dev = [ { name = "ruff", specifier = ">=0.7.3" }, ] +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -560,6 +568,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + [[package]] name = "packaging" version = "24.2" @@ -720,20 +740,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -819,15 +825,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-multipart" version = "0.0.32" @@ -1029,27 +1026,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a5/10f97f73544edcdef54409f1d839f6049a0d79df68adbc1ceb24d1aaca42/smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da", size = 24282, upload-time = "2023-09-17T11:35:03.253Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "sse-starlette" -version = "2.1.3" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, - { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678, upload-time = "2024-08-01T08:52:50.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383, upload-time = "2024-08-01T08:52:48.659Z" }, + { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, ] [[package]] @@ -1104,6 +1091,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" diff --git a/src/memory/README.md b/src/memory/README.md index e5ac259fbe..ab911d4885 100644 --- a/src/memory/README.md +++ b/src/memory/README.md @@ -137,6 +137,10 @@ Example: - Returns the same shape as `read_graph` (entities and relations) - Mutation tools (`create_entities`, `create_relations`, `add_observations`, `delete_entities`, `delete_observations`, `delete_relations`) emit `notifications/resources/updated` for this URI, so subscribed clients see live changes +## Requirements + +Node.js 20 or later, when the server runs through `npx` or from source. + # Usage with Claude Desktop ### Setup diff --git a/src/memory/__tests__/entry-point.test.ts b/src/memory/__tests__/entry-point.test.ts index 63722256c6..5593f0d64b 100644 --- a/src/memory/__tests__/entry-point.test.ts +++ b/src/memory/__tests__/entry-point.test.ts @@ -7,8 +7,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { promises as fs } from "fs"; import path from "path"; import { pathToFileURL } from "url"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { isMainModule, main } from "../index.js"; import { call, makeTempGraph, readFileText } from "./helpers.js"; diff --git a/src/memory/__tests__/helpers.ts b/src/memory/__tests__/helpers.ts index a801a015a5..4be893f7c4 100644 --- a/src/memory/__tests__/helpers.ts +++ b/src/memory/__tests__/helpers.ts @@ -6,10 +6,9 @@ import { promises as fs } from "fs"; import os from "os"; import path from "path"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { InMemoryTransport } from "@modelcontextprotocol/server"; +import type { McpServer, CallToolResult } from "@modelcontextprotocol/server"; +import { Client } from "@modelcontextprotocol/client"; import { createServer } from "../index.js"; export interface Connection { diff --git a/src/memory/__tests__/resource.test.ts b/src/memory/__tests__/resource.test.ts index 9f85bd12a7..0e9d119177 100644 --- a/src/memory/__tests__/resource.test.ts +++ b/src/memory/__tests__/resource.test.ts @@ -4,9 +4,7 @@ // that mutation tools send (notifyGraphUpdated). They replace the earlier // tests that called the register functions with a mocked McpServer. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; -import { ResourceUpdatedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; +import type { Client, JSONRPCMessage } from "@modelcontextprotocol/client"; import { call, connect, makeTempGraph } from "./helpers.js"; import type { Connection } from "./helpers.js"; @@ -31,7 +29,7 @@ describe("knowledge-graph resource over the protocol", () => { client = conn.client; updates = []; client.setNotificationHandler( - ResourceUpdatedNotificationSchema, + "notifications/resources/updated", (notification) => { updates.push(notification.params); }, @@ -102,7 +100,7 @@ describe("knowledge-graph resource over the protocol", () => { it("rejects a URI it does not serve", async () => { await expect( client.readResource({ uri: "memory://other" }), - ).rejects.toThrow("Resource memory://other not found"); + ).rejects.toThrow("Resource not found: memory://other"); }); }); diff --git a/src/memory/__tests__/stdio-smoke.test.ts b/src/memory/__tests__/stdio-smoke.test.ts index ab47e65b40..81cecdf2cf 100644 --- a/src/memory/__tests__/stdio-smoke.test.ts +++ b/src/memory/__tests__/stdio-smoke.test.ts @@ -8,8 +8,8 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { promises as fs } from "fs"; import path from "path"; import { fileURLToPath } from "url"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Client } from "@modelcontextprotocol/client"; import { call, makeTempGraph, readFileText } from "./helpers.js"; const distIndexPath = path.join( diff --git a/src/memory/__tests__/tools.test.ts b/src/memory/__tests__/tools.test.ts index cea031eb93..170d5933e5 100644 --- a/src/memory/__tests__/tools.test.ts +++ b/src/memory/__tests__/tools.test.ts @@ -1,14 +1,14 @@ // Characterization tests for the memory server's nine tools, driven through // an SDK Client over an in-memory transport (#4854). They pin what a client -// sees today on SDK 1.x: the advertised tool list, each tool's text and +// sees today: the advertised tool list, each tool's text and // structured results, and the error results. A test that pins a known bug // cites its issue, so the PR that fixes it has a test to change. import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/client"; import { call, connect, makeTempGraph, textOf } from "./helpers.js"; import type { Connection } from "./helpers.js"; -const DRAFT_07 = "http://json-schema.org/draft-07/schema#"; +const JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"; // JSON Schema fragments as the SDK emits them. Output schemas close every // object with additionalProperties: false; input schemas leave them open. @@ -56,7 +56,7 @@ const graphOutputSchema = { relations: { type: "array", items: relationItem(true) }, }, required: ["entities", "relations"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, additionalProperties: false, }; @@ -64,7 +64,7 @@ const statusOutputSchema = { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" } }, required: ["success", "message"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, additionalProperties: false, }; @@ -86,7 +86,10 @@ const readAnnotations = { idempotentHint: true, openWorldHint: false, }; -const execution = { taskSupport: "forbidden" }; +// SDK 1.x advertised `execution: { taskSupport: "forbidden" }` on every tool; +// SDK v2 removed the experimental tasks layer and no longer sends it. Part 5 +// (#4852) decides what a tool advertises under the tasks extension. +const execution = undefined; const expectedTools = [ { @@ -98,7 +101,7 @@ const expectedTools = [ type: "object", properties: { entities: { type: "array", items: entityItem(false) } }, required: ["entities"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: { type: "object", @@ -107,7 +110,7 @@ const expectedTools = [ skipped: { type: "array", items: { type: "string" } }, }, required: ["entities"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, additionalProperties: false, }, annotations: writeAnnotations, @@ -122,13 +125,13 @@ const expectedTools = [ type: "object", properties: { relations: { type: "array", items: relationItem(false) } }, required: ["relations"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: { type: "object", properties: { relations: { type: "array", items: relationItem(true) } }, required: ["relations"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, additionalProperties: false, }, annotations: writeAnnotations, @@ -163,7 +166,7 @@ const expectedTools = [ }, }, required: ["observations"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: { type: "object", @@ -182,7 +185,7 @@ const expectedTools = [ }, }, required: ["results"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, additionalProperties: false, }, annotations: writeAnnotations, @@ -203,7 +206,7 @@ const expectedTools = [ }, }, required: ["entityNames"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: statusOutputSchema, annotations: deleteAnnotations, @@ -238,7 +241,7 @@ const expectedTools = [ }, }, required: ["deletions"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: statusOutputSchema, annotations: deleteAnnotations, @@ -258,7 +261,7 @@ const expectedTools = [ }, }, required: ["relations"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: statusOutputSchema, annotations: deleteAnnotations, @@ -268,7 +271,11 @@ const expectedTools = [ name: "read_graph", title: "Read Graph", description: "Read the entire knowledge graph", - inputSchema: { type: "object", properties: {}, $schema: DRAFT_07 }, + inputSchema: { + type: "object", + properties: {}, + $schema: JSON_SCHEMA_2020_12, + }, outputSchema: graphOutputSchema, annotations: readAnnotations, execution, @@ -288,7 +295,7 @@ const expectedTools = [ }, }, required: ["query"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: graphOutputSchema, annotations: readAnnotations, @@ -308,7 +315,7 @@ const expectedTools = [ }, }, required: ["names"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: graphOutputSchema, annotations: readAnnotations, @@ -386,19 +393,17 @@ describe("memory tools over the protocol", () => { }); it("offers no prompts", async () => { - await expect(client.listPrompts()).rejects.toThrow(/Method not found/); + // A raw request: SDK v2's listPrompts() answers an empty list without + // asking a server that does not advertise prompts. + await expect( + client.request({ method: "prompts/list", params: {} }), + ).rejects.toThrow(/Method not found/); }); - it("reports an unknown tool as a tool error", async () => { - const result = await call(client, "no_such_tool"); - expect(result).toEqual({ - content: [ - { - type: "text", - text: "MCP error -32602: Tool no_such_tool not found", - }, - ], - isError: true, + it("rejects an unknown tool with -32602", async () => { + await expect(call(client, "no_such_tool")).rejects.toMatchObject({ + code: -32602, + message: "Tool no_such_tool not found", }); }); }); @@ -499,7 +504,7 @@ describe("memory tools over the protocol", () => { content: [ { type: "text", - text: "MCP error -32602: Input validation error: Invalid arguments for tool create_entities: Invalid input: expected array, received undefined at entities", + text: "Input validation error: Invalid arguments for tool create_entities: entities: Invalid input: expected array, received undefined", }, ], isError: true, @@ -873,7 +878,7 @@ describe("memory tools over the protocol", () => { content: [ { type: "text", - text: "MCP error -32602: Input validation error: Invalid arguments for tool search_nodes: Invalid input: expected string, received number at query", + text: "Input validation error: Invalid arguments for tool search_nodes: query: Invalid input: expected string, received number", }, ], isError: true, @@ -888,7 +893,7 @@ describe("memory tools over the protocol", () => { content: [ { type: "text", - text: "MCP error -32602: Input validation error: Invalid arguments for tool search_nodes: Too big: expected string to have <=2048 characters at query", + text: "Input validation error: Invalid arguments for tool search_nodes: query: Too big: expected string to have <=2048 characters", }, ], isError: true, diff --git a/src/memory/index.ts b/src/memory/index.ts index eab385a9fa..0e6bd9ffdc 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -5,14 +5,9 @@ // subscribable resource. createServer() builds a server over a graph file so // tests can run it in-process; main() connects it to stdio, and runs only // when this file is the process entry point, so importing it starts nothing. - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { - SubscribeRequestSchema, - UnsubscribeRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { McpServer } from "@modelcontextprotocol/server"; +import type { Transport } from "@modelcontextprotocol/server"; import { z } from "zod"; import { promises as fs, realpathSync } from "fs"; import type { FileHandle } from "fs/promises"; @@ -773,14 +768,14 @@ export function createServer(memoryFilePath: string): McpServer { title: "Create Entities", description: "Create multiple new entities in the knowledge graph. An entity whose name already exists, or repeats an earlier entity in the same call, is skipped and its observations are not added; the result lists the skipped names in `skipped`. Use add_observations to add observations to an existing entity.", - inputSchema: { + inputSchema: z.object({ entities: z.array(EntitySchema), - }, - outputSchema: { + }), + outputSchema: z.object({ entities: z.array(EntitySchema), // Present only when at least one requested entity was skipped. skipped: z.array(z.string()).optional(), - }, + }), annotations: { readOnlyHint: false, destructiveHint: false, @@ -820,12 +815,12 @@ export function createServer(memoryFilePath: string): McpServer { title: "Create Relations", description: "Create multiple new relations between entities in the knowledge graph. Relations should be in active voice", - inputSchema: { + inputSchema: z.object({ relations: z.array(RelationSchema), - }, - outputSchema: { + }), + outputSchema: z.object({ relations: z.array(RelationSchema), - }, + }), annotations: { readOnlyHint: false, destructiveHint: false, @@ -852,7 +847,7 @@ export function createServer(memoryFilePath: string): McpServer { title: "Add Observations", description: "Add new observations to existing entities in the knowledge graph", - inputSchema: { + inputSchema: z.object({ observations: z.array( z.object({ entityName: z @@ -863,15 +858,15 @@ export function createServer(memoryFilePath: string): McpServer { .describe("An array of observation contents to add"), }), ), - }, - outputSchema: { + }), + outputSchema: z.object({ results: z.array( z.object({ entityName: z.string(), addedObservations: z.array(z.string()), }), ), - }, + }), annotations: { readOnlyHint: false, destructiveHint: false, @@ -898,15 +893,15 @@ export function createServer(memoryFilePath: string): McpServer { title: "Delete Entities", description: "Delete multiple entities and their associated relations from the knowledge graph", - inputSchema: { + inputSchema: z.object({ entityNames: z .array(z.string()) .describe("An array of entity names to delete"), - }, - outputSchema: { + }), + outputSchema: z.object({ success: z.boolean(), message: z.string(), - }, + }), annotations: { readOnlyHint: false, destructiveHint: true, @@ -936,7 +931,7 @@ export function createServer(memoryFilePath: string): McpServer { title: "Delete Observations", description: "Delete specific observations from entities in the knowledge graph", - inputSchema: { + inputSchema: z.object({ deletions: z.array( z.object({ entityName: z @@ -947,11 +942,11 @@ export function createServer(memoryFilePath: string): McpServer { .describe("An array of observations to delete"), }), ), - }, - outputSchema: { + }), + outputSchema: z.object({ success: z.boolean(), message: z.string(), - }, + }), annotations: { readOnlyHint: false, destructiveHint: true, @@ -987,15 +982,15 @@ export function createServer(memoryFilePath: string): McpServer { { title: "Delete Relations", description: "Delete multiple relations from the knowledge graph", - inputSchema: { + inputSchema: z.object({ relations: z .array(RelationSchema) .describe("An array of relations to delete"), - }, - outputSchema: { + }), + outputSchema: z.object({ success: z.boolean(), message: z.string(), - }, + }), annotations: { readOnlyHint: false, destructiveHint: true, @@ -1024,11 +1019,11 @@ export function createServer(memoryFilePath: string): McpServer { { title: "Read Graph", description: "Read the entire knowledge graph", - inputSchema: {}, - outputSchema: { + inputSchema: z.object({}), + outputSchema: z.object({ entities: z.array(EntitySchema), relations: z.array(RelationSchema), - }, + }), annotations: { readOnlyHint: true, destructiveHint: false, @@ -1053,13 +1048,13 @@ export function createServer(memoryFilePath: string): McpServer { { title: "Search Nodes", description: "Search for nodes in the knowledge graph based on a query", - inputSchema: { + inputSchema: z.object({ query: SearchNodesQuerySchema, - }, - outputSchema: { + }), + outputSchema: z.object({ entities: z.array(EntitySchema), relations: z.array(RelationSchema), - }, + }), annotations: { readOnlyHint: true, destructiveHint: false, @@ -1084,15 +1079,15 @@ export function createServer(memoryFilePath: string): McpServer { { title: "Open Nodes", description: "Open specific nodes in the knowledge graph by their names", - inputSchema: { + inputSchema: z.object({ names: z .array(z.string()) .describe("An array of entity names to retrieve"), - }, - outputSchema: { + }), + outputSchema: z.object({ entities: z.array(EntitySchema), relations: z.array(RelationSchema), - }, + }), annotations: { readOnlyHint: true, destructiveHint: false, @@ -1151,11 +1146,11 @@ export function registerKnowledgeGraphSubscriptions( resourceSubscribers: Set, ) { server.server.registerCapabilities({ resources: { subscribe: true } }); - server.server.setRequestHandler(SubscribeRequestSchema, async (request) => { + server.server.setRequestHandler("resources/subscribe", async (request) => { resourceSubscribers.add(request.params.uri); return {}; }); - server.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { + server.server.setRequestHandler("resources/unsubscribe", async (request) => { resourceSubscribers.delete(request.params.uri); return {}; }); diff --git a/src/memory/package.json b/src/memory/package.json index 2efc0ad4fc..e48e521b9d 100644 --- a/src/memory/package.json +++ b/src/memory/package.json @@ -12,6 +12,9 @@ "url": "https://github.com/modelcontextprotocol/servers.git" }, "type": "module", + "engines": { + "node": ">=20" + }, "bin": { "mcp-server-memory": "dist/index.js" }, @@ -31,10 +34,11 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.31.0", - "zod": "^4.0.0" + "@modelcontextprotocol/server": "^2.3.1", + "zod": "^4.2.0" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", diff --git a/src/sequentialthinking/README.md b/src/sequentialthinking/README.md index b6b853aa56..cbb15f7329 100644 --- a/src/sequentialthinking/README.md +++ b/src/sequentialthinking/README.md @@ -69,6 +69,10 @@ After installing the server in your MCP host: 3. Ask the host to solve a non-trivial problem in a step-by-step way. 4. Verify that the host invokes the tool multiple times instead of returning a one-shot answer. +## Requirements + +Node.js 20 or later, when the server runs through `npx` or from source. + ## Configuration ### Usage with Claude Desktop diff --git a/src/sequentialthinking/__tests__/entry-point.test.ts b/src/sequentialthinking/__tests__/entry-point.test.ts index 1b763a02d1..4ac351e738 100644 --- a/src/sequentialthinking/__tests__/entry-point.test.ts +++ b/src/sequentialthinking/__tests__/entry-point.test.ts @@ -17,8 +17,7 @@ import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { isEntryPoint, main } from "../index.js"; const indexUrl = new URL("../index.ts", import.meta.url).href; diff --git a/src/sequentialthinking/__tests__/helpers.ts b/src/sequentialthinking/__tests__/helpers.ts index 1d0d8940f3..d36f8e45e7 100644 --- a/src/sequentialthinking/__tests__/helpers.ts +++ b/src/sequentialthinking/__tests__/helpers.ts @@ -7,8 +7,7 @@ // and type changes alone. import { expect } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { createServer } from "../index.js"; export const TOOL_NAME = "sequentialthinking"; diff --git a/src/sequentialthinking/__tests__/input-schema.test.ts b/src/sequentialthinking/__tests__/input-schema.test.ts index f5ff488b90..8df78f6a4e 100644 --- a/src/sequentialthinking/__tests__/input-schema.test.ts +++ b/src/sequentialthinking/__tests__/input-schema.test.ts @@ -3,7 +3,7 @@ // error a client gets back for each kind of bad input. Originally the #4651 // regression test, which spawned dist/index.js and was skipped when no build // existed; ported to the in-process client (#4854) so it always runs and -// always tests the source. The error texts are the SDK 1.x mapping of a Zod +// always tests the source. The error texts are the SDK's mapping of a Zod // failure and are pinned verbatim: a change to them is a wire change. import { describe, it, expect, beforeEach, afterEach } from "vitest"; @@ -16,7 +16,7 @@ import { } from "./helpers.js"; const INVALID = - "MCP error -32602: Input validation error: Invalid arguments for tool sequentialthinking: "; + "Input validation error: Invalid arguments for tool sequentialthinking: "; describe("sequentialthinking input schema", () => { let conn: Connected; @@ -43,7 +43,7 @@ describe("sequentialthinking input schema", () => { it("rejects a call that omits nextThoughtNeeded", async () => { await expectInvalid( { thought: "t", thoughtNumber: 1, totalThoughts: 1 }, - "Invalid input at nextThoughtNeeded", + "nextThoughtNeeded: Invalid input", ); }); @@ -51,16 +51,16 @@ describe("sequentialthinking input schema", () => { const { thought: _omitted, ...args } = thought(); await expectInvalid( args, - "Invalid input: expected string, received undefined at thought", + "thought: Invalid input: expected string, received undefined", ); }); it("reports every missing numeric field, coerced to NaN", async () => { await expectInvalid( { thought: "t" }, - "Invalid input at nextThoughtNeeded\n" + - "Invalid input: expected number, received NaN at thoughtNumber\n" + - "Invalid input: expected number, received NaN at totalThoughts", + "nextThoughtNeeded: Invalid input, " + + "thoughtNumber: Invalid input: expected number, received NaN, " + + "totalThoughts: Invalid input: expected number, received NaN", ); }); @@ -95,7 +95,7 @@ describe("sequentialthinking input schema", () => { async (value) => { await expectInvalid( thought({ nextThoughtNeeded: value }), - `Expected boolean or "true"/"false" string, received "${value}" at nextThoughtNeeded`, + `nextThoughtNeeded: Expected boolean or "true"/"false" string, received "${value}"`, ); }, ); @@ -103,7 +103,7 @@ describe("sequentialthinking input schema", () => { it.each([1, 0, null])("rejects the non-string %j", async (value) => { await expectInvalid( thought({ nextThoughtNeeded: value }), - "Invalid input at nextThoughtNeeded", + "nextThoughtNeeded: Invalid input", ); }); @@ -117,11 +117,11 @@ describe("sequentialthinking input schema", () => { ).toBeFalsy(); await expectInvalid( thought({ isRevision: "maybe" }), - 'Expected boolean or "true"/"false" string, received "maybe" at isRevision', + 'isRevision: Expected boolean or "true"/"false" string, received "maybe"', ); await expectInvalid( thought({ needsMoreThoughts: "nope" }), - 'Expected boolean or "true"/"false" string, received "nope" at needsMoreThoughts', + 'needsMoreThoughts: Expected boolean or "true"/"false" string, received "nope"', ); }); }); @@ -156,13 +156,13 @@ describe("sequentialthinking input schema", () => { "Invalid input: expected number, received NaN", ], ])("rejects %s = %j", async (field, value, detail) => { - await expectInvalid(thought({ [field]: value }), `${detail} at ${field}`); + await expectInvalid(thought({ [field]: value }), `${field}: ${detail}`); }); it("rejects a non-string branchId", async () => { await expectInvalid( thought({ branchFromThought: 1, branchId: 7 }), - "Invalid input: expected string, received number at branchId", + "branchId: Invalid input: expected string, received number", ); }); }); diff --git a/src/sequentialthinking/__tests__/stdio-smoke.test.ts b/src/sequentialthinking/__tests__/stdio-smoke.test.ts index 9c44f30335..f7924198c1 100644 --- a/src/sequentialthinking/__tests__/stdio-smoke.test.ts +++ b/src/sequentialthinking/__tests__/stdio-smoke.test.ts @@ -10,8 +10,8 @@ import { existsSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Client } from "@modelcontextprotocol/client"; const packageJson = createRequire(import.meta.url)("../package.json") as { version: string; diff --git a/src/sequentialthinking/__tests__/tools-list.test.ts b/src/sequentialthinking/__tests__/tools-list.test.ts index d1a9380dba..38d0b2da9e 100644 --- a/src/sequentialthinking/__tests__/tools-list.test.ts +++ b/src/sequentialthinking/__tests__/tools-list.test.ts @@ -1,6 +1,6 @@ // Pins what a client sees when it connects and lists tools: the server's // identity and capabilities, and the one tool's name, title, description, -// annotations, input schema and output schema, exactly as SDK 1.x emits them +// annotations, input schema and output schema, exactly as the SDK emits them // today. These are characterization tests. The annotations assert the values // fixed in #4721 (the tool is stateful, so neither read-only nor idempotent). // The 2781-character description (#799) is pinned as current design: the @@ -64,8 +64,12 @@ describe("sequentialthinking: initialize and tools/list", () => { }); }); - it("forbids task-augmented execution", () => { - expect(tool.execution).toEqual({ taskSupport: "forbidden" }); + // SDK 1.x advertised `execution: { taskSupport: "forbidden" }`. SDK v2 + // removed the experimental tasks layer and advertises no `execution` at + // all; Part 5 (#4852) decides what the tool advertises under the tasks + // extension. + it("advertises no task-execution metadata", () => { + expect(tool.execution).toBeUndefined(); }); // #799: some clients cap a tool description at 1024 characters. This one is @@ -80,9 +84,9 @@ describe("sequentialthinking: initialize and tools/list", () => { ); }); - it("advertises the input schema as draft-07 JSON Schema", () => { + it("advertises the input schema as JSON Schema 2020-12", () => { expect(tool.inputSchema).toEqual({ - $schema: "http://json-schema.org/draft-07/schema#", + $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { thought: { type: "string", description: "Your current thinking step" }, @@ -136,7 +140,7 @@ describe("sequentialthinking: initialize and tools/list", () => { it("advertises a closed output schema with all five fields required", () => { expect(tool.outputSchema).toEqual({ - $schema: "http://json-schema.org/draft-07/schema#", + $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { thoughtNumber: { type: "number" }, @@ -161,25 +165,22 @@ describe("sequentialthinking: initialize and tools/list", () => { }); it("offers no prompts or resources", async () => { - await expect(conn.client.listPrompts()).rejects.toThrow(/Method not found/); - await expect(conn.client.listResources()).rejects.toThrow( - /Method not found/, - ); + // Raw requests: SDK v2's listPrompts() and listResources() answer empty + // lists without asking a server that does not advertise them. + await expect( + conn.client.request({ method: "prompts/list", params: {} }), + ).rejects.toThrow(/Method not found/); + await expect( + conn.client.request({ method: "resources/list", params: {} }), + ).rejects.toThrow(/Method not found/); }); - it("reports an unknown tool as a tool error", async () => { - const result = await conn.client.callTool({ - name: "no_such_tool", - arguments: {}, - }); - expect(result).toEqual({ - content: [ - { - type: "text", - text: "MCP error -32602: Tool no_such_tool not found", - }, - ], - isError: true, + it("rejects an unknown tool with -32602", async () => { + await expect( + conn.client.callTool({ name: "no_such_tool", arguments: {} }), + ).rejects.toMatchObject({ + code: -32602, + message: "Tool no_such_tool not found", }); }); }); diff --git a/src/sequentialthinking/index.ts b/src/sequentialthinking/index.ts index 5bc02e4602..a03d27f469 100644 --- a/src/sequentialthinking/index.ts +++ b/src/sequentialthinking/index.ts @@ -8,9 +8,9 @@ import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { McpServer } from "@modelcontextprotocol/server"; +import type { Transport } from "@modelcontextprotocol/server"; import { z } from "zod"; import { SequentialThinkingServer } from "./lib.js"; import { SERVER_VERSION } from "./version.js"; @@ -100,7 +100,7 @@ You should: 9. Repeat the process until satisfied with the solution 10. Provide a single, ideally correct answer as the final output 11. Only set nextThoughtNeeded to false when truly done and a satisfactory answer is reached`, - inputSchema: { + inputSchema: z.object({ thought: z.string().describe("Your current thinking step"), nextThoughtNeeded: coercedBoolean.describe( "Whether another thought step is needed", @@ -136,7 +136,7 @@ You should: needsMoreThoughts: coercedBoolean .optional() .describe("If more thoughts are needed"), - }, + }), annotations: { // Every call appends to the server's in-memory thought history, and a // call with both branchFromThought and branchId also appends to that @@ -146,13 +146,13 @@ You should: idempotentHint: false, openWorldHint: false, }, - outputSchema: { + outputSchema: z.object({ thoughtNumber: z.number(), totalThoughts: z.number(), nextThoughtNeeded: z.boolean(), branches: z.array(z.string()), thoughtHistoryLength: z.number(), - }, + }), }, async (args) => { const result = thinkingServer.processThought(args); diff --git a/src/sequentialthinking/package.json b/src/sequentialthinking/package.json index e3b1b9c790..ae41494e13 100644 --- a/src/sequentialthinking/package.json +++ b/src/sequentialthinking/package.json @@ -12,6 +12,9 @@ "url": "https://github.com/modelcontextprotocol/servers.git" }, "type": "module", + "engines": { + "node": ">=20" + }, "bin": { "mcp-server-sequential-thinking": "dist/index.js" }, @@ -31,12 +34,13 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.31.0", + "@modelcontextprotocol/server": "^2.3.1", "chalk": "^5.3.0", "yargs": "^17.7.2", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", "@types/yargs": "^17.0.32", "@vitest/coverage-v8": "^4.1.11", diff --git a/src/time/README.md b/src/time/README.md index 0db4b0cce4..6cb87ee4b6 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -6,7 +6,7 @@ A Model Context Protocol server that provides time and timezone conversion capab Source: https://github.com/modelcontextprotocol/servers/tree/main/src/time -Requires MCP Python SDK 1.x (`mcp>=1.29.0,<2`). SDK 2.0 renamed APIs this server uses. The port to v2 is in progress. +Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). ### Available Tools diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml index 2c27a2ed41..d37e27724e 100644 --- a/src/time/pyproject.toml +++ b/src/time/pyproject.toml @@ -17,7 +17,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", ] dependencies = [ - "mcp>=1.29.0,<2", + "jsonschema>=4.20.0", + "mcp>=2.2,<3", "pydantic>=2.0.0", "tzdata>=2024.2", "tzlocal>=5.3.1", diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index f224b00e44..92e2704d7b 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -1,26 +1,40 @@ +# The time MCP server: `get_current_time` and `convert_time` over IANA +# timezones, with the local timezone detected (or set with --local-timezone) +# and named in the tool descriptions so a model can default to it. +# +# Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no +# longer validates tool arguments or folds tool exceptions into `isError` +# results, so `call_tool` does both with SDK v1's messages, keeping tool +# results as they were on v1. `serve()` runs the legacy (2025-11-25) handshake +# loop only; serving 2026-07-28 is #4853. + from datetime import datetime, timedelta from functools import cache from importlib.metadata import version from enum import Enum import json import sys -from typing import Sequence +from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones from tzlocal import get_localzone_name # ← returns "Europe/Paris", etc. -from mcp.server import Server +import jsonschema +from mcp.server import Server, ServerRequestContext +from mcp.server.runner import serve_loop from mcp.server.stdio import stdio_server from mcp.types import ( + CallToolRequestParams, + CallToolResult, + ContentBlock, + ListToolsResult, + PaginatedRequestParams, Tool, ToolAnnotations, TextContent, - ImageContent, - EmbeddedResource, - ErrorData, INVALID_PARAMS, ) -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from pydantic import BaseModel @@ -91,9 +105,7 @@ def get_zoneinfo(timezone_name: str) -> ZoneInfo: try: return load_zoneinfo(timezone_name) except Exception as e: - raise McpError( - ErrorData(code=INVALID_PARAMS, message=f"Invalid timezone: {str(e)}") - ) + raise MCPError(code=INVALID_PARAMS, message=f"Invalid timezone: {str(e)}") class TimeServer: @@ -168,8 +180,14 @@ def convert_time( ) +def tool_error(message: str) -> CallToolResult: + """A tool call that failed, as an `isError` result the model can read.""" + return CallToolResult( + content=[TextContent(type="text", text=message)], is_error=True + ) + + async def serve(local_timezone: str | None = None) -> None: - server = Server("mcp-time", version=SERVER_VERSION) time_server = TimeServer() if local_timezone: # Fail before the transport opens, with one line naming the bad value @@ -185,64 +203,65 @@ async def serve(local_timezone: str | None = None) -> None: ) local_tz = str(get_local_tz(local_timezone)) - @server.list_tools() - async def list_tools() -> list[Tool]: - """List available time tools.""" - return [ - Tool( - name=TimeTools.GET_CURRENT_TIME.value, - description="Get current time in a specific timezone", - inputSchema={ - "type": "object", - "properties": { - "timezone": { - "type": "string", - "description": f"IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no timezone provided by the user.", - } - }, - "required": ["timezone"], + tools = [ + Tool( + name=TimeTools.GET_CURRENT_TIME.value, + description="Get current time in a specific timezone", + input_schema={ + "type": "object", + "properties": { + "timezone": { + "type": "string", + "description": f"IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no timezone provided by the user.", + } }, - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + "required": ["timezone"], + }, + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=TimeTools.CONVERT_TIME.value, - description="Convert time between timezones", - inputSchema={ - "type": "object", - "properties": { - "source_timezone": { - "type": "string", - "description": f"Source IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no source timezone provided by the user.", - }, - "time": { - "type": "string", - "description": "Time to convert in 24-hour format (HH:MM)", - }, - "target_timezone": { - "type": "string", - "description": f"Target IANA timezone name (e.g., 'Asia/Tokyo', 'America/San_Francisco'). Use '{local_tz}' as local timezone if no target timezone provided by the user.", - }, + ), + Tool( + name=TimeTools.CONVERT_TIME.value, + description="Convert time between timezones", + input_schema={ + "type": "object", + "properties": { + "source_timezone": { + "type": "string", + "description": f"Source IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no source timezone provided by the user.", + }, + "time": { + "type": "string", + "description": "Time to convert in 24-hour format (HH:MM)", + }, + "target_timezone": { + "type": "string", + "description": f"Target IANA timezone name (e.g., 'Asia/Tokyo', 'America/San_Francisco'). Use '{local_tz}' as local timezone if no target timezone provided by the user.", }, - "required": ["source_timezone", "time", "target_timezone"], }, - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + "required": ["source_timezone", "time", "target_timezone"], + }, + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - ] + ), + ] + schemas = {tool.name: tool.input_schema for tool in tools} - @server.call_tool() - async def call_tool( - name: str, arguments: dict - ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + async def list_tools( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListToolsResult: + """List available time tools.""" + return ListToolsResult(tools=tools) + + def run_tool(name: str, arguments: dict[str, Any]) -> list[ContentBlock]: """Handle tool calls for time queries.""" try: match name: @@ -258,7 +277,7 @@ async def call_tool( k in arguments for k in ["source_timezone", "time", "target_timezone"] ): - raise ValueError( # pragma: no cover # unreachable: the SDK rejects a missing key against inputSchema first + raise ValueError( # pragma: no cover # unreachable: call_tool rejects a missing key against inputSchema first "Missing required arguments" ) for key in ["source_timezone", "target_timezone"]: @@ -280,6 +299,41 @@ async def call_tool( except Exception as e: raise ValueError(f"Error processing mcp-server-time query: {str(e)}") + async def call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams + ) -> CallToolResult: + # SDK v2's low-level server neither validates arguments nor turns a + # handler exception into an isError result, both of which SDK v1 did. + # Both are done here, with v1's messages, so tool results are unchanged. + arguments = params.arguments or {} + schema = schemas.get(params.name) + if schema is not None: + try: + jsonschema.validate(instance=arguments, schema=schema) + except jsonschema.ValidationError as e: + return tool_error(f"Input validation error: {e.message}") + try: + return CallToolResult(content=run_tool(params.name, arguments)) + except Exception as e: + return tool_error(str(e)) + + server = Server( + "mcp-time", + version=SERVER_VERSION, + on_list_tools=list_tools, + on_call_tool=call_tool, + ) options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, options) + # Legacy era only. Server.run() would also serve 2026-07-28 (its + # dual-era loop answers server/discover and per-request envelopes); + # adopting that era is #4853, so this port (#4851) serves the + # handshake loop alone, the only era it served on SDK v1. + async with server.lifespan(server) as lifespan_state: + await serve_loop( + server, + read_stream, + write_stream, + lifespan_state=lifespan_state, + init_options=options, + ) diff --git a/src/time/tests/test_protocol.py b/src/time/tests/test_protocol.py index f80cb35a1c..5c2246097e 100644 --- a/src/time/tests/test_protocol.py +++ b/src/time/tests/test_protocol.py @@ -3,7 +3,7 @@ # # Each test runs the real `serve()` coroutine with `stdio_server` swapped for # an in-memory stream pair, and talks to it with a real `ClientSession`. That -# exercises what a client actually sees: the SDK's input validation, its +# exercises what a client actually sees: the server's input validation, its # folding of handler exceptions into `isError` results, the advertised tool # schemas and the initialize handshake. The pure helpers in `server.py` are # covered directly in `test_server.py`. @@ -33,6 +33,8 @@ import pytest from mcp import ClientSession from mcp.shared.memory import create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp.types import METHOD_NOT_FOUND, JSONRPCRequest from pydantic import BaseModel from mcp_server_time.server import serve @@ -54,7 +56,12 @@ def now(cls, tz: tzinfo | None = None): def wire(model: BaseModel) -> Any: """Serialize an SDK model the way it travels over the wire.""" - return model.model_dump(by_alias=True, mode="json", exclude_none=True) + # exclude_unset: SDK v2 result models default fields the 2025-11-25 wire + # does not carry (resultType, ttlMs, cacheScope), so only what the server + # actually sent is compared. + return model.model_dump( + by_alias=True, mode="json", exclude_none=True, exclude_unset=True + ) @asynccontextmanager @@ -131,7 +138,9 @@ async def test_initialize_reports_server_info_and_capabilities() -> None: } assert init["serverInfo"]["version"] != version("mcp") # Only tools are advertised; no resources, prompts or logging. - assert init["capabilities"] == {"experimental": {}, "tools": {"listChanged": False}} + # Behavior change in the SDK v2 port (#4851): v2 omits the empty + # `experimental: {}` object v1 always sent. It advertised nothing. + assert init["capabilities"] == {"tools": {"listChanged": False}} assert "instructions" not in init @@ -468,7 +477,7 @@ async def test_get_current_time_does_not_default_to_the_local_timezone() -> None @pytest.mark.parametrize( "arguments,expected", [ - # Rejected by the SDK's input validation against inputSchema, before + # Rejected by call_tool's input validation against inputSchema, before # the handler runs. ({}, error_result("Input validation error: 'timezone' is a required property")), ( @@ -479,7 +488,7 @@ async def test_get_current_time_does_not_default_to_the_local_timezone() -> None {"timezone": None}, error_result("Input validation error: None is not of type 'string'"), ), - # Raised by the handler and folded into an error result by the SDK. + # Raised by the handler and folded into an error result by call_tool. ({"timezone": ""}, handler_error("Missing required argument: timezone")), ( {"timezone": "Invalid/Timezone"}, @@ -818,7 +827,7 @@ async def test_convert_time_ignores_extra_arguments() -> None: @pytest.mark.parametrize( "arguments,expected", [ - # Rejected by the SDK's input validation, before the handler runs. A + # Rejected by call_tool's input validation, before the handler runs. A # missing key never reaches the handler's own "Missing required # arguments" check, which is why that branch is marked unreachable. ( @@ -900,8 +909,8 @@ async def test_convert_time_checks_timezones_before_the_time_string() -> None: async def test_unknown_tool_is_an_error_result_not_a_protocol_error() -> None: - # The SDK skips validation for an unlisted tool (and logs a warning), then - # the handler's fallthrough raises, which the SDK folds into `isError`. + # call_tool skips validation for an unlisted tool, then the handler's + # fallthrough raises, which call_tool folds into `isError`. async with connected() as session: result = await call(session, "no_such_tool", {"timezone": "UTC"}) assert result == handler_error("Unknown tool: no_such_tool") @@ -914,3 +923,60 @@ async def test_session_survives_errors() -> None: await call(session, "get_current_time", {"timezone": "Bad/Zone"}) result = await call(session, "get_current_time", {"timezone": "UTC"}) assert result["isError"] is False + + +# --------------------------------------------------------------------------- +# Protocol era +# --------------------------------------------------------------------------- + + +async def test_modern_era_is_not_served() -> None: + # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. + # Server.run() would also open a 2026-07-28 connection for a request that + # carries the per-request `_meta` envelope; adopting that era is #4853. + # Until then such a request is refused as an unknown method. + # + # Behavior change in the SDK v2 port: SDK v1 answered every unknown + # method with -32602 "Invalid request parameters" (its request union + # failed to parse); SDK v2 answers -32601 "Method not found", the code + # JSON-RPC defines for it. + discover = JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="server/discover", + params={ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + }, + ) + response: SessionMessage | Exception | None = None + async with create_client_server_memory_streams() as ( + (client_read, client_write), + server_streams, + ): + + @asynccontextmanager + async def fake_stdio_server() -> AsyncIterator[Any]: + yield server_streams + + with patch("mcp_server_time.server.stdio_server", fake_stdio_server): + async with anyio.create_task_group() as tg: + tg.start_soon(serve, "UTC") + await client_write.send(SessionMessage(discover)) + with anyio.fail_after(5): + response = await client_read.receive() + tg.cancel_scope.cancel() + + assert isinstance(response, SessionMessage) + assert response.message.model_dump(by_alias=True, exclude_none=True) == { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": METHOD_NOT_FOUND, + "message": "Method not found", + "data": "server/discover", + }, + } diff --git a/src/time/tests/test_server.py b/src/time/tests/test_server.py index 2b80df3fa4..a64ecc863f 100644 --- a/src/time/tests/test_server.py +++ b/src/time/tests/test_server.py @@ -1,5 +1,5 @@ from freezegun import freeze_time -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError import pytest from unittest.mock import patch from zoneinfo import ZoneInfo @@ -89,7 +89,7 @@ def test_get_current_time(test_time, timezone, expected): def test_get_current_time_with_invalid_timezone(): time_server = TimeServer() with pytest.raises( - McpError, + MCPError, match=r"Invalid timezone: 'No time zone found with key Invalid/Timezone'", ): time_server.get_current_time("Invalid/Timezone") @@ -120,7 +120,7 @@ def test_get_current_time_with_invalid_timezone(): ) def test_convert_time_errors(source_tz, time_str, target_tz, expected_error): time_server = TimeServer() - with pytest.raises((McpError, ValueError), match=expected_error): + with pytest.raises((MCPError, ValueError), match=expected_error): time_server.convert_time(source_tz, time_str, target_tz) @@ -539,7 +539,7 @@ def test_get_zoneinfo_rejects_a_key_the_tz_database_does_not_list(): without_warsaw = known_timezones() - {"Europe/Warsaw"} with patch("mcp_server_time.server.known_timezones", return_value=without_warsaw): with pytest.raises( - McpError, + MCPError, match="Invalid timezone: 'No time zone found with key Europe/Warsaw'", ): get_zoneinfo("Europe/Warsaw") diff --git a/src/time/uv.lock b/src/time/uv.lock index e20f3f0a97..16584d44cd 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -4,8 +4,9 @@ requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", + "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] [[package]] @@ -49,15 +50,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] -[[package]] -name = "certifi" -version = "2024.8.30" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507, upload-time = "2024-08-30T01:55:04.365Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, -] - [[package]] name = "cffi" version = "2.0.0" @@ -395,40 +387,42 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, ] [[package]] -name = "httpx" -version = "0.28.0" +name = "httpx2" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/df/676b7cf674dd1bdc71a64ad393c89879f75e4a0ab8395165b498262ae106/httpx-0.28.0.tar.gz", hash = "sha256:0858d3bab51ba7e386637f22a61d8ccddaeec5f3fe4209da3a6168dbb91573e0", size = 141307, upload-time = "2024-11-28T14:54:56.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/fb/a19866137577ba60c6d8b69498dc36be479b13ba454f691348ddf428f185/httpx-0.28.0-py3-none-any.whl", hash = "sha256:dc0b419a0cfeb6e8b34e85167c0da2671206f5095f1baa9663d23bcfd6b535fc", size = 73551, upload-time = "2024-11-28T14:54:55.141Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, ] [[package]] -name = "httpx-sse" -version = "0.4.0" +name = "httpx2-jsfetch" +version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -478,15 +472,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.29.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -496,9 +490,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, ] [[package]] @@ -506,6 +500,7 @@ name = "mcp-server-time" version = "2026.10.10" source = { editable = "." } dependencies = [ + { name = "jsonschema" }, { name = "mcp" }, { name = "pydantic" }, { name = "tzdata" }, @@ -524,7 +519,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "mcp", specifier = ">=1.29.0,<2" }, + { name = "jsonschema", specifier = ">=4.20.0" }, + { name = "mcp", specifier = ">=2.2,<3" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "tzdata", specifier = ">=2024.2" }, { name = "tzlocal", specifier = ">=5.3.1" }, @@ -540,6 +536,19 @@ dev = [ { name = "ruff", specifier = ">=0.8.1" }, ] +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -549,6 +558,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + [[package]] name = "packaging" version = "24.2" @@ -709,20 +730,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -820,15 +827,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-multipart" version = "0.0.32" @@ -1032,16 +1030,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "2.1.3" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, - { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678, upload-time = "2024-08-01T08:52:50.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383, upload-time = "2024-08-01T08:52:48.659Z" }, + { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, ] [[package]] @@ -1096,6 +1093,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"