From 11a9913bd4a0e646bd3cbc3abba5ac1a88cf2fd7 Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:37:17 +0000 Subject: [PATCH 01/14] Add repository skeleton for Resource operations README, CONTRIBUTING, AGENTS/CLAUDE, SECURITY and CODEOWNERS following ext-skills; docs/ for open questions (from the charter), decision log, related work and implementations; a python/sdk placeholder package with CI following experimental-ext-interceptors; and a conformance/ folder. Co-Authored-By: Claude Opus 5.5 --- .github/CODEOWNERS | 8 +++ .github/workflows/python.yml | 55 ++++++++++++++++ .gitignore | 20 ++++++ AGENTS.md | 19 ++++++ CLAUDE.md | 8 +++ CONTRIBUTING.md | 62 ++++++++++++++++++ README.md | 40 +++++++++++- SECURITY.md | 14 ++++ conformance/README.md | 5 ++ docs/decisions.md | 28 ++++++++ docs/implementations.md | 23 +++++++ docs/open-questions.md | 12 ++++ docs/related-work.md | 17 +++++ python/sdk/README.md | 18 +++++ python/sdk/pyproject.toml | 65 +++++++++++++++++++ .../sdk/src/mcp_ext_filesystems/__init__.py | 5 ++ python/sdk/tests/test_package.py | 5 ++ 17 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/python.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 conformance/README.md create mode 100644 docs/decisions.md create mode 100644 docs/implementations.md create mode 100644 docs/open-questions.md create mode 100644 docs/related-work.md create mode 100644 python/sdk/README.md create mode 100644 python/sdk/pyproject.toml create mode 100644 python/sdk/src/mcp_ext_filesystems/__init__.py create mode 100644 python/sdk/tests/test_package.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6da0018 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +# CODEOWNERS for the experimental-ext-filesystems repository. +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# +# Team mappings are configured in the access repository: +# https://github.com/modelcontextprotocol/access + +# Default owners for all files: reviews route to the Files Working Group. +* @modelcontextprotocol/filesystems-wg diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..acfafaa --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,55 @@ +name: Python CI +on: + push: + branches: [main] + paths: + - 'python/**' + - '.github/workflows/python.yml' + pull_request: + paths: + - 'python/**' + - '.github/workflows/python.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + python-lint: + name: "Linting" + runs-on: ubuntu-latest + defaults: + run: + working-directory: python/sdk + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install -e . ruff mypy + - name: Ruff format + run: ruff format --check . + - name: Ruff lint + run: ruff check . + - name: mypy + run: mypy src/mcp_ext_filesystems + + python-test: + name: "Unit Tests" + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + defaults: + run: + working-directory: python/sdk + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install -e . pytest + - name: Run tests + run: pytest -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06f0c89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Editors and OS +.idea/ +.vscode/ +*.swp +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d1510ba --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +## What to reference as a source + +Nothing in this repository is a specification yet. The [Files Working Group charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389) defines the scope. The `docs/` folder holds open questions, the decision log, related work, and the implementations list. Treat it as context, not as normative text. + +Do not describe a proposal in this repository as an accepted or official part of MCP. Until accepted text lands under `specification/`, everything here is experimental. + +## Proposing changes + +Changes are proposed as pull requests. A change to the group's direction is a dated entry in [`docs/decisions.md`](docs/decisions.md) with `**Status:** Proposed`, in the ADR-lite format used in that file (Status / Context / Decision / Rationale / References). When a decision supersedes or amends an earlier one, add a forward pointer to the earlier entry's `**Status:**` line rather than rewriting it. + +## Code + +The Python reference implementation lives in `python/sdk/`. Run `ruff format --check .`, `ruff check .`, `mypy src/mcp_ext_filesystems`, and `pytest` from that directory before opening a pull request. + +## AI disclosure + +Pull requests and issues must disclose AI assistance, per the MCP [AI contribution policy](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/AI_POLICY.md). + +See [CONTRIBUTING.md](CONTRIBUTING.md) for participation, meetings, and decision-making. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ae9ce14 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +@AGENTS.md + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..447ab60 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing + +## How to Participate + +The Files Working Group welcomes contributions from anyone interested in operating on MCP Resources beyond a single read. You can participate by: + +- Joining the discussion in `#filesystem-wg` on the MCP Discord (info on joining the server [here](https://modelcontextprotocol.io/community/communication#discord)) +- Opening pull requests with concrete proposals, so the group can comment on specifics +- Sharing findings from your own implementations as [GitHub issues](https://github.com/modelcontextprotocol/experimental-ext-filesystems/issues) +- Adding servers, SDKs, and hosts to [docs/implementations.md](docs/implementations.md) + +## Communication Channels + +| Channel | Purpose | Response Expectation | +| :--- | :--- | :--- | +| Discord `#filesystem-wg` | Quick questions, coordination, async discussion | Best effort | +| This repository | Proposals, decision log, reference implementations, conformance scenarios | Reviewed by the WG | + +## Meetings + +Working Session cadence is defined in the [charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389). Meeting requirements (advance notice, agendas, and notes) follow MCP [group governance](https://modelcontextprotocol.io/community/working-interest-groups#meeting-requirements). + +## Decision-Making + +Scope and per-decision-type authority are defined in the charter. The decision progression (lazy consensus, then formal vote, then escalation) follows MCP [group governance](https://modelcontextprotocol.io/community/working-interest-groups#decision-making-process). + +## Contribution Guidelines + +### Proposals + +A proposal is a pull request. For a change to the group's direction, add a dated entry to [docs/decisions.md](docs/decisions.md) with `**Status:** Proposed`, in the ADR-lite format defined there. A proposal can also carry prototype code under `python/` or scenarios under `conformance/` that show it working. + +### Decision Log + +Record significant decisions in [docs/decisions.md](docs/decisions.md) after the meeting where they were made, or once consensus is reached asynchronously. A decision is worth logging when it: + +- Chooses one approach over alternatives +- Sets or changes the group's scope +- Establishes a convention or coordination mechanism + +When a decision supersedes or amends an earlier one, add a forward pointer to the earlier entry's `**Status:**` line rather than rewriting it. + +### Sharing Implementation Findings + +When reporting a finding: + +- Include enough detail for others to reproduce or evaluate it +- Note which clients and servers were tested, and the specification revision +- Be explicit about what worked, what didn't, and what remains untested +- Write "Not documented" for missing details instead of inferring them + +### Community Input + +When quoting community discussion, attribute it by name and GitHub handle, link to the source where possible, and use blockquotes to separate it from editorial text. + +### AI Contributions + +Follow the MCP [AI contribution policy](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/AI_POLICY.md): disclose any AI assistance in the pull request or issue, along with the extent of it. + +## License + +By contributing, you agree that your contributions are licensed under the Apache License 2.0. diff --git a/README.md b/README.md index 787ff40..af15d74 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,38 @@ -# experimental-ext-filesystems -Experimental MCP extension for filesystem operations on Resources. Maintained by the Filesystems Working Group. +# MCP Filesystems (Experimental Extension) + +> **Status:** Experimental. This work is for prototyping and feedback only, and is not an accepted or official MCP extension. + +This repository holds the working documents and reference implementations for **Resource operations**: a common interface for operating on MCP Resources beyond a single read (`create`, `update`, `delete`, a metadata read (`stat`), scoped listing, search, and glob matching), with optimistic concurrency control on writes. It is maintained by the Files Working Group, which replaces the File Uploads and Filesystems Working Groups. + +**Charter:** [Files Working Group charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389) (in review): mission, scope, leadership, and active work items. +**Discord:** `#filesystem-wg` on the [MCP Discord](https://modelcontextprotocol.io/community/communication#discord). +**Open work:** [pull requests](https://github.com/modelcontextprotocol/experimental-ext-filesystems/pulls) and [issues](https://github.com/modelcontextprotocol/experimental-ext-filesystems/issues). Proposals, findings, and implementations are welcome. + +## Repository Contents + +| Document | Description | +| :--- | :--- | +| [Open Questions](docs/open-questions.md) | Questions the group needs to settle, starting with the charter's | +| [Decision Log](docs/decisions.md) | ADR-lite record of the group's decisions, and the vehicle for proposing changes | +| [Related Work](docs/related-work.md) | SEPs, extensions, and discussions this work builds on | +| [Implementations](docs/implementations.md) | Servers, SDKs, and hosts that implement Resource operations | +| [`python/`](python/) | Python reference implementation | +| [`conformance/`](conformance/) | Conformance scenarios for each new method and schema element | + +Nothing under `docs/` is part of a specification. A specification will live under `specification/` once the group has accepted text to put there. + +## Implementations + +| Language | Directory | Package | Status | +| :--- | :--- | :--- | :--- | +| Python | `python/sdk/` | `mcp-ext-filesystems` | Planned | + +The charter also lists a TypeScript SDK reference implementation ([@ochafik](https://github.com/ochafik)). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for how to participate. + +## License + +Apache License 2.0. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6eaed0a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +This repository follows the same security policy as the main Model Context Protocol project. + +Please see the [MCP Security Policy](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/SECURITY.md) for full details on reporting security issues, the trust model, and what is considered in scope. + +## Reporting Security Issues + +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. + +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..c1fb009 --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,5 @@ +# Conformance + +Conformance scenarios for each new Resource operation method and schema element, as the charter requires. Scenarios should run against any implementation, not only the ones in this repository, so that independent servers exercising the write path can be checked against a common client. + +No scenarios yet. They arrive alongside the first proposal that defines a method. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..942cfa4 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,28 @@ +# Decision Log + +This document records significant decisions made by the Files Working Group about Resource operations, using an ADR-lite (Architecture Decision Record) format. It serves as a transparent, auditable trace of the group's reasoning over time. + +For background on the ADR format, see [adr.github.io](https://adr.github.io/). + +No decisions have been recorded yet. The first one the charter calls for is listed in [open-questions.md](open-questions.md). + +## Entry format + +Add new entries below this section, oldest first. A pull request that adds an entry with `**Status:** Proposed` is a proposal. + +```markdown +### YYYY-MM-DD: Short statement of the decision + +**Status:** Proposed | Accepted | Superseded by [YYYY-MM-DD entry](#anchor) + +**Context:** What prompted the decision, and the options considered. + +**Decision:** What the group decided. + +**Rationale:** Why this option over the alternatives. + +**References:** +- Links to meeting notes, issues, pull requests, or Discord threads +``` + +--- diff --git a/docs/implementations.md b/docs/implementations.md new file mode 100644 index 0000000..20eac23 --- /dev/null +++ b/docs/implementations.md @@ -0,0 +1,23 @@ +# Implementations + +Servers, SDKs, and hosts that implement Resource operations. This list is community-maintained. + +**Status:** `prototype` demo or fork · `in progress` open PR · `planned` issue only. A link after the status points to the issue or PR tracking the work. + +To add or update a row, open a pull request. Keep notes to one line. + +## SDKs and frameworks + +| Implementation | Language | Status | Notes | +| :--- | :--- | :--- | :--- | +| [`python/sdk/`](../python/sdk/) | Python | `planned` | Reference implementation in this repository | + +## Servers + +| Implementation | Language | Status | Notes | +| :--- | :--- | :--- | :--- | + +## Hosts + +| Implementation | Status | Notes | +| :--- | :--- | :--- | diff --git a/docs/open-questions.md b/docs/open-questions.md new file mode 100644 index 0000000..953059a --- /dev/null +++ b/docs/open-questions.md @@ -0,0 +1,12 @@ +# Open Questions + +Questions the group needs to settle for Resource operations. The first three come from the [charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389); add others by pull request. When a question is settled, record the outcome in the [decision log](decisions.md) and link it here. + +| # | Question | Source | Status | +| :--- | :--- | :--- | :--- | +| 1 | Is a transferred file addressed as a Resource URI? The answer decides whether a `files/*` method family exists alongside `resources/*`, and binds all three workstreams. | Charter, cross-cutting concerns | Open | +| 2 | Which optimistic concurrency control mechanism do writes use, including create-if-absent? | Charter, workstream 2 | Open | +| 3 | How do writes interact with `notifications/resources/updated`, `ttlMs`, `cacheScope`, and `lastModified`? | Charter, workstream 2 | Open | +| 4 | Which typed errors do Resource operations return? | Charter, workstream 2 | Open | +| 5 | Does the work fit one Extensions Track SEP, or several (for example writes separate from listing and search)? | Charter, workstream 2 | Open | +| 6 | How do Resource operations relate to the optional directory read in the Skills extension? | Charter, related groups | Open | diff --git a/docs/related-work.md b/docs/related-work.md new file mode 100644 index 0000000..45300ef --- /dev/null +++ b/docs/related-work.md @@ -0,0 +1,17 @@ +# Related Work + +SEPs, extensions, and discussions that Resource operations build on or must stay consistent with. Add entries by pull request; keep each to a line or two. + +## SEPs named in the charter + +| SEP | Status | Relevance | +| :--- | :--- | :--- | +| [SEP-2571](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2571) | Closed 2026-09-22 | Proposed `resources/create` and `resources/delete`. The charter takes its create and delete operations as input to workstream 2. | +| [SEP-2631](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2631) | Draft | File objects and out-of-band transfer. Input to the content transfer and file input workstreams. | +| [SEP-2532](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2532) | Open | `resources/stream`. To be reconciled with the transfer work. | +| [SEP-2356](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2356) | Closed 2026-06-26 | File input model. Input to the file inputs workstream. | + +## Extensions and groups + +- [Skills extension](https://github.com/modelcontextprotocol/ext-skills): reads skill files through Resources and defines an optional directory read. The charter asks this group to coordinate on any Resource operation that affects skills. +- [Interceptors extension](https://github.com/modelcontextprotocol/experimental-ext-interceptors): repository layout this one follows for per-language reference implementations. diff --git a/python/sdk/README.md b/python/sdk/README.md new file mode 100644 index 0000000..6c73062 --- /dev/null +++ b/python/sdk/README.md @@ -0,0 +1,18 @@ +# MCP Filesystems Python SDK + +Python reference implementation of the experimental MCP Resource operations extension, built on the [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) v2. + +**Status:** Planned. This package is a placeholder until the group has a proposal to implement; see [open questions](../../docs/open-questions.md). + +## Development + +```bash +uv venv && uv pip install -e . pytest ruff mypy +ruff format --check . && ruff check . +mypy src/mcp_ext_filesystems +pytest +``` + +## License + +Apache License 2.0. See [LICENSE](../../LICENSE). diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml new file mode 100644 index 0000000..d9d298d --- /dev/null +++ b/python/sdk/pyproject.toml @@ -0,0 +1,65 @@ +[project] +name = "mcp-ext-filesystems" +version = "0.1.0" +description = "Python reference implementation of the experimental MCP Resource operations extension" +authors = [{name = "The MCP Files Working Group"}] +readme = "README.md" +license = {text = "Apache-2.0"} +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "mcp>=2,<3", +] + +[project.urls] +"Homepage" = "https://github.com/modelcontextprotocol/experimental-ext-filesystems" +"Bug Reports" = "https://github.com/modelcontextprotocol/experimental-ext-filesystems/issues" +"Source" = "https://github.com/modelcontextprotocol/experimental-ext-filesystems" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/mcp_ext_filesystems"] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "C4", # flake8-comprehensions + "PERF", # Perflint +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # Allow re-exports in __init__.py + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--strict-markers" diff --git a/python/sdk/src/mcp_ext_filesystems/__init__.py b/python/sdk/src/mcp_ext_filesystems/__init__.py new file mode 100644 index 0000000..78e279e --- /dev/null +++ b/python/sdk/src/mcp_ext_filesystems/__init__.py @@ -0,0 +1,5 @@ +"""Python reference implementation of the experimental MCP Resource operations extension.""" + +from importlib.metadata import version + +__version__ = version("mcp-ext-filesystems") diff --git a/python/sdk/tests/test_package.py b/python/sdk/tests/test_package.py new file mode 100644 index 0000000..9146f22 --- /dev/null +++ b/python/sdk/tests/test_package.py @@ -0,0 +1,5 @@ +import mcp_ext_filesystems + + +def test_package_reports_its_version() -> None: + assert mcp_ext_filesystems.__version__ == "0.1.0" From b5a1eaa7426c84b2f3ec802855038f6d79747f1e Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:40:02 +0000 Subject: [PATCH 02/14] Add problem statement and use cases; tie the reference implementation to SDK extension points Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ docs/problem-statement.md | 9 +++++++++ docs/related-work.md | 7 ++++++- docs/use-cases.md | 19 +++++++++++++++++++ python/sdk/README.md | 2 +- 5 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 docs/problem-statement.md create mode 100644 docs/use-cases.md diff --git a/README.md b/README.md index af15d74..7ac8b3d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ This repository holds the working documents and reference implementations for ** | Document | Description | | :--- | :--- | +| [Problem Statement](docs/problem-statement.md) | What is missing from Resources today, and what the charter asks for | +| [Use Cases](docs/use-cases.md) | Concrete situations Resource operations should serve | | [Open Questions](docs/open-questions.md) | Questions the group needs to settle, starting with the charter's | | [Decision Log](docs/decisions.md) | ADR-lite record of the group's decisions, and the vehicle for proposing changes | | [Related Work](docs/related-work.md) | SEPs, extensions, and discussions this work builds on | diff --git a/docs/problem-statement.md b/docs/problem-statement.md new file mode 100644 index 0000000..5565b09 --- /dev/null +++ b/docs/problem-statement.md @@ -0,0 +1,9 @@ +# Problem Statement + +MCP Resources are read-oriented. A client can list Resources and read one by URI, but there is no common way to create, update, or delete one, to read only its metadata, or to list, search, or glob within a scope. Servers that need these operations expose them as tools, each with its own names, arguments, and error shapes, so clients cannot treat them uniformly. + +Writes add a second problem: concurrency. Without a shared mechanism, two clients that read a Resource and write it back can silently overwrite each other's changes (last writer wins), and "create only if it does not exist" cannot be expressed. + +The [charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389) puts this in workstream 2, Resource operations: create, update, delete, and `stat`; scoped listing, directory traversal, server-side search and glob matching, and bulk reads; typed errors; optimistic concurrency control on writes, including create-if-absent; and defined interactions with `notifications/resources/updated`, `ttlMs`, `cacheScope`, and `lastModified`. + +Moving content that does not fit inline in JSON-RPC (content transfer) and marking tool or elicitation inputs as files (file inputs) are the charter's other two workstreams. Resource operations need to stay compatible with both, and in particular with the open question of whether a transferred file is addressed as a Resource URI. diff --git a/docs/related-work.md b/docs/related-work.md index 45300ef..057a95c 100644 --- a/docs/related-work.md +++ b/docs/related-work.md @@ -11,7 +11,12 @@ SEPs, extensions, and discussions that Resource operations build on or must stay | [SEP-2532](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2532) | Open | `resources/stream`. To be reconciled with the transfer work. | | [SEP-2356](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2356) | Closed 2026-06-26 | File input model. Input to the file inputs workstream. | +## Extension framework + +- [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) (merged): extensions framework (naming, capability negotiation). Resource operations would ship as an extension under it. +- [SEP-3371](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3371) (open): consistent SDK extension points (extension registration, custom request and notification methods, middleware). The reference implementations here should be built on these extension points rather than on changes to the SDKs. + ## Extensions and groups -- [Skills extension](https://github.com/modelcontextprotocol/ext-skills): reads skill files through Resources and defines an optional directory read. The charter asks this group to coordinate on any Resource operation that affects skills. +- [Skills extension](https://github.com/modelcontextprotocol/ext-skills): reads skill files through Resources and defines an optional directory read (`resources/directory/read`). The charter asks this group to coordinate on any Resource operation that affects skills. - [Interceptors extension](https://github.com/modelcontextprotocol/experimental-ext-interceptors): repository layout this one follows for per-language reference implementations. diff --git a/docs/use-cases.md b/docs/use-cases.md new file mode 100644 index 0000000..afe6ccc --- /dev/null +++ b/docs/use-cases.md @@ -0,0 +1,19 @@ +# Use Cases + +Concrete situations Resource operations should serve. Add yours by pull request: describe who the client and server are, what they need to do, and what they do today instead. + +## Shared state written by several agents + +The charter names multi-agent and job orchestration systems as the motivating consumers of a shared write path. Several agents, or several runs of one agent, read and update the same Resources (plans, notes, task state). Each needs to know whether its write applied cleanly or raced another one. + +## Memory servers + +A memory server stores facts as files that an agent reads and rewrites. Today a write is a tool call that replaces the file, so two sessions updating the same memory lose one update without either noticing. A conditional write (update only if the version is still the one I read) and create-if-absent would make that safe, and `notifications/resources/updated` would tell other sessions to re-read. + +## Servers that ask for base64 content in prose + +Some servers ask the model to pass file content as base64 inside a tool argument, described in the tool's text. The charter calls for a migration guide away from this pattern, which depends on a real write path existing first. + +## Navigating large Resource trees + +A client working in a large repository or document store needs to list one directory, glob for matching paths, or read metadata (`stat`) without reading every Resource, then read only what it needs. diff --git a/python/sdk/README.md b/python/sdk/README.md index 6c73062..d5cd338 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -1,6 +1,6 @@ # MCP Filesystems Python SDK -Python reference implementation of the experimental MCP Resource operations extension, built on the [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) v2. +Python reference implementation of the experimental MCP Resource operations extension, built on the [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) v2. It is meant to plug in through the SDK's extension mechanism (the `Extension` and `MethodBinding` API used by the Apps extension), following [SEP-3371](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3371), rather than by patching the SDK. **Status:** Planned. This package is a placeholder until the group has a proposal to implement; see [open questions](../../docs/open-questions.md). From 79d67cf75d76f30a891a629c170fc469a56a1ebb Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:57:57 +0000 Subject: [PATCH 03/14] Add Resource operations strawman: stat, create, update, delete with version-based concurrency Draft spec in docs/proposals/resource-operations.md, a Proposed decision log entry, and implementation-independent conflict scenarios under conformance/. Listing reuses SEP-2640's resources/directory/read unchanged. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- conformance/README.md | 6 +- conformance/resource-operations-conflicts.md | 36 ++++ docs/decisions.md | 19 +- docs/implementations.md | 2 +- docs/proposals/resource-operations.md | 212 +++++++++++++++++++ 6 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 conformance/resource-operations-conflicts.md create mode 100644 docs/proposals/resource-operations.md diff --git a/README.md b/README.md index 7ac8b3d..875a432 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This repository holds the working documents and reference implementations for ** | [Problem Statement](docs/problem-statement.md) | What is missing from Resources today, and what the charter asks for | | [Use Cases](docs/use-cases.md) | Concrete situations Resource operations should serve | | [Open Questions](docs/open-questions.md) | Questions the group needs to settle, starting with the charter's | +| [Resource operations strawman](docs/proposals/resource-operations.md) | Draft `stat`, `create`, `update`, `delete` with version-based concurrency, for discussion | | [Decision Log](docs/decisions.md) | ADR-lite record of the group's decisions, and the vehicle for proposing changes | | [Related Work](docs/related-work.md) | SEPs, extensions, and discussions this work builds on | | [Implementations](docs/implementations.md) | Servers, SDKs, and hosts that implement Resource operations | @@ -27,7 +28,7 @@ Nothing under `docs/` is part of a specification. A specification will live unde | Language | Directory | Package | Status | | :--- | :--- | :--- | :--- | -| Python | `python/sdk/` | `mcp-ext-filesystems` | Planned | +| Python | `python/sdk/` | `mcp-ext-filesystems` | Prototype | The charter also lists a TypeScript SDK reference implementation ([@ochafik](https://github.com/ochafik)). diff --git a/conformance/README.md b/conformance/README.md index c1fb009..9c633f9 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -2,4 +2,8 @@ Conformance scenarios for each new Resource operation method and schema element, as the charter requires. Scenarios should run against any implementation, not only the ones in this repository, so that independent servers exercising the write path can be checked against a common client. -No scenarios yet. They arrive alongside the first proposal that defines a method. +| Scenarios | Covers | +| :--- | :--- | +| [resource-operations-conflicts.md](resource-operations-conflicts.md) | Create-if-absent and stale `ifMatch` from the [Resource operations strawman](../docs/proposals/resource-operations.md) | + +Scenarios are written as request and expectation tables for now. A runnable harness can come once the methods settle. diff --git a/conformance/resource-operations-conflicts.md b/conformance/resource-operations-conflicts.md new file mode 100644 index 0000000..4a9577f --- /dev/null +++ b/conformance/resource-operations-conflicts.md @@ -0,0 +1,36 @@ +# Scenarios: write conflicts + +Implementation-independent checks for the conflict behaviour in the [Resource operations strawman](../docs/proposals/resource-operations.md). A harness runs them against any server that declares `io.modelcontextprotocol/resource-operations`, using one or two client connections. + +Conventions: + +- `` is a URI the server accepts for writes and that does not exist at the start. The harness gets it from configuration. +- `` are versions captured from earlier responses. Versions are opaque: compare them only for equality, never parse them. +- "Precondition failed" means the error code assigned to precondition failures (provisionally `-32010`). +- Each step lists the request and the checks on its response. A step fails the scenario if any check fails. + +## RO-CONFLICT-1: create-if-absent + +| # | Connection | Request | Expect | +| :--- | :--- | :--- | :--- | +| 1 | A | `resources/create {uri: , contents: {uri: , text: "a"}}` | Result. `version` is a non-empty string; save as ``. `changes` contains `{uri: , effect: "created", version: }`. | +| 2 | B | `resources/create {uri: , contents: {uri: , text: "b"}}` | Error. Code is precondition failed. `data.uri` = ``, `data.reason` = `"alreadyExists"`, `data.currentVersion` = ``. | +| 3 | A | `resources/stat {uri: }` | Result. `version` = ``. The second create changed nothing. | + +## RO-CONFLICT-2: lost update + +The memory-server case from [use cases](../docs/use-cases.md): two sessions read the same version and both write it back. + +| # | Connection | Request | Expect | +| :--- | :--- | :--- | :--- | +| 1 | A | `resources/create {uri: , contents: {uri: , text: "0"}}` | Result. | +| 2 | A | `resources/stat {uri: }` | Result. Save `version` as ``. | +| 3 | B | `resources/stat {uri: }` | Result. `version` = ``. | +| 4 | A | `resources/update {uri: , contents: {uri: , text: "0A"}, ifMatch: }` | Result. Save `version` as ``; `` != ``. `changes` contains `{uri: , effect: "updated", previousVersion: , version: }`. | +| 5 | B | `resources/update {uri: , contents: {uri: , text: "0B"}, ifMatch: }` | Error. Code is precondition failed. `data.reason` = `"versionMismatch"`, `data.currentVersion` = ``. | +| 6 | B | `resources/stat {uri: }` | Result. `version` = ``. The rejected write changed nothing. | +| 7 | B | `resources/update {uri: , contents: {uri: , text: "0AB"}, ifMatch: }` | Result. `changes` contains `previousVersion: `. | +| 8 | B | `resources/delete {uri: , ifMatch: }` | Error. Code is precondition failed. `data.reason` = `"versionMismatch"`. | +| 9 | A | `resources/stat {uri: }` | Result. The resource still exists. | + +Optional, if the server reads back what it stores: after step 7, `resources/read {uri: }` returns text `"0AB"`. diff --git a/docs/decisions.md b/docs/decisions.md index 942cfa4..0e5d507 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,7 +4,7 @@ This document records significant decisions made by the Files Working Group abou For background on the ADR format, see [adr.github.io](https://adr.github.io/). -No decisions have been recorded yet. The first one the charter calls for is listed in [open-questions.md](open-questions.md). +No decisions have been accepted yet. The first one the charter calls for is listed in [open-questions.md](open-questions.md). ## Entry format @@ -26,3 +26,20 @@ Add new entries below this section, oldest first. A pull request that adds an en ``` --- + +### 2026-10-09: Strawman for Resource operations: stat, create, update, delete with version-based concurrency + +**Status:** Proposed + +**Context:** Workstream 2 of the [charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389) asks for create, update, delete, and `stat`, typed errors, and optimistic concurrency control on writes including create-if-absent ([open questions](open-questions.md) 2 to 6). Options considered for concurrency: no precondition (last writer wins), timestamps (`lastModified`), and an opaque server-minted version compared on write. For listing: a new method, or the `resources/directory/read` method already defined by the Skills extension (SEP-2640). + +**Decision:** Use [docs/proposals/resource-operations.md](proposals/resource-operations.md) as the group's starting strawman: an extension `io.modelcontextprotocol/resource-operations` (provisional) with `resources/stat`, `resources/create` (create-if-absent only), `resources/update` (requires `ifMatch`), and `resources/delete` (optional `ifMatch`); an opaque `version` as the concurrency token; a precondition-failed error carrying `currentVersion`; and reuse of `resources/directory/read` unchanged for listing. Search, glob, bulk reads, and ranges are deferred. + +**Rationale:** An opaque version is what HTTP ETags, S3, and GCS already use, survives coarse clocks, and lets each server choose a counter, digest, or backend generation. Required `ifMatch` on update rules out lost updates by omission. Reusing SEP-2640's method avoids two listing methods for the same tree. The vocabulary (`version`, change-set entries with `effect`) follows Kryspin Ziemski's working document shared in #filesystem-wg, so later Query and Range work can build on it. A Python prototype built on the SDK's `Extension`/`MethodBinding` API and conformance scenarios accompany the draft. + +**References:** +- [Proposal](proposals/resource-operations.md), [Python prototype](../python/sdk/), [conformance scenarios](../conformance/) +- [SEP-2571](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2571) (closed): `resources/create` and `resources/delete` +- [SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640): Skills extension, `resources/directory/read` +- [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133), [SEP-3371](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3371): extensions framework and SDK extension points +- Kryspin Ziemski's working document "MCP Resources as Addressable Domain State", shared in #filesystem-wg diff --git a/docs/implementations.md b/docs/implementations.md index 20eac23..31c68ea 100644 --- a/docs/implementations.md +++ b/docs/implementations.md @@ -10,7 +10,7 @@ To add or update a row, open a pull request. Keep notes to one line. | Implementation | Language | Status | Notes | | :--- | :--- | :--- | :--- | -| [`python/sdk/`](../python/sdk/) | Python | `planned` | Reference implementation in this repository | +| [`python/sdk/`](../python/sdk/) | Python | `prototype` | Reference implementation of the [strawman](proposals/resource-operations.md) in this repository | ## Servers diff --git a/docs/proposals/resource-operations.md b/docs/proposals/resource-operations.md new file mode 100644 index 0000000..b7897c3 --- /dev/null +++ b/docs/proposals/resource-operations.md @@ -0,0 +1,212 @@ +# Resource operations: strawman + +**Status:** Draft strawman for discussion. Not accepted by the Files Working Group, not part of any specification. Written to be torn apart. + +**Decision log entry:** [2026-10-09](../decisions.md#2026-10-09-strawman-for-resource-operations-stat-create-update-delete-with-version-based-concurrency) +**Prototype:** [`python/sdk/`](../../python/sdk/) · **Conformance scenarios:** [`conformance/`](../../conformance/) + +## Motivation + +Resources can be listed and read, but not created, updated, deleted, or inspected without reading the content ([problem statement](../problem-statement.md)). Servers that need writes expose them as tools with ad hoc names, arguments, and errors, and nothing stops two writers from overwriting each other ([use cases](../use-cases.md): shared state written by several agents, memory servers). + +This strawman covers the smallest slice of workstream 2 that fixes the write path: a metadata read (`resources/stat`), `resources/create` with create-if-absent semantics, `resources/update` conditional on a version, `resources/delete` with an optional version check, typed errors, and how these interact with existing cache and notification fields. Listing reuses `resources/directory/read` from SEP-2640. Search, glob, bulk reads, and ranges are deferred. + +Vocabulary follows Kryspin Ziemski's working document shared in #filesystem-wg ("MCP Resources as Addressable Domain State"), which proposes Create, Read, Update, Delete, Query, and Range (CRUDQR) over Resources and a change-set entry `{uri, effect, previousVersion?, version?}`. This draft covers C, U, D, plus `stat`, and uses `version` as the concurrency token and the change-set entry in write results. Query and Range are deferred. + +## Extension identifier and capability negotiation + +Identifier (provisional): `io.modelcontextprotocol/resource-operations`. + +A server that supports the extension declares it under `capabilities.extensions` ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)) and also declares the `resources` capability: + +```json +{ + "capabilities": { + "resources": {}, + "extensions": { + "io.modelcontextprotocol/resource-operations": { "directoryRead": true } + } + } +} +``` + +| Setting | Type | Default | Meaning | +| :--- | :--- | :--- | :--- | +| `directoryRead` | boolean | `false` | The server serves `resources/directory/read` as defined by SEP-2640. | + +Declaring the extension commits the server to serving all four methods below. A server that cannot perform an operation on a given URI returns `notSupported` for that URI. Clients MUST NOT call these methods on a server that did not declare the extension. No new methods are added to core, and no existing method changes type: per the core-maintainer meeting on 2026-10-07, extensions may add methods but must not change the types of existing ones. + +## Versions + +A `version` is an opaque string minted by the server for the current state of one resource. It plays the role of an HTTP strong ETag. + +- Clients compare versions only for exact string equality. Versions have no order. +- A version MUST change whenever the content or `mimeType` of the resource changes. A version MUST NOT be reused for a different state of the same URI, including after a delete and re-create. A digest over the content and `mimeType` is a valid version. +- Versions are scoped to one server and one URI. +- Returned by `resources/stat`, `resources/create`, and `resources/update`. Core shapes that this extension may not retype carry it in `_meta` under `io.modelcontextprotocol/resource-version`: entries in a `resources/directory/read` result (`Resource._meta`), and, when the server can provide it, each item of a `resources/read` result (`contents[]._meta`). The `_meta` key name is provisional. +- A directory resource's version SHOULD change when its set of direct children changes. + +## Methods + +Content in `create` and `update` uses the core `TextResourceContents` or `BlobResourceContents` shape, the same one `resources/read` returns. `contents.uri` MUST equal `uri`. Content travels inline; moving large content is the content transfer workstream's job. + +### `resources/stat` + +Returns metadata and the current version without the content. The result is a core `Resource` plus `version`, and carries the SEP-2549 cache fields like other cacheable results. + +```json +{ "method": "resources/stat", "params": { "uri": "mem://notes/plan.md" } } +``` +```json +{ + "resource": { + "uri": "mem://notes/plan.md", "name": "plan.md", "mimeType": "text/markdown", "size": 7, + "annotations": { "lastModified": "2026-10-09T14:02:11Z" } + }, + "version": "17", + "ttlMs": 0, + "cacheScope": "private" +} +``` + +A directory resource stats with `mimeType: "inode/directory"`, as in SEP-2640. + +### `resources/create` + +Creates the resource at `uri` only if nothing exists there (create-if-absent). If the URI is taken, the server returns `alreadyExists` with the current version and changes nothing. There is no upsert: a client that wants to overwrite reads the version from the error and calls `resources/update`. + +```json +{ + "method": "resources/create", + "params": { + "uri": "mem://notes/plan.md", + "contents": { "uri": "mem://notes/plan.md", "mimeType": "text/markdown", "text": "# Plan\n" } + } +} +``` +```json +{ + "resource": { "uri": "mem://notes/plan.md", "name": "plan.md", "mimeType": "text/markdown", "size": 7 }, + "version": "17", + "changes": [{ "uri": "mem://notes/plan.md", "effect": "created", "version": "17" }] +} +``` + +The client chooses the URI. SEP-2571 instead had the server assign the URI; that variant is an open question below. + +### `resources/update` + +Replaces the content of an existing resource, only if its current version equals `ifMatch`. `ifMatch` is required, so every update is conditional and a lost update cannot happen by omission. The result reports the previous and new versions. + +```json +{ + "method": "resources/update", + "params": { + "uri": "mem://notes/plan.md", + "contents": { "uri": "mem://notes/plan.md", "mimeType": "text/markdown", "text": "# Plan\n- ship\n" }, + "ifMatch": "17" + } +} +``` +```json +{ + "resource": { "uri": "mem://notes/plan.md", "name": "plan.md", "mimeType": "text/markdown", "size": 14 }, + "version": "18", + "changes": [{ "uri": "mem://notes/plan.md", "effect": "updated", "previousVersion": "17", "version": "18" }] +} +``` + +Update is full replacement. Partial updates (patch, append, range writes) are deferred. + +### `resources/delete` + +Deletes the resource. With `ifMatch`, only if the current version matches. Deleting a directory resource is out of scope for this draft (`notSupported`). + +```json +{ "method": "resources/delete", "params": { "uri": "mem://notes/plan.md", "ifMatch": "18" } } +``` +```json +{ "changes": [{ "uri": "mem://notes/plan.md", "effect": "deleted", "previousVersion": "18" }] } +``` + +### Change sets + +Each write result carries `changes`, a list of change-set entries in the vocabulary of Kryspin's document. It MUST contain an entry for the target URI and MAY contain entries for other resources the operation affected. This draft uses only `created`, `updated`, and `deleted`; `invalidated` and `affected` are left to a later draft. `version` duplicates the target entry's `version` for convenience; whether to keep both is open. + +### Concurrency + +The server MUST compare the version and apply the write as one atomic step. A check followed by a separate write reintroduces the lost update this mechanism exists to prevent. + +The lost-update case from [use cases](../use-cases.md) plays out as follows. Two sessions stat `plan.md` at version `17`. Session A updates with `ifMatch: "17"` and gets version `18`. Session B updates with `ifMatch: "17"` and gets `versionMismatch` with `currentVersion: "18"`. B re-reads, merges, and retries with `ifMatch: "18"`. + +### Listing: reuse `resources/directory/read` + +The Skills extension ([SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640), Final) already defines `resources/directory/read`: request `{uri, cursor?}`, result `{resources: Resource[], nextCursor?}` listing direct children, with subdirectories as `inode/directory` resources and `-32602` for a missing or non-directory URI. SEP-2640 states that the method is not skill-specific and that a server MAY support it on any directory resource under any scheme. + +This draft reuses that method unchanged and adds no listing method of its own. The only addition is a way to advertise it without the Skills extension: SEP-2640 gates the method behind `directoryRead` under `io.modelcontextprotocol/skills`, and declaring that extension commits a server to `skills/list` and `skills/get`, which a plain file server cannot honor. So this extension carries its own `directoryRead` setting with the same meaning. A client may call the method if either extension declares `directoryRead: true`. Entries carry their version in `_meta` (see Versions), which also gives SEP-2640 hosts the snapshot token that SEP-2640 notes it lacks. + +The cleaner long-term shape is probably for one definition to live in one place (this extension, or core) with Skills referencing it. That is for the Skills Over MCP WG and this WG to agree on ([open question 6](../open-questions.md)). + +### Deferred + +- **Search and glob (Query).** The query language and glob semantics over URIs (as opposed to paths) are unsettled; Kryspin's document lists the query language as open. Better to land the write path first than to block it on a query design. +- **Recursive traversal.** Clients descend with repeated `resources/directory/read` calls. A recursive variant needs limits and pagination rules that are easier to set once listing is settled. +- **Bulk reads.** Their size limits and failure modes depend on the content transfer workstream. +- **Range.** Partial reads and writes need range coordinates (bytes, lines, records) and overlap with `resources/stream` (SEP-2532), which the charter assigns to content transfer. + +## Errors + +| Condition | Code | `data.reason` | Other `data` | +| :--- | :--- | :--- | :--- | +| Resource does not exist | `-32602` Invalid params | `notFound` | `uri` | +| URI malformed, `contents.uri` mismatch, or not a directory (`resources/directory/read`) | `-32602` Invalid params | `invalidUri` | `uri` | +| `ifMatch` does not match | `-32010` (provisional) | `versionMismatch` | `uri`, `currentVersion` | +| `resources/create` on an existing URI | `-32010` (provisional) | `alreadyExists` | `uri`, `currentVersion` | +| Operation not supported for this URI (read-only scheme, directory, ancestor is a file) | `-32011` (provisional) | `notSupported` | `uri` | +| Extension not declared, or wrong protocol version | `-32601` Method not found | | | + +Not found reuses `-32602` because core `resources/read` (2026-07-28) and SEP-2640's `resources/directory/read` already use it for unknown resources; `data.reason` lets a client tell it apart from other invalid params without a new code. Version mismatch and already-exists share one code because both mean "your precondition about the current state was wrong" (HTTP 412 covers `If-Match` and `If-None-Match: *` alike) and both carry the current version for recovery. + +The two new codes are placeholders in the implementation-defined band. The 2026-07-28 allocation policy reserves `-32020..-32099` for spec-defined codes allocated in sequence, `-32000` and `-32001` are used by the Python SDK internally, and `-32002` is retired. Where extension error codes should come from is an open question. + +## Interactions + +**`notifications/resources/updated`.** After a successful update or delete, the server SHOULD send `notifications/resources/updated` for the URI to clients subscribed to it (through `subscriptions/listen` with `resourceSubscriptions` on 2026-07-28, or `resources/subscribe` on earlier versions). After a create or delete, it SHOULD send `notifications/resources/list_changed` if it declared `listChanged`. The writer may receive its own notification; it can compare its result's `version` with a fresh `stat`. Open: whether the notification should carry the change-set entry in `_meta` so subscribers can skip the `stat`. + +**`lastModified`.** Servers that track modification time MUST set `annotations.lastModified` on the `Resource` returned by `stat` and by writes, and update it on every write. It is informational only and MUST NOT be used as a precondition: timestamps have coarse resolution and clocks skew, so two writes can share one. + +**`ttlMs`.** `resources/stat` results carry `ttlMs` as a freshness hint for metadata, as SEP-2549 defines for other results. A cached version is only ever a guess: the conditional write is the check, and a stale version fails safely with `versionMismatch`. A client SHOULD discard cached reads and stats of a URI after its own successful write to it or an updated notification for it. Write results are not cacheable. + +**`cacheScope`.** `resources/stat` defaults to `"private"`. A version reveals that and when a resource changed, so a server whose resources differ per user MUST NOT mark `stat` results `"public"`. Open: whether `"public"` should be allowed at all for `stat`. + +## SDK extension points + +The Python prototype is built only on the MCP Python SDK's public `Extension` and `MethodBinding` API (`mcp>=2,<3`, tested on 2.3.0), per [SEP-3371](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3371), without patching the SDK. It works, with these gaps, offered as feedback for SEP-3371: + +1. **Extension method handlers cannot publish change events.** A `MethodBinding` handler receives a `ServerRequestContext`, which has no handle on the server's `SubscriptionBus`; only the `MCPServer`-level `Context` has `notify_resource_updated`. On 2026-07-28 connections, `session.send_resource_updated` is dropped by design (change notifications travel only on `subscriptions/listen` streams). The prototype works around this by having the server author pass the same bus to both `MCPServer(subscriptions=bus)` and the extension. A bus or notify API on the handler context would remove that wiring. +2. **Events carry no metadata.** `ResourceUpdated` holds only `uri`, so the `_meta` of `notifications/resources/updated` cannot carry a version or change-set entry through the bus, even though the wire notification allows `_meta`. +3. **Extensions cannot contribute to `resources/read`.** `MethodBinding` rejects spec methods (correctly, given the 2026-10-07 rule), `ResourceBinding` takes only static resources, resource templates take one static `meta` per template, and an `Extension` cannot contribute middleware. So the extension can neither make store-backed resources readable through `resources/read` nor stamp a per-read version into `contents[]._meta`; the server author has to register a template. A per-read hook for `_meta`, or letting an extension contribute a resource provider, would close this. +4. **One method, two extensions.** `MCPServer` raises if two extensions bind the same method. A server running both Skills (with `read_directory`) and this extension must bind `resources/directory/read` in exactly one of them; the prototype makes it opt-in (`directory_read=True`) for that reason. Shared methods across extensions need an ownership rule. +5. **Legacy wire.** `capabilities.extensions` exists only on 2026-07-28 in this SDK, and `MCPServer` does not serve `resources/subscribe`, which an extension cannot bind. The prototype therefore serves its methods on 2026-07-28 only. + +## Open questions + +Numbers refer to [open-questions.md](../open-questions.md); "new" marks questions this draft raises. + +- **#1** Files as Resource URIs: not addressed (see below). This draft assumes `resources/*` and would be renamed if the group chooses a `files/*` family. +- **#2** Concurrency: this draft proposes an opaque `version`, required `ifMatch` on update, optional on delete, and create-if-absent as the only create. Alternatives: optional `ifMatch` on update (unconditional overwrite allowed); HTTP-style `ifNoneMatch: "*"` on a single write method instead of separate create and update. +- **#3** Interactions: proposed above. Open: change-set entry in notification `_meta`; `cacheScope: "public"` for `stat`; whether `resources/read` SHOULD or MUST carry the version in `_meta`, and the key name. +- **#4** Typed errors: proposed above. Open: where extension error codes are allocated; whether `notFound` deserves its own code despite core's `-32602`. +- **#5** One SEP or several: this draft suggests one SEP for `stat` and writes, with listing settled jointly with Skills, and Query and Range in a later SEP. +- **#6** Skills directory read: reuse as-is with a second `directoryRead` flag. Open: whether the definition moves here or to core. +- **New** Server-assigned URIs (SEP-2571 style, create in a collection) alongside client-chosen URIs. +- **New** Whether a server can declare `stat` without writes (a read-only setting) instead of answering `notSupported` per URI. +- **New** The extension identifier and `_meta` key names, and whether experimental builds should use a different prefix. +- **New** Whether `version` and `changes` both belong in write results. + +## Not addressed + +- **Content transfer.** Content travels inline. Out-of-band transfer, digests, size limits, and streaming belong to workstream 1; `create` and `update` will need a way to reference transferred content once that exists. +- **File inputs.** Marking tool or elicitation inputs as files is workstream 3. Nothing here depends on it. +- **Whether files are Resource URIs.** The charter's cross-cutting decision ([open question 1](../open-questions.md)). This draft operates on Resource URIs because that is what exists today; it takes no position on the decision. +- **Authorization.** Who may write what is out of the charter's scope beyond stating that the existing MCP authorization specification applies. From 6940b5a4274d5cfc9f441cb116ce788d30faa2b5 Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:57:57 +0000 Subject: [PATCH 04/14] Prototype the strawman on the Python SDK's Extension/MethodBinding API Wire types, typed errors, a pluggable store with an in-memory implementation, the server extension, and client helpers. No SDK patches; gaps are listed in the proposal's SDK extension points section. Co-Authored-By: Claude Opus 5.5 --- python/sdk/README.md | 42 ++++- .../sdk/src/mcp_ext_filesystems/__init__.py | 15 ++ python/sdk/src/mcp_ext_filesystems/client.py | 89 +++++++++ python/sdk/src/mcp_ext_filesystems/errors.py | 93 +++++++++ python/sdk/src/mcp_ext_filesystems/server.py | 127 +++++++++++++ python/sdk/src/mcp_ext_filesystems/store.py | 177 ++++++++++++++++++ python/sdk/src/mcp_ext_filesystems/wire.py | 125 +++++++++++++ 7 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 python/sdk/src/mcp_ext_filesystems/client.py create mode 100644 python/sdk/src/mcp_ext_filesystems/errors.py create mode 100644 python/sdk/src/mcp_ext_filesystems/server.py create mode 100644 python/sdk/src/mcp_ext_filesystems/store.py create mode 100644 python/sdk/src/mcp_ext_filesystems/wire.py diff --git a/python/sdk/README.md b/python/sdk/README.md index d5cd338..5bd1d7e 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -2,7 +2,47 @@ Python reference implementation of the experimental MCP Resource operations extension, built on the [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) v2. It is meant to plug in through the SDK's extension mechanism (the `Extension` and `MethodBinding` API used by the Apps extension), following [SEP-3371](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3371), rather than by patching the SDK. -**Status:** Planned. This package is a placeholder until the group has a proposal to implement; see [open questions](../../docs/open-questions.md). +**Status:** Prototype of the [Resource operations strawman](../../docs/proposals/resource-operations.md). Expect every name and code to change. + +It serves `resources/stat`, `resources/create`, `resources/update`, `resources/delete`, and optionally SEP-2640's `resources/directory/read`, on the 2026-07-28 protocol version, through `Extension`/`MethodBinding` only. + +## Usage + +Server: back the extension with a `ResourceStore` (an in-memory one is included). Pass the same `SubscriptionBus` to the server and the extension so writes reach `subscriptions/listen` subscribers. + +```python +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import InMemorySubscriptionBus + +from mcp_ext_filesystems import InMemoryStore, ResourceOperations + +bus = InMemorySubscriptionBus() +server = MCPServer( + "notes", + subscriptions=bus, + extensions=[ResourceOperations(InMemoryStore(), subscriptions=bus, directory_read=True)], +) +``` + +Client: wrap a connected session. Errors arrive as typed exceptions. + +```python +from mcp import Client +from mcp_types import TextResourceContents + +from mcp_ext_filesystems import ResourceOperationsClient, VersionMismatch + +uri = "mem://notes/plan.md" +async with Client(server) as client: + ops = ResourceOperationsClient(client.session) + created = await ops.create(uri, TextResourceContents(uri=uri, text="# Plan\n")) + try: + await ops.update(uri, TextResourceContents(uri=uri, text="# Plan\n- ship\n"), if_match=created.version) + except VersionMismatch as conflict: + ... # someone else wrote first: re-read, merge, retry with conflict.current_version +``` + +Store-backed resources are not readable through `resources/read` unless the server registers a resource template for them; see "SDK extension points" in the proposal. ## Development diff --git a/python/sdk/src/mcp_ext_filesystems/__init__.py b/python/sdk/src/mcp_ext_filesystems/__init__.py index 78e279e..2e43578 100644 --- a/python/sdk/src/mcp_ext_filesystems/__init__.py +++ b/python/sdk/src/mcp_ext_filesystems/__init__.py @@ -2,4 +2,19 @@ from importlib.metadata import version +from mcp_ext_filesystems.client import ResourceOperationsClient +from mcp_ext_filesystems.errors import ( + NOT_SUPPORTED, + PRECONDITION_FAILED, + AlreadyExists, + InvalidResourceUri, + OperationNotSupported, + ResourceNotFound, + ResourceOperationError, + VersionMismatch, +) +from mcp_ext_filesystems.server import ResourceOperations +from mcp_ext_filesystems.store import InMemoryStore, ResourceStore, Stat +from mcp_ext_filesystems.wire import EXTENSION_ID, VERSION_META_KEY, ResourceChange + __version__ = version("mcp-ext-filesystems") diff --git a/python/sdk/src/mcp_ext_filesystems/client.py b/python/sdk/src/mcp_ext_filesystems/client.py new file mode 100644 index 0000000..8fedcf0 --- /dev/null +++ b/python/sdk/src/mcp_ext_filesystems/client.py @@ -0,0 +1,89 @@ +"""Client side: typed helpers over a connected `ClientSession`. + + async with Client(server) as client: + ops = ResourceOperationsClient(client.session) + stat = await ops.stat("mem://notes/plan.md") + await ops.update("mem://notes/plan.md", TextResourceContents(uri=..., text="..."), if_match=stat.version) + +Wire errors come back as the typed exceptions in `mcp_ext_filesystems.errors`. +""" + +from __future__ import annotations + +from typing import Any, TypeVar + +from mcp.client.session import ClientSession +from mcp.shared.exceptions import MCPError +from mcp_types import Request, Resource, Result + +from mcp_ext_filesystems.errors import from_mcp_error +from mcp_ext_filesystems.wire import ( + EXTENSION_ID, + CreateParams, + CreateRequest, + CreateResult, + DeleteParams, + DeleteRequest, + DeleteResult, + ReadDirectoryParams, + ReadDirectoryRequest, + ReadDirectoryResult, + ResourceContents, + StatParams, + StatRequest, + StatResult, + UpdateParams, + UpdateRequest, + UpdateResult, +) + +ResultT = TypeVar("ResultT", bound=Result) + + +class ResourceOperationsClient: + def __init__(self, session: ClientSession) -> None: + self._session = session + + def supported(self) -> bool: + """Whether the server advertised the extension.""" + return self._settings() is not None + + async def stat(self, uri: str) -> StatResult: + return await self._send(StatRequest(params=StatParams(uri=uri)), StatResult) + + async def create(self, uri: str, contents: ResourceContents) -> CreateResult: + return await self._send(CreateRequest(params=CreateParams(uri=uri, contents=contents)), CreateResult) + + async def update(self, uri: str, contents: ResourceContents, *, if_match: str) -> UpdateResult: + params = UpdateParams(uri=uri, contents=contents, if_match=if_match) + return await self._send(UpdateRequest(params=params), UpdateResult) + + async def delete(self, uri: str, *, if_match: str | None = None) -> DeleteResult: + return await self._send(DeleteRequest(params=DeleteParams(uri=uri, if_match=if_match)), DeleteResult) + + async def read_directory(self, uri: str) -> list[Resource]: + """Call `resources/directory/read`, following `nextCursor` to the end.""" + if not (self._settings() or {}).get("directoryRead"): + raise RuntimeError(f"server does not advertise {EXTENSION_ID!r} with directoryRead") + resources: list[Resource] = [] + cursor: str | None = None + while True: + request = ReadDirectoryRequest(params=ReadDirectoryParams(uri=uri, cursor=cursor)) + page = await self._send(request, ReadDirectoryResult) + resources.extend(page.resources) + if page.next_cursor is None: + return resources + cursor = page.next_cursor + + def _settings(self) -> dict[str, object] | None: + capabilities = self._session.server_capabilities + extensions = capabilities.extensions if capabilities else None + return (extensions or {}).get(EXTENSION_ID) + + async def _send(self, request: Request[Any, Any], result_type: type[ResultT]) -> ResultT: + if not self.supported(): + raise RuntimeError(f"server does not advertise {EXTENSION_ID!r}") + try: + return await self._session.send_request(request, result_type) + except MCPError as error: + raise from_mcp_error(error) from None diff --git a/python/sdk/src/mcp_ext_filesystems/errors.py b/python/sdk/src/mcp_ext_filesystems/errors.py new file mode 100644 index 0000000..64a0ec6 --- /dev/null +++ b/python/sdk/src/mcp_ext_filesystems/errors.py @@ -0,0 +1,93 @@ +"""Typed errors for Resource operations. + +Not found and invalid URI reuse `-32602` (Invalid params), the code core `resources/read` and SEP-2640's +`resources/directory/read` use for unknown resources. Precondition failures and unsupported operations need +codes of their own; the values below are provisional placeholders in the implementation-defined band +(-32000..-32019), pending allocation. Every error carries `data.uri` and `data.reason`. +""" + +from __future__ import annotations + +from typing import Any + +from mcp.shared.exceptions import MCPError +from mcp_types import INVALID_PARAMS + +PRECONDITION_FAILED = -32010 +"""Provisional: `ifMatch` did not match, or `resources/create` found the URI taken. `data.currentVersion` is set.""" + +NOT_SUPPORTED = -32011 +"""Provisional: the server does not support this operation on this URI.""" + + +class ResourceOperationError(MCPError): + """Base class. `reason` is the machine-readable discriminator sent as `data.reason`.""" + + code_value: int = INVALID_PARAMS + reason: str = "" + + def __init__(self, uri: str, message: str, *, current_version: str | None = None) -> None: + data: dict[str, Any] = {"uri": uri, "reason": self.reason} + if current_version is not None: + data["currentVersion"] = current_version + super().__init__(code=self.code_value, message=message, data=data) + self.uri = uri + self.current_version = current_version + + +class ResourceNotFound(ResourceOperationError): + reason = "notFound" + + def __init__(self, uri: str) -> None: + super().__init__(uri, f"Resource not found: {uri}") + + +class InvalidResourceUri(ResourceOperationError): + reason = "invalidUri" + + def __init__(self, uri: str, detail: str = "not a valid resource URI") -> None: + super().__init__(uri, f"Invalid resource URI {uri!r}: {detail}") + + +class VersionMismatch(ResourceOperationError): + code_value = PRECONDITION_FAILED + reason = "versionMismatch" + + def __init__(self, uri: str, current_version: str) -> None: + super().__init__(uri, f"ifMatch does not match the current version of {uri}", current_version=current_version) + + +class AlreadyExists(ResourceOperationError): + code_value = PRECONDITION_FAILED + reason = "alreadyExists" + + def __init__(self, uri: str, current_version: str) -> None: + super().__init__(uri, f"Resource already exists: {uri}", current_version=current_version) + + +class OperationNotSupported(ResourceOperationError): + code_value = NOT_SUPPORTED + reason = "notSupported" + + def __init__(self, uri: str, detail: str = "operation not supported for this URI") -> None: + super().__init__(uri, f"{detail}: {uri}") + + +def from_mcp_error(error: MCPError) -> MCPError: + """Map a wire error back to its typed exception; return `error` unchanged if it is not one of ours.""" + data = error.data if isinstance(error.data, dict) else {} + uri, reason, current = data.get("uri"), data.get("reason"), data.get("currentVersion") + if not isinstance(uri, str): + return error + if error.code == INVALID_PARAMS and reason == "notFound": + return ResourceNotFound(uri) + if error.code == INVALID_PARAMS and reason == "invalidUri": + return InvalidResourceUri(uri) + if error.code == PRECONDITION_FAILED and isinstance(current, str): + if reason == "versionMismatch": + return VersionMismatch(uri, current) + if reason == "alreadyExists": + return AlreadyExists(uri, current) + if error.code == NOT_SUPPORTED: + return OperationNotSupported(uri) + return error diff --git a/python/sdk/src/mcp_ext_filesystems/server.py b/python/sdk/src/mcp_ext_filesystems/server.py new file mode 100644 index 0000000..9e69620 --- /dev/null +++ b/python/sdk/src/mcp_ext_filesystems/server.py @@ -0,0 +1,127 @@ +"""Server side: the Resource operations extension, registered through the SDK's `Extension`/`MethodBinding` API. + + store = InMemoryStore() + bus = InMemorySubscriptionBus() + server = MCPServer("notes", subscriptions=bus, extensions=[ResourceOperations(store, subscriptions=bus)]) + +Pass the same `SubscriptionBus` to `MCPServer` and to the extension so writes reach `subscriptions/listen` +streams. Extension handlers receive no handle on the server's bus; see "SDK extension points" in the proposal. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any +from urllib.parse import urlsplit + +from mcp.server.context import HandlerResult, ServerRequestContext +from mcp.server.extension import Extension, MethodBinding +from mcp.server.subscriptions import ResourcesListChanged, ResourceUpdated, SubscriptionBus +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +from mcp_ext_filesystems.errors import InvalidResourceUri +from mcp_ext_filesystems.store import ResourceStore +from mcp_ext_filesystems.wire import ( + EXTENSION_ID, + METHOD_CREATE, + METHOD_DELETE, + METHOD_READ_DIRECTORY, + METHOD_STAT, + METHOD_UPDATE, + CreateParams, + CreateResult, + DeleteParams, + DeleteResult, + ReadDirectoryParams, + ReadDirectoryResult, + ResourceChange, + StatParams, + StatResult, + UpdateParams, + UpdateResult, +) + +# `capabilities.extensions` only exists on the 2026-07-28 wire, so the methods are served there only. +_VERSIONS = frozenset(MODERN_PROTOCOL_VERSIONS) + + +class ResourceOperations(Extension): + """Serve `resources/stat`, `create`, `update`, `delete`, and optionally `resources/directory/read`. + + `directory_read=True` binds SEP-2640's `resources/directory/read` and advertises `directoryRead`. Leave it + off on a server that also runs the Skills extension with directory reads: the SDK lets one extension bind a + method, and binding it twice fails at construction. + """ + + identifier = EXTENSION_ID + + def __init__( + self, + store: ResourceStore, + *, + subscriptions: SubscriptionBus | None = None, + directory_read: bool = False, + ) -> None: + self._store = store + self._bus = subscriptions + self._directory_read = directory_read + + def settings(self) -> dict[str, Any]: + return {"directoryRead": True} if self._directory_read else {} + + def methods(self) -> Sequence[MethodBinding]: + bindings = [ + MethodBinding(METHOD_STAT, StatParams, self._stat, _VERSIONS), + MethodBinding(METHOD_CREATE, CreateParams, self._create, _VERSIONS), + MethodBinding(METHOD_UPDATE, UpdateParams, self._update, _VERSIONS), + MethodBinding(METHOD_DELETE, DeleteParams, self._delete, _VERSIONS), + ] + if self._directory_read: + bindings.append(MethodBinding(METHOD_READ_DIRECTORY, ReadDirectoryParams, self._read_directory, _VERSIONS)) + return bindings + + async def _stat(self, ctx: ServerRequestContext[Any, Any], params: StatParams) -> HandlerResult: + _check_uri(params.uri) + stat = await self._store.stat(params.uri) + return StatResult(resource=stat.resource, version=stat.version) + + async def _create(self, ctx: ServerRequestContext[Any, Any], params: CreateParams) -> HandlerResult: + _check_uri(params.uri, params.contents.uri) + stat = await self._store.create(params.uri, params.contents) + await self._publish(ResourcesListChanged()) + change = ResourceChange(uri=params.uri, effect="created", version=stat.version) + return CreateResult(resource=stat.resource, version=stat.version, changes=[change]) + + async def _update(self, ctx: ServerRequestContext[Any, Any], params: UpdateParams) -> HandlerResult: + _check_uri(params.uri, params.contents.uri) + previous, stat = await self._store.update(params.uri, params.contents, params.if_match) + await self._publish(ResourceUpdated(uri=params.uri)) + change = ResourceChange(uri=params.uri, effect="updated", previous_version=previous, version=stat.version) + return UpdateResult(resource=stat.resource, version=stat.version, changes=[change]) + + async def _delete(self, ctx: ServerRequestContext[Any, Any], params: DeleteParams) -> HandlerResult: + _check_uri(params.uri) + previous = await self._store.delete(params.uri, params.if_match) + await self._publish(ResourceUpdated(uri=params.uri)) + await self._publish(ResourcesListChanged()) + return DeleteResult(changes=[ResourceChange(uri=params.uri, effect="deleted", previous_version=previous)]) + + async def _read_directory(self, ctx: ServerRequestContext[Any, Any], params: ReadDirectoryParams) -> HandlerResult: + _check_uri(params.uri) + return ReadDirectoryResult(resources=await self._store.read_directory(params.uri)) + + async def _publish(self, event: ResourceUpdated | ResourcesListChanged) -> None: + if self._bus is not None: + await self._bus.publish(event) + + +def _check_uri(uri: str, contents_uri: str | None = None) -> None: + parts = urlsplit(uri) + if not parts.scheme or "://" not in uri: + raise InvalidResourceUri(uri, "not an absolute URI") + if parts.fragment or uri.endswith("/"): + raise InvalidResourceUri(uri, "fragments and trailing slashes are not allowed") + if any(segment in (".", "..") for segment in parts.path.split("/")): + raise InvalidResourceUri(uri, "dot segments are not allowed") + if contents_uri is not None and contents_uri != uri: + raise InvalidResourceUri(uri, f"contents.uri {contents_uri!r} does not match") diff --git a/python/sdk/src/mcp_ext_filesystems/store.py b/python/sdk/src/mcp_ext_filesystems/store.py new file mode 100644 index 0000000..0d9a093 --- /dev/null +++ b/python/sdk/src/mcp_ext_filesystems/store.py @@ -0,0 +1,177 @@ +"""Pluggable storage behind the extension, plus an in-memory implementation for tests and demos.""" + +from __future__ import annotations + +import base64 +import hashlib +from dataclasses import dataclass +from datetime import datetime, timezone +from itertools import count +from typing import Protocol + +from mcp_types import Annotations, BlobResourceContents, Resource + +from mcp_ext_filesystems.errors import ( + AlreadyExists, + InvalidResourceUri, + OperationNotSupported, + ResourceNotFound, + VersionMismatch, +) +from mcp_ext_filesystems.wire import DIRECTORY_MIME_TYPE, VERSION_META_KEY, ResourceContents + + +@dataclass(frozen=True) +class Stat: + """A resource's metadata and its current version.""" + + resource: Resource + version: str + + +class ResourceStore(Protocol): + """Backend for the extension. + + Each write MUST compare the version and apply the change atomically: the extension does not hold a lock + between a check and a write, so a store that checks then writes in two steps reintroduces lost updates. + Methods raise the typed errors in `mcp_ext_filesystems.errors`. + """ + + async def stat(self, uri: str) -> Stat: + """Raise `ResourceNotFound` if `uri` does not exist.""" + ... + + async def create(self, uri: str, contents: ResourceContents) -> Stat: + """Create `uri` only if it does not exist; raise `AlreadyExists` with the current version otherwise.""" + ... + + async def update(self, uri: str, contents: ResourceContents, if_match: str) -> tuple[str, Stat]: + """Replace `uri` only if its version equals `if_match`. Return the previous version and the new stat.""" + ... + + async def delete(self, uri: str, if_match: str | None) -> str: + """Delete `uri`, conditionally if `if_match` is set. Return the deleted version.""" + ... + + async def read_directory(self, uri: str) -> list[Resource]: + """Return the direct children of directory `uri`.""" + ... + + +@dataclass(frozen=True) +class _Entry: + contents: ResourceContents + version: str + last_modified: str + + +class InMemoryStore: + """A flat map from URI to contents. Directories are implicit: a URI is a directory if it has descendants. + + Versions come from one counter shared by every URI, so a deleted and re-created resource never repeats a + version. Writes contain no `await`, which makes each compare-and-write atomic on a single event loop. + """ + + def __init__(self, scheme: str = "mem") -> None: + self._scheme = scheme + self._entries: dict[str, _Entry] = {} + self._versions = count(1) + + async def stat(self, uri: str) -> Stat: + return self._stat(uri) + + def _stat(self, uri: str) -> Stat: + entry = self._entries.get(uri) + if entry is not None: + return Stat(_describe(uri, entry), entry.version) + children = self._children(uri) + if not children: + raise ResourceNotFound(uri) + # A directory's version changes whenever the set of its direct children or any child's version changes. + digest = hashlib.sha256(repr(sorted((c.uri, (c.meta or {}).get(VERSION_META_KEY)) for c in children)).encode()) + return Stat(_directory(uri), "d-" + digest.hexdigest()[:16]) + + async def create(self, uri: str, contents: ResourceContents) -> Stat: + self._check_writable(uri) + if uri in self._entries or self._children(uri): + raise AlreadyExists(uri, self._stat(uri).version) + entry = self._write(uri, contents) + return Stat(_describe(uri, entry), entry.version) + + async def update(self, uri: str, contents: ResourceContents, if_match: str) -> tuple[str, Stat]: + current = self._file(uri) + if current.version != if_match: + raise VersionMismatch(uri, current.version) + entry = self._write(uri, contents) + return current.version, Stat(_describe(uri, entry), entry.version) + + async def delete(self, uri: str, if_match: str | None) -> str: + current = self._file(uri) + if if_match is not None and current.version != if_match: + raise VersionMismatch(uri, current.version) + del self._entries[uri] + return current.version + + async def read_directory(self, uri: str) -> list[Resource]: + if uri in self._entries: + raise InvalidResourceUri(uri, "not a directory resource") + children = self._children(uri) + if not children: + raise ResourceNotFound(uri) + return children + + def _file(self, uri: str) -> _Entry: + entry = self._entries.get(uri) + if entry is None: + if self._children(uri): + raise OperationNotSupported(uri, "directories cannot be updated or deleted") + raise ResourceNotFound(uri) + return entry + + def _check_writable(self, uri: str) -> None: + root = f"{self._scheme}://" + if not uri.startswith(root): + raise OperationNotSupported(uri, f"this store only holds {root} resources") + segments = uri.removeprefix(root).split("/") + if any(root + "/".join(segments[:i]) in self._entries for i in range(1, len(segments))): + raise OperationNotSupported(uri, "an ancestor is a file, not a directory") + + def _write(self, uri: str, contents: ResourceContents) -> _Entry: + stamp = datetime.now(timezone.utc).isoformat() + entry = _Entry(contents.model_copy(update={"uri": uri}), str(next(self._versions)), stamp) + self._entries[uri] = entry + return entry + + def _children(self, uri: str) -> list[Resource]: + prefix = uri + "/" + files: dict[str, Resource] = {} + directories: set[str] = set() + for key, entry in self._entries.items(): + if not key.startswith(prefix): + continue + head, _, rest = key[len(prefix) :].partition("/") + if rest: + directories.add(prefix + head) + else: + files[key] = _describe(key, entry) + return [*files.values(), *(_directory(d) for d in sorted(directories))] + + +def _describe(uri: str, entry: _Entry) -> Resource: + contents = entry.contents + if isinstance(contents, BlobResourceContents): + size = len(base64.b64decode(contents.blob)) + else: + size = len(contents.text.encode()) + return Resource( + uri=uri, + name=uri.rsplit("/", 1)[-1], + mime_type=contents.mime_type, + size=size, + annotations=Annotations(last_modified=entry.last_modified), + _meta={VERSION_META_KEY: entry.version}, + ) + + +def _directory(uri: str) -> Resource: + return Resource(uri=uri, name=uri.rsplit("/", 1)[-1], mime_type=DIRECTORY_MIME_TYPE) diff --git a/python/sdk/src/mcp_ext_filesystems/wire.py b/python/sdk/src/mcp_ext_filesystems/wire.py new file mode 100644 index 0000000..5663400 --- /dev/null +++ b/python/sdk/src/mcp_ext_filesystems/wire.py @@ -0,0 +1,125 @@ +"""Wire types for the Resource operations strawman (docs/proposals/resource-operations.md).""" + +from __future__ import annotations + +from typing import Literal + +from mcp_types import ( + BlobResourceContents, + CacheableResult, + PaginatedRequestParams, + PaginatedResult, + Request, + RequestParams, + Resource, + Result, + TextResourceContents, +) +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +EXTENSION_ID = "io.modelcontextprotocol/resource-operations" +"""Provisional identifier, advertised under `capabilities.extensions` (SEP-2133).""" + +VERSION_META_KEY = "io.modelcontextprotocol/resource-version" +"""`_meta` key that carries a version on core shapes this extension may not retype (read contents, list entries).""" + +DIRECTORY_MIME_TYPE = "inode/directory" +"""Marks a directory resource, as in SEP-2640.""" + +METHOD_STAT = "resources/stat" +METHOD_CREATE = "resources/create" +METHOD_UPDATE = "resources/update" +METHOD_DELETE = "resources/delete" +METHOD_READ_DIRECTORY = "resources/directory/read" +"""Defined by SEP-2640 (Skills). Served here unchanged; see the proposal's Listing section.""" + +ResourceContents = TextResourceContents | BlobResourceContents +Effect = Literal["created", "updated", "deleted"] + + +class _WireModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class ResourceChange(_WireModel): + """One entry of a change set, in the vocabulary of Kryspin Ziemski's working document.""" + + uri: str + effect: Effect + previous_version: str | None = None + version: str | None = None + + +class StatParams(RequestParams): + uri: str + + +class StatResult(CacheableResult): + resource: Resource + version: str + + +class CreateParams(RequestParams): + uri: str + contents: ResourceContents + + +class CreateResult(Result): + resource: Resource + version: str + changes: list[ResourceChange] + + +class UpdateParams(RequestParams): + uri: str + contents: ResourceContents + if_match: str + + +class UpdateResult(Result): + resource: Resource + version: str + changes: list[ResourceChange] + + +class DeleteParams(RequestParams): + uri: str + if_match: str | None = None + + +class DeleteResult(Result): + changes: list[ResourceChange] + + +class ReadDirectoryParams(PaginatedRequestParams): + uri: str + + +class ReadDirectoryResult(PaginatedResult): + resources: list[Resource] + + +class StatRequest(Request[StatParams, Literal["resources/stat"]]): + method: Literal["resources/stat"] = "resources/stat" + params: StatParams + + +class CreateRequest(Request[CreateParams, Literal["resources/create"]]): + method: Literal["resources/create"] = "resources/create" + params: CreateParams + + +class UpdateRequest(Request[UpdateParams, Literal["resources/update"]]): + method: Literal["resources/update"] = "resources/update" + params: UpdateParams + + +class DeleteRequest(Request[DeleteParams, Literal["resources/delete"]]): + method: Literal["resources/delete"] = "resources/delete" + params: DeleteParams + + +class ReadDirectoryRequest(Request[ReadDirectoryParams, Literal["resources/directory/read"]]): + method: Literal["resources/directory/read"] = "resources/directory/read" + params: ReadDirectoryParams From 025d2413c2c0b3dcf496542a9aa772c715abc902 Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:57:57 +0000 Subject: [PATCH 05/14] Test the prototype over the in-process transport Capability negotiation, happy paths, create-if-absent and stale ifMatch conflicts, not found, resources/updated via subscriptions/listen, and the lost-update scenario from docs/use-cases.md. Co-Authored-By: Claude Opus 5.5 --- python/sdk/tests/conftest.py | 6 + python/sdk/tests/test_resource_operations.py | 193 +++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 python/sdk/tests/conftest.py create mode 100644 python/sdk/tests/test_resource_operations.py diff --git a/python/sdk/tests/conftest.py b/python/sdk/tests/conftest.py new file mode 100644 index 0000000..5c53fe0 --- /dev/null +++ b/python/sdk/tests/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" diff --git a/python/sdk/tests/test_resource_operations.py b/python/sdk/tests/test_resource_operations.py new file mode 100644 index 0000000..d81221c --- /dev/null +++ b/python/sdk/tests/test_resource_operations.py @@ -0,0 +1,193 @@ +"""End-to-end tests over the SDK's in-process transport (`Client(server)`).""" + +from __future__ import annotations + +import anyio +import pytest +from mcp.client.client import Client +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import InMemorySubscriptionBus +from mcp.shared.exceptions import MCPError +from mcp.shared.subscriptions import ResourceUpdated +from mcp_types import METHOD_NOT_FOUND, TextResourceContents + +from mcp_ext_filesystems import ( + EXTENSION_ID, + NOT_SUPPORTED, + PRECONDITION_FAILED, + VERSION_META_KEY, + AlreadyExists, + InMemoryStore, + InvalidResourceUri, + OperationNotSupported, + ResourceNotFound, + ResourceOperations, + ResourceOperationsClient, + VersionMismatch, +) +from mcp_ext_filesystems.wire import DIRECTORY_MIME_TYPE, StatParams, StatRequest, StatResult + +pytestmark = pytest.mark.anyio + +URI = "mem://notes/plan.md" + + +def text(body: str, uri: str = URI) -> TextResourceContents: + return TextResourceContents(uri=uri, text=body, mime_type="text/markdown") + + +def make_server(*, directory_read: bool = False) -> MCPServer: + bus = InMemorySubscriptionBus() + extension = ResourceOperations(InMemoryStore(), subscriptions=bus, directory_read=directory_read) + return MCPServer("notes", subscriptions=bus, extensions=[extension]) + + +async def test_server_advertises_the_extension() -> None: + async with Client(make_server()) as client: + assert client.session.server_capabilities.extensions == {EXTENSION_ID: {}} + async with Client(make_server(directory_read=True)) as client: + assert client.session.server_capabilities.extensions == {EXTENSION_ID: {"directoryRead": True}} + + +async def test_methods_are_not_served_on_the_legacy_wire() -> None: + async with Client(make_server(), mode="legacy") as client: + assert not ResourceOperationsClient(client.session).supported() + with pytest.raises(MCPError) as caught: + await client.session.send_request(StatRequest(params=StatParams(uri=URI)), StatResult) + assert caught.value.code == METHOD_NOT_FOUND + + +async def test_create_stat_update_delete() -> None: + async with Client(make_server()) as client: + ops = ResourceOperationsClient(client.session) + + created = await ops.create(URI, text("v1")) + assert created.resource.size == 2 + assert created.resource.annotations is not None and created.resource.annotations.last_modified + assert [c.effect for c in created.changes] == ["created"] + assert created.changes[0].version == created.version + + stat = await ops.stat(URI) + assert stat.version == created.version + assert stat.resource.mime_type == "text/markdown" + + updated = await ops.update(URI, text("version two"), if_match=stat.version) + assert updated.version != stat.version + assert updated.resource.size == len("version two") + change = updated.changes[0] + assert (change.effect, change.previous_version, change.version) == ("updated", stat.version, updated.version) + + deleted = await ops.delete(URI, if_match=updated.version) + assert deleted.changes[0].effect == "deleted" + assert deleted.changes[0].previous_version == updated.version + with pytest.raises(ResourceNotFound): + await ops.stat(URI) + + +async def test_create_if_absent_conflict_returns_the_current_version() -> None: + async with Client(make_server()) as client: + ops = ResourceOperationsClient(client.session) + first = await ops.create(URI, text("mine")) + with pytest.raises(AlreadyExists) as caught: + await ops.create(URI, text("theirs")) + assert caught.value.code == PRECONDITION_FAILED + assert caught.value.data == {"uri": URI, "reason": "alreadyExists", "currentVersion": first.version} + + +async def test_stale_if_match_is_rejected_with_the_current_version() -> None: + async with Client(make_server()) as client: + ops = ResourceOperationsClient(client.session) + v1 = (await ops.create(URI, text("one"))).version + v2 = (await ops.update(URI, text("two"), if_match=v1)).version + with pytest.raises(VersionMismatch) as caught: + await ops.update(URI, text("stale"), if_match=v1) + assert (await ops.stat(URI)).version == v2 + assert caught.value.code == PRECONDITION_FAILED + assert caught.value.data == {"uri": URI, "reason": "versionMismatch", "currentVersion": v2} + + +async def test_delete_with_stale_if_match_keeps_the_resource() -> None: + async with Client(make_server()) as client: + ops = ResourceOperationsClient(client.session) + v1 = (await ops.create(URI, text("one"))).version + v2 = (await ops.update(URI, text("two"), if_match=v1)).version + with pytest.raises(VersionMismatch) as caught: + await ops.delete(URI, if_match=v1) + assert caught.value.current_version == v2 + assert (await ops.stat(URI)).version == v2 + await ops.delete(URI) + with pytest.raises(ResourceNotFound): + await ops.stat(URI) + + +async def test_not_found_uses_invalid_params_like_resources_read() -> None: + async with Client(make_server()) as client: + ops = ResourceOperationsClient(client.session) + for call in (ops.stat(URI), ops.update(URI, text("x"), if_match="1"), ops.delete(URI)): + with pytest.raises(ResourceNotFound) as caught: + await call + assert caught.value.code == -32602 + assert caught.value.data == {"uri": URI, "reason": "notFound"} + + +async def test_invalid_uri_and_unsupported_scheme() -> None: + async with Client(make_server()) as client: + ops = ResourceOperationsClient(client.session) + with pytest.raises(InvalidResourceUri): + await ops.stat("plan.md") + with pytest.raises(InvalidResourceUri): + await ops.create(URI, text("x", uri="mem://notes/other.md")) + with pytest.raises(OperationNotSupported) as caught: + await ops.create("file:///etc/hosts", text("x", uri="file:///etc/hosts")) + assert caught.value.code == NOT_SUPPORTED + + +async def test_update_and_delete_notify_resource_subscribers() -> None: + server = make_server() + async with Client(server) as watcher, Client(server) as writer: + ops = ResourceOperationsClient(writer.session) + version = (await ops.create(URI, text("one"))).version + async with watcher.listen(resource_subscriptions=[URI]) as subscription: + await ops.update(URI, text("two"), if_match=version) + with anyio.fail_after(2): + assert await subscription.__anext__() == ResourceUpdated(uri=URI) + await ops.delete(URI) + with anyio.fail_after(2): + assert await subscription.__anext__() == ResourceUpdated(uri=URI) + + +async def test_lost_update_is_rejected() -> None: + """docs/use-cases.md, Memory servers: two sessions read the same version and both write it back.""" + server = make_server() + async with Client(server) as first, Client(server) as second: + a, b = ResourceOperationsClient(first.session), ResourceOperationsClient(second.session) + await a.create(URI, text("- fact 0\n")) + + seen_by_a = await a.stat(URI) + seen_by_b = await b.stat(URI) + assert seen_by_a.version == seen_by_b.version + + written = await a.update(URI, text("- fact 0\n- fact A\n"), if_match=seen_by_a.version) + with pytest.raises(VersionMismatch) as caught: + await b.update(URI, text("- fact 0\n- fact B\n"), if_match=seen_by_b.version) + assert caught.value.current_version == written.version + + # The rejected writer re-reads, merges, and retries against the version it was told about. + retried = await b.update(URI, text("- fact 0\n- fact A\n- fact B\n"), if_match=caught.value.current_version) + assert retried.changes[0].previous_version == written.version + + +async def test_directory_read_reuses_the_skills_method_shape() -> None: + async with Client(make_server(directory_read=True)) as client: + ops = ResourceOperationsClient(client.session) + created = await ops.create(URI, text("plan")) + await ops.create("mem://notes/archive/old.md", text("old", uri="mem://notes/archive/old.md")) + + children = {r.uri: r for r in await ops.read_directory("mem://notes")} + assert set(children) == {URI, "mem://notes/archive"} + assert children[URI].meta == {VERSION_META_KEY: created.version} + assert children["mem://notes/archive"].mime_type == DIRECTORY_MIME_TYPE + assert (await ops.stat("mem://notes/archive")).resource.mime_type == DIRECTORY_MIME_TYPE + + with pytest.raises(InvalidResourceUri): + await ops.read_directory(URI) From 51dd27a30551be23894d2ed08150b13312596a9d Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:59:40 +0000 Subject: [PATCH 06/14] Proposal status line: drop informal wording --- docs/proposals/resource-operations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/proposals/resource-operations.md b/docs/proposals/resource-operations.md index b7897c3..0360344 100644 --- a/docs/proposals/resource-operations.md +++ b/docs/proposals/resource-operations.md @@ -1,6 +1,6 @@ # Resource operations: strawman -**Status:** Draft strawman for discussion. Not accepted by the Files Working Group, not part of any specification. Written to be torn apart. +**Status:** Draft strawman for discussion. Not accepted by the Files Working Group, not part of any specification. **Decision log entry:** [2026-10-09](../decisions.md#2026-10-09-strawman-for-resource-operations-stat-create-update-delete-with-version-based-concurrency) **Prototype:** [`python/sdk/`](../../python/sdk/) · **Conformance scenarios:** [`conformance/`](../../conformance/) From bc226194268c6f70180ff356f4c25e0c022b6e97 Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:28:09 +0000 Subject: [PATCH 07/14] docs: point at the Filesystems WG charter now that the Files WG merge was dropped The File Uploads and Filesystems charter merge (#3389) was abandoned on 2026-10-11, so the references to the Files Working Group, its charter PR and its three workstreams now point at the Filesystems WG charter and the File Uploads WG instead. Scope text follows the Filesystems charter (create, update, delete, stat, concurrency, change notification and caching). --- AGENTS.md | 2 +- CONTRIBUTING.md | 4 ++-- README.md | 6 +++--- docs/decisions.md | 2 +- docs/open-questions.md | 12 ++++++------ docs/problem-statement.md | 4 ++-- docs/related-work.md | 6 +++--- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d1510ba..da1aa89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ ## What to reference as a source -Nothing in this repository is a specification yet. The [Files Working Group charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389) defines the scope. The `docs/` folder holds open questions, the decision log, related work, and the implementations list. Treat it as context, not as normative text. +Nothing in this repository is a specification yet. The [Filesystems Working Group charter](https://modelcontextprotocol.io/community/working-groups/filesystems) defines the scope. The `docs/` folder holds open questions, the decision log, related work, and the implementations list. Treat it as context, not as normative text. Do not describe a proposal in this repository as an accepted or official part of MCP. Until accepted text lands under `specification/`, everything here is experimental. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 447ab60..edb9614 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## How to Participate -The Files Working Group welcomes contributions from anyone interested in operating on MCP Resources beyond a single read. You can participate by: +The Filesystems Working Group welcomes contributions from anyone interested in operating on MCP Resources beyond a single read. You can participate by: - Joining the discussion in `#filesystem-wg` on the MCP Discord (info on joining the server [here](https://modelcontextprotocol.io/community/communication#discord)) - Opening pull requests with concrete proposals, so the group can comment on specifics @@ -18,7 +18,7 @@ The Files Working Group welcomes contributions from anyone interested in operati ## Meetings -Working Session cadence is defined in the [charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389). Meeting requirements (advance notice, agendas, and notes) follow MCP [group governance](https://modelcontextprotocol.io/community/working-interest-groups#meeting-requirements). +Working Session cadence is defined in the [charter](https://modelcontextprotocol.io/community/working-groups/filesystems). Meeting requirements (advance notice, agendas, and notes) follow MCP [group governance](https://modelcontextprotocol.io/community/working-interest-groups#meeting-requirements). ## Decision-Making diff --git a/README.md b/README.md index 7ac8b3d..5c1642a 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ > **Status:** Experimental. This work is for prototyping and feedback only, and is not an accepted or official MCP extension. -This repository holds the working documents and reference implementations for **Resource operations**: a common interface for operating on MCP Resources beyond a single read (`create`, `update`, `delete`, a metadata read (`stat`), scoped listing, search, and glob matching), with optimistic concurrency control on writes. It is maintained by the Files Working Group, which replaces the File Uploads and Filesystems Working Groups. +This repository holds the working documents and reference implementations for **Resource operations**: a common interface for operating on MCP Resources beyond a single read (`create`, `update`, `delete`, and a metadata read, `stat`), with optimistic concurrency control on writes and a defined interaction with change notification and caching. It is maintained by the Filesystems Working Group, which coordinates with the [File Uploads Working Group](https://modelcontextprotocol.io/community/working-groups/file-uploads) on where an upload flow ends and a resource write begins. -**Charter:** [Files Working Group charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389) (in review): mission, scope, leadership, and active work items. +**Charter:** [Filesystems Working Group charter](https://modelcontextprotocol.io/community/working-groups/filesystems): mission, scope, leadership, and active work items. **Discord:** `#filesystem-wg` on the [MCP Discord](https://modelcontextprotocol.io/community/communication#discord). **Open work:** [pull requests](https://github.com/modelcontextprotocol/experimental-ext-filesystems/pulls) and [issues](https://github.com/modelcontextprotocol/experimental-ext-filesystems/issues). Proposals, findings, and implementations are welcome. @@ -29,7 +29,7 @@ Nothing under `docs/` is part of a specification. A specification will live unde | :--- | :--- | :--- | :--- | | Python | `python/sdk/` | `mcp-ext-filesystems` | Planned | -The charter also lists a TypeScript SDK reference implementation ([@ochafik](https://github.com/ochafik)). +The charter's success criteria call for reference implementations in at least two Tier 1 SDKs. ## Contributing diff --git a/docs/decisions.md b/docs/decisions.md index 942cfa4..db22e0e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1,6 +1,6 @@ # Decision Log -This document records significant decisions made by the Files Working Group about Resource operations, using an ADR-lite (Architecture Decision Record) format. It serves as a transparent, auditable trace of the group's reasoning over time. +This document records significant decisions made by the Filesystems Working Group about Resource operations, using an ADR-lite (Architecture Decision Record) format. It serves as a transparent, auditable trace of the group's reasoning over time. For background on the ADR format, see [adr.github.io](https://adr.github.io/). diff --git a/docs/open-questions.md b/docs/open-questions.md index 953059a..43d8cd2 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -1,12 +1,12 @@ # Open Questions -Questions the group needs to settle for Resource operations. The first three come from the [charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389); add others by pull request. When a question is settled, record the outcome in the [decision log](decisions.md) and link it here. +Questions the group needs to settle for Resource operations. The first three come from the [charter](https://modelcontextprotocol.io/community/working-groups/filesystems); add others by pull request. When a question is settled, record the outcome in the [decision log](decisions.md) and link it here. | # | Question | Source | Status | | :--- | :--- | :--- | :--- | -| 1 | Is a transferred file addressed as a Resource URI? The answer decides whether a `files/*` method family exists alongside `resources/*`, and binds all three workstreams. | Charter, cross-cutting concerns | Open | -| 2 | Which optimistic concurrency control mechanism do writes use, including create-if-absent? | Charter, workstream 2 | Open | -| 3 | How do writes interact with `notifications/resources/updated`, `ttlMs`, `cacheScope`, and `lastModified`? | Charter, workstream 2 | Open | -| 4 | Which typed errors do Resource operations return? | Charter, workstream 2 | Open | -| 5 | Does the work fit one Extensions Track SEP, or several (for example writes separate from listing and search)? | Charter, workstream 2 | Open | +| 1 | Is a transferred file addressed as a Resource URI? The charter keeps writes on Resources rather than a parallel `files/*` family, so the open part is where an upload flow ends and a resource write begins. | Charter, related groups (File Uploads WG) | Open | +| 2 | Which optimistic concurrency control mechanism do writes use, including create-if-absent? | Charter, scope | Open | +| 3 | How do writes interact with `notifications/resources/updated`, `ttlMs`, `cacheScope`, and `lastModified`? | Charter, scope | Open | +| 4 | Which typed errors do Resource operations return? | Proposed | Open | +| 5 | How do the open proposals in this space (SEP-2571, SEP-2532) end up: folded in, superseded, or scoped out? | Charter, success criteria | Open | | 6 | How do Resource operations relate to the optional directory read in the Skills extension? | Charter, related groups | Open | diff --git a/docs/problem-statement.md b/docs/problem-statement.md index 5565b09..269bfd1 100644 --- a/docs/problem-statement.md +++ b/docs/problem-statement.md @@ -4,6 +4,6 @@ MCP Resources are read-oriented. A client can list Resources and read one by URI Writes add a second problem: concurrency. Without a shared mechanism, two clients that read a Resource and write it back can silently overwrite each other's changes (last writer wins), and "create only if it does not exist" cannot be expressed. -The [charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389) puts this in workstream 2, Resource operations: create, update, delete, and `stat`; scoped listing, directory traversal, server-side search and glob matching, and bulk reads; typed errors; optimistic concurrency control on writes, including create-if-absent; and defined interactions with `notifications/resources/updated`, `ttlMs`, `cacheScope`, and `lastModified`. +The [charter](https://modelcontextprotocol.io/community/working-groups/filesystems) scopes one Extensions Track SEP for this: create, update, delete, and `stat`; optimistic concurrency control on writes, including create-if-absent; and defined interactions with `notifications/resources/updated`, `ttlMs`, `cacheScope`, and `lastModified`. -Moving content that does not fit inline in JSON-RPC (content transfer) and marking tool or elicitation inputs as files (file inputs) are the charter's other two workstreams. Resource operations need to stay compatible with both, and in particular with the open question of whether a transferred file is addressed as a Resource URI. +Moving content that does not fit inline in JSON-RPC, and marking tool or elicitation inputs as files, belong to the [File Uploads Working Group](https://modelcontextprotocol.io/community/working-groups/file-uploads). Resource operations need to stay compatible with that work, in particular on where an upload flow ends and a resource write begins. diff --git a/docs/related-work.md b/docs/related-work.md index 057a95c..f294f02 100644 --- a/docs/related-work.md +++ b/docs/related-work.md @@ -6,10 +6,10 @@ SEPs, extensions, and discussions that Resource operations build on or must stay | SEP | Status | Relevance | | :--- | :--- | :--- | -| [SEP-2571](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2571) | Closed 2026-09-22 | Proposed `resources/create` and `resources/delete`. The charter takes its create and delete operations as input to workstream 2. | -| [SEP-2631](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2631) | Draft | File objects and out-of-band transfer. Input to the content transfer and file input workstreams. | +| [SEP-2571](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2571) | Closed 2026-09-22 | Proposed `resources/create` and `resources/delete`. The charter names taking it up with its author as the group's first work item. | +| [SEP-2631](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2631) | Draft | File objects and out-of-band transfer. The [File Uploads Working Group](https://modelcontextprotocol.io/community/working-groups/file-uploads) plans to supersede it with a new SEP. | | [SEP-2532](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2532) | Open | `resources/stream`. To be reconciled with the transfer work. | -| [SEP-2356](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2356) | Closed 2026-06-26 | File input model. Input to the file inputs workstream. | +| [SEP-2356](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2356) | Closed 2026-06-26 | File input model, closed in favour of SEP-2631. | ## Extension framework From f1a3b2c256aae414702e093c470d28aa9b7047ba Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:28:10 +0000 Subject: [PATCH 08/14] docs: update the strawman's references to the Filesystems WG charter --- docs/decisions.md | 2 +- docs/proposals/resource-operations.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 5c1897b..3e8e0bf 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -31,7 +31,7 @@ Add new entries below this section, oldest first. A pull request that adds an en **Status:** Proposed -**Context:** Workstream 2 of the [charter](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3389) asks for create, update, delete, and `stat`, typed errors, and optimistic concurrency control on writes including create-if-absent ([open questions](open-questions.md) 2 to 6). Options considered for concurrency: no precondition (last writer wins), timestamps (`lastModified`), and an opaque server-minted version compared on write. For listing: a new method, or the `resources/directory/read` method already defined by the Skills extension (SEP-2640). +**Context:** The [charter](https://modelcontextprotocol.io/community/working-groups/filesystems) asks for create, update, delete, and `stat`, typed errors, and optimistic concurrency control on writes including create-if-absent ([open questions](open-questions.md) 2 to 6). Options considered for concurrency: no precondition (last writer wins), timestamps (`lastModified`), and an opaque server-minted version compared on write. For listing: a new method, or the `resources/directory/read` method already defined by the Skills extension (SEP-2640). **Decision:** Use [docs/proposals/resource-operations.md](proposals/resource-operations.md) as the group's starting strawman: an extension `io.modelcontextprotocol/resource-operations` (provisional) with `resources/stat`, `resources/create` (create-if-absent only), `resources/update` (requires `ifMatch`), and `resources/delete` (optional `ifMatch`); an opaque `version` as the concurrency token; a precondition-failed error carrying `currentVersion`; and reuse of `resources/directory/read` unchanged for listing. Search, glob, bulk reads, and ranges are deferred. diff --git a/docs/proposals/resource-operations.md b/docs/proposals/resource-operations.md index 0360344..21e1de6 100644 --- a/docs/proposals/resource-operations.md +++ b/docs/proposals/resource-operations.md @@ -1,6 +1,6 @@ # Resource operations: strawman -**Status:** Draft strawman for discussion. Not accepted by the Files Working Group, not part of any specification. +**Status:** Draft strawman for discussion. Not accepted by the Filesystems Working Group, not part of any specification. **Decision log entry:** [2026-10-09](../decisions.md#2026-10-09-strawman-for-resource-operations-stat-create-update-delete-with-version-based-concurrency) **Prototype:** [`python/sdk/`](../../python/sdk/) · **Conformance scenarios:** [`conformance/`](../../conformance/) @@ -9,7 +9,7 @@ Resources can be listed and read, but not created, updated, deleted, or inspected without reading the content ([problem statement](../problem-statement.md)). Servers that need writes expose them as tools with ad hoc names, arguments, and errors, and nothing stops two writers from overwriting each other ([use cases](../use-cases.md): shared state written by several agents, memory servers). -This strawman covers the smallest slice of workstream 2 that fixes the write path: a metadata read (`resources/stat`), `resources/create` with create-if-absent semantics, `resources/update` conditional on a version, `resources/delete` with an optional version check, typed errors, and how these interact with existing cache and notification fields. Listing reuses `resources/directory/read` from SEP-2640. Search, glob, bulk reads, and ranges are deferred. +This strawman covers the smallest slice of the charter's scope that fixes the write path: a metadata read (`resources/stat`), `resources/create` with create-if-absent semantics, `resources/update` conditional on a version, `resources/delete` with an optional version check, typed errors, and how these interact with existing cache and notification fields. Listing reuses `resources/directory/read` from SEP-2640. Search, glob, bulk reads, and ranges are deferred. Vocabulary follows Kryspin Ziemski's working document shared in #filesystem-wg ("MCP Resources as Addressable Domain State"), which proposes Create, Read, Update, Delete, Query, and Range (CRUDQR) over Resources and a change-set entry `{uri, effect, previousVersion?, version?}`. This draft covers C, U, D, plus `stat`, and uses `version` as the concurrency token and the change-set entry in write results. Query and Range are deferred. @@ -151,7 +151,7 @@ The cleaner long-term shape is probably for one definition to live in one place - **Search and glob (Query).** The query language and glob semantics over URIs (as opposed to paths) are unsettled; Kryspin's document lists the query language as open. Better to land the write path first than to block it on a query design. - **Recursive traversal.** Clients descend with repeated `resources/directory/read` calls. A recursive variant needs limits and pagination rules that are easier to set once listing is settled. -- **Bulk reads.** Their size limits and failure modes depend on the content transfer workstream. +- **Bulk reads.** Their size limits and failure modes depend on the File Uploads Working Group's transfer design. - **Range.** Partial reads and writes need range coordinates (bytes, lines, records) and overlap with `resources/stream` (SEP-2532), which the charter assigns to content transfer. ## Errors @@ -206,7 +206,7 @@ Numbers refer to [open-questions.md](../open-questions.md); "new" marks question ## Not addressed -- **Content transfer.** Content travels inline. Out-of-band transfer, digests, size limits, and streaming belong to workstream 1; `create` and `update` will need a way to reference transferred content once that exists. -- **File inputs.** Marking tool or elicitation inputs as files is workstream 3. Nothing here depends on it. +- **Content transfer.** Content travels inline. Out-of-band transfer, digests, size limits, and streaming belong to the File Uploads Working Group; `create` and `update` will need a way to reference transferred content once that exists. +- **File inputs.** Marking tool or elicitation inputs as files belongs to the File Uploads Working Group. Nothing here depends on it. - **Whether files are Resource URIs.** The charter's cross-cutting decision ([open question 1](../open-questions.md)). This draft operates on Resource URIs because that is what exists today; it takes no position on the decision. - **Authorization.** Who may write what is out of the charter's scope beyond stating that the existing MCP authorization specification applies. From 99bd5abb9766c7a655be294e8917d1dee579e80e Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:28:31 +0000 Subject: [PATCH 09/14] chore: name the Filesystems WG in CODEOWNERS and the package metadata --- .github/CODEOWNERS | 2 +- python/sdk/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6da0018..e5a38a6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,5 +4,5 @@ # Team mappings are configured in the access repository: # https://github.com/modelcontextprotocol/access -# Default owners for all files: reviews route to the Files Working Group. +# Default owners for all files: reviews route to the Filesystems Working Group. * @modelcontextprotocol/filesystems-wg diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml index d9d298d..f079dbf 100644 --- a/python/sdk/pyproject.toml +++ b/python/sdk/pyproject.toml @@ -2,7 +2,7 @@ name = "mcp-ext-filesystems" version = "0.1.0" description = "Python reference implementation of the experimental MCP Resource operations extension" -authors = [{name = "The MCP Files Working Group"}] +authors = [{name = "The MCP Filesystems Working Group"}] readme = "README.md" license = {text = "Apache-2.0"} requires-python = ">=3.10" From 8c05c82f43dfdf1c104b773fee17530500e6239b Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:29:09 +0000 Subject: [PATCH 10/14] docs: last workstream reference in the strawman --- docs/proposals/resource-operations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/proposals/resource-operations.md b/docs/proposals/resource-operations.md index 21e1de6..34ffa58 100644 --- a/docs/proposals/resource-operations.md +++ b/docs/proposals/resource-operations.md @@ -48,7 +48,7 @@ A `version` is an opaque string minted by the server for the current state of on ## Methods -Content in `create` and `update` uses the core `TextResourceContents` or `BlobResourceContents` shape, the same one `resources/read` returns. `contents.uri` MUST equal `uri`. Content travels inline; moving large content is the content transfer workstream's job. +Content in `create` and `update` uses the core `TextResourceContents` or `BlobResourceContents` shape, the same one `resources/read` returns. `contents.uri` MUST equal `uri`. Content travels inline; moving large content is the File Uploads Working Group's job. ### `resources/stat` From f3f251407e71be67fc21fe47eaddc271e56914d6 Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:35:46 +0000 Subject: [PATCH 11/14] Use the merged problem statements; drop the duplicate doc Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/problem-statement.md | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 docs/problem-statement.md diff --git a/README.md b/README.md index 5c1642a..d66447b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This repository holds the working documents and reference implementations for ** | Document | Description | | :--- | :--- | -| [Problem Statement](docs/problem-statement.md) | What is missing from Resources today, and what the charter asks for | +| [Problem Statements](docs/problem-statements.md) | What is missing from Resources today, as six problem statements with a running example | | [Use Cases](docs/use-cases.md) | Concrete situations Resource operations should serve | | [Open Questions](docs/open-questions.md) | Questions the group needs to settle, starting with the charter's | | [Decision Log](docs/decisions.md) | ADR-lite record of the group's decisions, and the vehicle for proposing changes | diff --git a/docs/problem-statement.md b/docs/problem-statement.md deleted file mode 100644 index 269bfd1..0000000 --- a/docs/problem-statement.md +++ /dev/null @@ -1,9 +0,0 @@ -# Problem Statement - -MCP Resources are read-oriented. A client can list Resources and read one by URI, but there is no common way to create, update, or delete one, to read only its metadata, or to list, search, or glob within a scope. Servers that need these operations expose them as tools, each with its own names, arguments, and error shapes, so clients cannot treat them uniformly. - -Writes add a second problem: concurrency. Without a shared mechanism, two clients that read a Resource and write it back can silently overwrite each other's changes (last writer wins), and "create only if it does not exist" cannot be expressed. - -The [charter](https://modelcontextprotocol.io/community/working-groups/filesystems) scopes one Extensions Track SEP for this: create, update, delete, and `stat`; optimistic concurrency control on writes, including create-if-absent; and defined interactions with `notifications/resources/updated`, `ttlMs`, `cacheScope`, and `lastModified`. - -Moving content that does not fit inline in JSON-RPC, and marking tool or elicitation inputs as files, belong to the [File Uploads Working Group](https://modelcontextprotocol.io/community/working-groups/file-uploads). Resource operations need to stay compatible with that work, in particular on where an upload flow ends and a resource write begins. From 92dc2c4dfaf80e524e6d0862eeb5f0aadcb76e6e Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:37:59 +0000 Subject: [PATCH 12/14] Rename version to revision to match the problem statements; align with main Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- conformance/resource-operations-conflicts.md | 24 +++--- docs/decisions.md | 8 +- docs/proposals/resource-operations.md | 72 +++++++++--------- docs/use-cases.md | 2 +- python/sdk/README.md | 8 +- .../sdk/src/mcp_ext_filesystems/__init__.py | 4 +- python/sdk/src/mcp_ext_filesystems/client.py | 2 +- python/sdk/src/mcp_ext_filesystems/errors.py | 30 ++++---- python/sdk/src/mcp_ext_filesystems/server.py | 12 +-- python/sdk/src/mcp_ext_filesystems/store.py | 50 ++++++------- python/sdk/src/mcp_ext_filesystems/wire.py | 14 ++-- python/sdk/tests/test_resource_operations.py | 74 ++++++++++--------- 13 files changed, 154 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index 843a045..7f83e4a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ This repository holds the working documents and reference implementations for ** | [Problem Statements](docs/problem-statements.md) | What is missing from Resources today, as six problem statements with a running example | | [Use Cases](docs/use-cases.md) | Concrete situations Resource operations should serve | | [Open Questions](docs/open-questions.md) | Questions the group needs to settle, starting with the charter's | -| [Resource operations strawman](docs/proposals/resource-operations.md) | Draft `stat`, `create`, `update`, `delete` with version-based concurrency, for discussion | +| [Resource operations strawman](docs/proposals/resource-operations.md) | Draft `stat`, `create`, `update`, `delete` with revision-based concurrency, for discussion | | [Decision Log](docs/decisions.md) | ADR-lite record of the group's decisions, and the vehicle for proposing changes | | [Related Work](docs/related-work.md) | SEPs, extensions, and discussions this work builds on | | [Implementations](docs/implementations.md) | Servers, SDKs, and hosts that implement Resource operations | diff --git a/conformance/resource-operations-conflicts.md b/conformance/resource-operations-conflicts.md index 4a9577f..7b4bd4f 100644 --- a/conformance/resource-operations-conflicts.md +++ b/conformance/resource-operations-conflicts.md @@ -5,7 +5,7 @@ Implementation-independent checks for the conflict behaviour in the [Resource op Conventions: - `` is a URI the server accepts for writes and that does not exist at the start. The harness gets it from configuration. -- `` are versions captured from earlier responses. Versions are opaque: compare them only for equality, never parse them. +- `` are revisions captured from earlier responses. Revisions are opaque: compare them only for equality, never parse them. - "Precondition failed" means the error code assigned to precondition failures (provisionally `-32010`). - Each step lists the request and the checks on its response. A step fails the scenario if any check fails. @@ -13,24 +13,24 @@ Conventions: | # | Connection | Request | Expect | | :--- | :--- | :--- | :--- | -| 1 | A | `resources/create {uri: , contents: {uri: , text: "a"}}` | Result. `version` is a non-empty string; save as ``. `changes` contains `{uri: , effect: "created", version: }`. | -| 2 | B | `resources/create {uri: , contents: {uri: , text: "b"}}` | Error. Code is precondition failed. `data.uri` = ``, `data.reason` = `"alreadyExists"`, `data.currentVersion` = ``. | -| 3 | A | `resources/stat {uri: }` | Result. `version` = ``. The second create changed nothing. | +| 1 | A | `resources/create {uri: , contents: {uri: , text: "a"}}` | Result. `revision` is a non-empty string; save as ``. `changes` contains `{uri: , effect: "created", revision: }`. | +| 2 | B | `resources/create {uri: , contents: {uri: , text: "b"}}` | Error. Code is precondition failed. `data.uri` = ``, `data.reason` = `"alreadyExists"`, `data.currentRevision` = ``. | +| 3 | A | `resources/stat {uri: }` | Result. `revision` = ``. The second create changed nothing. | ## RO-CONFLICT-2: lost update -The memory-server case from [use cases](../docs/use-cases.md): two sessions read the same version and both write it back. +The memory-server case from [use cases](../docs/use-cases.md): two sessions read the same revision and both write it back. | # | Connection | Request | Expect | | :--- | :--- | :--- | :--- | | 1 | A | `resources/create {uri: , contents: {uri: , text: "0"}}` | Result. | -| 2 | A | `resources/stat {uri: }` | Result. Save `version` as ``. | -| 3 | B | `resources/stat {uri: }` | Result. `version` = ``. | -| 4 | A | `resources/update {uri: , contents: {uri: , text: "0A"}, ifMatch: }` | Result. Save `version` as ``; `` != ``. `changes` contains `{uri: , effect: "updated", previousVersion: , version: }`. | -| 5 | B | `resources/update {uri: , contents: {uri: , text: "0B"}, ifMatch: }` | Error. Code is precondition failed. `data.reason` = `"versionMismatch"`, `data.currentVersion` = ``. | -| 6 | B | `resources/stat {uri: }` | Result. `version` = ``. The rejected write changed nothing. | -| 7 | B | `resources/update {uri: , contents: {uri: , text: "0AB"}, ifMatch: }` | Result. `changes` contains `previousVersion: `. | -| 8 | B | `resources/delete {uri: , ifMatch: }` | Error. Code is precondition failed. `data.reason` = `"versionMismatch"`. | +| 2 | A | `resources/stat {uri: }` | Result. Save `revision` as ``. | +| 3 | B | `resources/stat {uri: }` | Result. `revision` = ``. | +| 4 | A | `resources/update {uri: , contents: {uri: , text: "0A"}, ifMatch: }` | Result. Save `revision` as ``; `` != ``. `changes` contains `{uri: , effect: "updated", previousRevision: , revision: }`. | +| 5 | B | `resources/update {uri: , contents: {uri: , text: "0B"}, ifMatch: }` | Error. Code is precondition failed. `data.reason` = `"revisionMismatch"`, `data.currentRevision` = ``. | +| 6 | B | `resources/stat {uri: }` | Result. `revision` = ``. The rejected write changed nothing. | +| 7 | B | `resources/update {uri: , contents: {uri: , text: "0AB"}, ifMatch: }` | Result. `changes` contains `previousRevision: `. | +| 8 | B | `resources/delete {uri: , ifMatch: }` | Error. Code is precondition failed. `data.reason` = `"revisionMismatch"`. | | 9 | A | `resources/stat {uri: }` | Result. The resource still exists. | Optional, if the server reads back what it stores: after step 7, `resources/read {uri: }` returns text `"0AB"`. diff --git a/docs/decisions.md b/docs/decisions.md index 3e8e0bf..f68c1d9 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -27,15 +27,15 @@ Add new entries below this section, oldest first. A pull request that adds an en --- -### 2026-10-09: Strawman for Resource operations: stat, create, update, delete with version-based concurrency +### 2026-10-09: Strawman for Resource operations: stat, create, update, delete with revision-based concurrency **Status:** Proposed -**Context:** The [charter](https://modelcontextprotocol.io/community/working-groups/filesystems) asks for create, update, delete, and `stat`, typed errors, and optimistic concurrency control on writes including create-if-absent ([open questions](open-questions.md) 2 to 6). Options considered for concurrency: no precondition (last writer wins), timestamps (`lastModified`), and an opaque server-minted version compared on write. For listing: a new method, or the `resources/directory/read` method already defined by the Skills extension (SEP-2640). +**Context:** The [charter](https://modelcontextprotocol.io/community/working-groups/filesystems) asks for create, update, delete, and `stat`, typed errors, and optimistic concurrency control on writes including create-if-absent ([open questions](open-questions.md) 2 to 6). Options considered for concurrency: no precondition (last writer wins), timestamps (`lastModified`), and an opaque server-minted revision compared on write. For listing: a new method, or the `resources/directory/read` method already defined by the Skills extension (SEP-2640). -**Decision:** Use [docs/proposals/resource-operations.md](proposals/resource-operations.md) as the group's starting strawman: an extension `io.modelcontextprotocol/resource-operations` (provisional) with `resources/stat`, `resources/create` (create-if-absent only), `resources/update` (requires `ifMatch`), and `resources/delete` (optional `ifMatch`); an opaque `version` as the concurrency token; a precondition-failed error carrying `currentVersion`; and reuse of `resources/directory/read` unchanged for listing. Search, glob, bulk reads, and ranges are deferred. +**Decision:** Use [docs/proposals/resource-operations.md](proposals/resource-operations.md) as the group's starting strawman: an extension `io.modelcontextprotocol/resource-operations` (provisional) with `resources/stat`, `resources/create` (create-if-absent only), `resources/update` (requires `ifMatch`), and `resources/delete` (optional `ifMatch`); an opaque `revision` as the concurrency token, the term [Problem Statement 5](problem-statements.md#problem-statement-5-optimistic-concurrency-control) uses; a precondition-failed error carrying `currentRevision`; and reuse of `resources/directory/read` unchanged for listing. Search, glob, bulk reads, and ranges are deferred. -**Rationale:** An opaque version is what HTTP ETags, S3, and GCS already use, survives coarse clocks, and lets each server choose a counter, digest, or backend generation. Required `ifMatch` on update rules out lost updates by omission. Reusing SEP-2640's method avoids two listing methods for the same tree. The vocabulary (`version`, change-set entries with `effect`) follows Kryspin Ziemski's working document shared in #filesystem-wg, so later Query and Range work can build on it. A Python prototype built on the SDK's `Extension`/`MethodBinding` API and conformance scenarios accompany the draft. +**Rationale:** An opaque revision identifier is what HTTP ETags, S3, and GCS already use, survives coarse clocks, and lets each server choose a counter, digest, or backend generation. Required `ifMatch` on update rules out lost updates by omission. Reusing SEP-2640's method avoids two listing methods for the same tree. The change-set vocabulary (entries with `effect`) follows Kryspin Ziemski's working document shared in #filesystem-wg, so later Query and Range work can build on it; that document calls the token `version`, and this draft uses `revision` to match the problem statements. A Python prototype built on the SDK's `Extension`/`MethodBinding` API and conformance scenarios accompany the draft. **References:** - [Proposal](proposals/resource-operations.md), [Python prototype](../python/sdk/), [conformance scenarios](../conformance/) diff --git a/docs/proposals/resource-operations.md b/docs/proposals/resource-operations.md index 34ffa58..d378385 100644 --- a/docs/proposals/resource-operations.md +++ b/docs/proposals/resource-operations.md @@ -2,16 +2,16 @@ **Status:** Draft strawman for discussion. Not accepted by the Filesystems Working Group, not part of any specification. -**Decision log entry:** [2026-10-09](../decisions.md#2026-10-09-strawman-for-resource-operations-stat-create-update-delete-with-version-based-concurrency) +**Decision log entry:** [2026-10-09](../decisions.md#2026-10-09-strawman-for-resource-operations-stat-create-update-delete-with-revision-based-concurrency) **Prototype:** [`python/sdk/`](../../python/sdk/) · **Conformance scenarios:** [`conformance/`](../../conformance/) ## Motivation -Resources can be listed and read, but not created, updated, deleted, or inspected without reading the content ([problem statement](../problem-statement.md)). Servers that need writes expose them as tools with ad hoc names, arguments, and errors, and nothing stops two writers from overwriting each other ([use cases](../use-cases.md): shared state written by several agents, memory servers). +Resources can be listed and read, but not created, updated, deleted, or inspected without reading the content ([Problem Statement 4](../problem-statements.md#problem-statement-4-writes-metadata-and-typed-errors)). Servers that need writes expose them as tools with ad hoc names, arguments, and errors, and nothing stops two writers from overwriting each other ([Problem Statement 5](../problem-statements.md#problem-statement-5-optimistic-concurrency-control); [use cases](../use-cases.md): shared state written by several agents, memory servers). -This strawman covers the smallest slice of the charter's scope that fixes the write path: a metadata read (`resources/stat`), `resources/create` with create-if-absent semantics, `resources/update` conditional on a version, `resources/delete` with an optional version check, typed errors, and how these interact with existing cache and notification fields. Listing reuses `resources/directory/read` from SEP-2640. Search, glob, bulk reads, and ranges are deferred. +This strawman covers the smallest slice of the charter's scope that fixes the write path: a metadata read (`resources/stat`), `resources/create` with create-if-absent semantics, `resources/update` conditional on a revision, `resources/delete` with an optional revision check, typed errors, and how these interact with existing cache and notification fields. Listing reuses `resources/directory/read` from SEP-2640. Search, glob, bulk reads, and ranges are deferred. -Vocabulary follows Kryspin Ziemski's working document shared in #filesystem-wg ("MCP Resources as Addressable Domain State"), which proposes Create, Read, Update, Delete, Query, and Range (CRUDQR) over Resources and a change-set entry `{uri, effect, previousVersion?, version?}`. This draft covers C, U, D, plus `stat`, and uses `version` as the concurrency token and the change-set entry in write results. Query and Range are deferred. +Vocabulary follows Kryspin Ziemski's working document shared in #filesystem-wg ("MCP Resources as Addressable Domain State"), which proposes Create, Read, Update, Delete, Query, and Range (CRUDQR) over Resources and a change-set entry `{uri, effect, previousVersion?, version?}`. This draft covers C, U, D, plus `stat`, and uses the change-set entry in write results. It calls the concurrency token a `revision`, the term [Problem Statement 5](../problem-statements.md#problem-statement-5-optimistic-concurrency-control) uses, so the entry here is `{uri, effect, previousRevision?, revision?}`. Query and Range are deferred. ## Extension identifier and capability negotiation @@ -36,15 +36,15 @@ A server that supports the extension declares it under `capabilities.extensions` Declaring the extension commits the server to serving all four methods below. A server that cannot perform an operation on a given URI returns `notSupported` for that URI. Clients MUST NOT call these methods on a server that did not declare the extension. No new methods are added to core, and no existing method changes type: per the core-maintainer meeting on 2026-10-07, extensions may add methods but must not change the types of existing ones. -## Versions +## Revisions -A `version` is an opaque string minted by the server for the current state of one resource. It plays the role of an HTTP strong ETag. +A `revision` is the opaque revision identifier that [Problem Statement 5](../problem-statements.md#problem-statement-5-optimistic-concurrency-control) asks for: a string minted by the server for the current state of one resource, compared byte-for-byte like a strong HTTP `ETag`. -- Clients compare versions only for exact string equality. Versions have no order. -- A version MUST change whenever the content or `mimeType` of the resource changes. A version MUST NOT be reused for a different state of the same URI, including after a delete and re-create. A digest over the content and `mimeType` is a valid version. -- Versions are scoped to one server and one URI. -- Returned by `resources/stat`, `resources/create`, and `resources/update`. Core shapes that this extension may not retype carry it in `_meta` under `io.modelcontextprotocol/resource-version`: entries in a `resources/directory/read` result (`Resource._meta`), and, when the server can provide it, each item of a `resources/read` result (`contents[]._meta`). The `_meta` key name is provisional. -- A directory resource's version SHOULD change when its set of direct children changes. +- Clients compare revisions only for exact string equality. Revisions have no order. +- A revision MUST change whenever the content or `mimeType` of the resource changes. A revision MUST NOT be reused for a different state of the same URI, including after a delete and re-create. A digest over the content and `mimeType` is a valid revision. +- Revisions are scoped to one server and one URI. +- Returned by `resources/stat`, `resources/create`, and `resources/update`. Core shapes that this extension may not retype carry it in `_meta` under `io.modelcontextprotocol/resource-revision`: entries in a `resources/directory/read` result (`Resource._meta`), and, when the server can provide it, each item of a `resources/read` result (`contents[]._meta`). The `_meta` key name is provisional. +- A directory resource's revision SHOULD change when its set of direct children changes. ## Methods @@ -52,7 +52,7 @@ Content in `create` and `update` uses the core `TextResourceContents` or `BlobRe ### `resources/stat` -Returns metadata and the current version without the content. The result is a core `Resource` plus `version`, and carries the SEP-2549 cache fields like other cacheable results. +Returns metadata and the current revision without the content. The result is a core `Resource` plus `revision`, and carries the SEP-2549 cache fields like other cacheable results. ```json { "method": "resources/stat", "params": { "uri": "mem://notes/plan.md" } } @@ -63,7 +63,7 @@ Returns metadata and the current version without the content. The result is a co "uri": "mem://notes/plan.md", "name": "plan.md", "mimeType": "text/markdown", "size": 7, "annotations": { "lastModified": "2026-10-09T14:02:11Z" } }, - "version": "17", + "revision": "17", "ttlMs": 0, "cacheScope": "private" } @@ -73,7 +73,7 @@ A directory resource stats with `mimeType: "inode/directory"`, as in SEP-2640. ### `resources/create` -Creates the resource at `uri` only if nothing exists there (create-if-absent). If the URI is taken, the server returns `alreadyExists` with the current version and changes nothing. There is no upsert: a client that wants to overwrite reads the version from the error and calls `resources/update`. +Creates the resource at `uri` only if nothing exists there (create-if-absent). If the URI is taken, the server returns `alreadyExists` with the current revision and changes nothing. There is no upsert: a client that wants to overwrite reads the revision from the error and calls `resources/update`. ```json { @@ -87,8 +87,8 @@ Creates the resource at `uri` only if nothing exists there (create-if-absent). I ```json { "resource": { "uri": "mem://notes/plan.md", "name": "plan.md", "mimeType": "text/markdown", "size": 7 }, - "version": "17", - "changes": [{ "uri": "mem://notes/plan.md", "effect": "created", "version": "17" }] + "revision": "17", + "changes": [{ "uri": "mem://notes/plan.md", "effect": "created", "revision": "17" }] } ``` @@ -96,7 +96,7 @@ The client chooses the URI. SEP-2571 instead had the server assign the URI; that ### `resources/update` -Replaces the content of an existing resource, only if its current version equals `ifMatch`. `ifMatch` is required, so every update is conditional and a lost update cannot happen by omission. The result reports the previous and new versions. +Replaces the content of an existing resource, only if its current revision equals `ifMatch`. `ifMatch` is required, so every update is conditional and a lost update cannot happen by omission. The result reports the previous and new revisions. ```json { @@ -111,8 +111,8 @@ Replaces the content of an existing resource, only if its current version equals ```json { "resource": { "uri": "mem://notes/plan.md", "name": "plan.md", "mimeType": "text/markdown", "size": 14 }, - "version": "18", - "changes": [{ "uri": "mem://notes/plan.md", "effect": "updated", "previousVersion": "17", "version": "18" }] + "revision": "18", + "changes": [{ "uri": "mem://notes/plan.md", "effect": "updated", "previousRevision": "17", "revision": "18" }] } ``` @@ -120,30 +120,30 @@ Update is full replacement. Partial updates (patch, append, range writes) are de ### `resources/delete` -Deletes the resource. With `ifMatch`, only if the current version matches. Deleting a directory resource is out of scope for this draft (`notSupported`). +Deletes the resource. With `ifMatch`, only if the current revision matches. Deleting a directory resource is out of scope for this draft (`notSupported`). ```json { "method": "resources/delete", "params": { "uri": "mem://notes/plan.md", "ifMatch": "18" } } ``` ```json -{ "changes": [{ "uri": "mem://notes/plan.md", "effect": "deleted", "previousVersion": "18" }] } +{ "changes": [{ "uri": "mem://notes/plan.md", "effect": "deleted", "previousRevision": "18" }] } ``` ### Change sets -Each write result carries `changes`, a list of change-set entries in the vocabulary of Kryspin's document. It MUST contain an entry for the target URI and MAY contain entries for other resources the operation affected. This draft uses only `created`, `updated`, and `deleted`; `invalidated` and `affected` are left to a later draft. `version` duplicates the target entry's `version` for convenience; whether to keep both is open. +Each write result carries `changes`, a list of change-set entries in the vocabulary of Kryspin's document. It MUST contain an entry for the target URI and MAY contain entries for other resources the operation affected. This draft uses only `created`, `updated`, and `deleted`; `invalidated` and `affected` are left to a later draft. `revision` duplicates the target entry's `revision` for convenience; whether to keep both is open. ### Concurrency -The server MUST compare the version and apply the write as one atomic step. A check followed by a separate write reintroduces the lost update this mechanism exists to prevent. +The server MUST compare the revision and apply the write as one atomic step. A check followed by a separate write reintroduces the lost update this mechanism exists to prevent. -The lost-update case from [use cases](../use-cases.md) plays out as follows. Two sessions stat `plan.md` at version `17`. Session A updates with `ifMatch: "17"` and gets version `18`. Session B updates with `ifMatch: "17"` and gets `versionMismatch` with `currentVersion: "18"`. B re-reads, merges, and retries with `ifMatch: "18"`. +The lost-update case from [use cases](../use-cases.md) plays out as follows. Two sessions stat `plan.md` at revision `17`. Session A updates with `ifMatch: "17"` and gets revision `18`. Session B updates with `ifMatch: "17"` and gets `revisionMismatch` with `currentRevision: "18"`. B re-reads, merges, and retries with `ifMatch: "18"`. ### Listing: reuse `resources/directory/read` The Skills extension ([SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640), Final) already defines `resources/directory/read`: request `{uri, cursor?}`, result `{resources: Resource[], nextCursor?}` listing direct children, with subdirectories as `inode/directory` resources and `-32602` for a missing or non-directory URI. SEP-2640 states that the method is not skill-specific and that a server MAY support it on any directory resource under any scheme. -This draft reuses that method unchanged and adds no listing method of its own. The only addition is a way to advertise it without the Skills extension: SEP-2640 gates the method behind `directoryRead` under `io.modelcontextprotocol/skills`, and declaring that extension commits a server to `skills/list` and `skills/get`, which a plain file server cannot honor. So this extension carries its own `directoryRead` setting with the same meaning. A client may call the method if either extension declares `directoryRead: true`. Entries carry their version in `_meta` (see Versions), which also gives SEP-2640 hosts the snapshot token that SEP-2640 notes it lacks. +This draft reuses that method unchanged and adds no listing method of its own. The only addition is a way to advertise it without the Skills extension: SEP-2640 gates the method behind `directoryRead` under `io.modelcontextprotocol/skills`, and declaring that extension commits a server to `skills/list` and `skills/get`, which a plain file server cannot honor. So this extension carries its own `directoryRead` setting with the same meaning. A client may call the method if either extension declares `directoryRead: true`. Entries carry their revision in `_meta` (see Revisions), which also gives SEP-2640 hosts the snapshot token that SEP-2640 notes it lacks. The cleaner long-term shape is probably for one definition to live in one place (this extension, or core) with Skills referencing it. That is for the Skills Over MCP WG and this WG to agree on ([open question 6](../open-questions.md)). @@ -160,32 +160,32 @@ The cleaner long-term shape is probably for one definition to live in one place | :--- | :--- | :--- | :--- | | Resource does not exist | `-32602` Invalid params | `notFound` | `uri` | | URI malformed, `contents.uri` mismatch, or not a directory (`resources/directory/read`) | `-32602` Invalid params | `invalidUri` | `uri` | -| `ifMatch` does not match | `-32010` (provisional) | `versionMismatch` | `uri`, `currentVersion` | -| `resources/create` on an existing URI | `-32010` (provisional) | `alreadyExists` | `uri`, `currentVersion` | +| `ifMatch` does not match | `-32010` (provisional) | `revisionMismatch` | `uri`, `currentRevision` | +| `resources/create` on an existing URI | `-32010` (provisional) | `alreadyExists` | `uri`, `currentRevision` | | Operation not supported for this URI (read-only scheme, directory, ancestor is a file) | `-32011` (provisional) | `notSupported` | `uri` | | Extension not declared, or wrong protocol version | `-32601` Method not found | | | -Not found reuses `-32602` because core `resources/read` (2026-07-28) and SEP-2640's `resources/directory/read` already use it for unknown resources; `data.reason` lets a client tell it apart from other invalid params without a new code. Version mismatch and already-exists share one code because both mean "your precondition about the current state was wrong" (HTTP 412 covers `If-Match` and `If-None-Match: *` alike) and both carry the current version for recovery. +Not found reuses `-32602` because core `resources/read` (2026-07-28) and SEP-2640's `resources/directory/read` already use it for unknown resources; `data.reason` lets a client tell it apart from other invalid params without a new code. Revision mismatch and already-exists share one code because both mean "your precondition about the current state was wrong" (HTTP 412 covers `If-Match` and `If-None-Match: *` alike) and both carry the current revision for recovery. The two new codes are placeholders in the implementation-defined band. The 2026-07-28 allocation policy reserves `-32020..-32099` for spec-defined codes allocated in sequence, `-32000` and `-32001` are used by the Python SDK internally, and `-32002` is retired. Where extension error codes should come from is an open question. ## Interactions -**`notifications/resources/updated`.** After a successful update or delete, the server SHOULD send `notifications/resources/updated` for the URI to clients subscribed to it (through `subscriptions/listen` with `resourceSubscriptions` on 2026-07-28, or `resources/subscribe` on earlier versions). After a create or delete, it SHOULD send `notifications/resources/list_changed` if it declared `listChanged`. The writer may receive its own notification; it can compare its result's `version` with a fresh `stat`. Open: whether the notification should carry the change-set entry in `_meta` so subscribers can skip the `stat`. +**`notifications/resources/updated`.** After a successful update or delete, the server SHOULD send `notifications/resources/updated` for the URI to clients subscribed to it (through `subscriptions/listen` with `resourceSubscriptions` on 2026-07-28, or `resources/subscribe` on earlier versions). After a create or delete, it SHOULD send `notifications/resources/list_changed` if it declared `listChanged`. The writer may receive its own notification; it can compare its result's `revision` with a fresh `stat`. Open: whether the notification should carry the change-set entry in `_meta` so subscribers can skip the `stat`. **`lastModified`.** Servers that track modification time MUST set `annotations.lastModified` on the `Resource` returned by `stat` and by writes, and update it on every write. It is informational only and MUST NOT be used as a precondition: timestamps have coarse resolution and clocks skew, so two writes can share one. -**`ttlMs`.** `resources/stat` results carry `ttlMs` as a freshness hint for metadata, as SEP-2549 defines for other results. A cached version is only ever a guess: the conditional write is the check, and a stale version fails safely with `versionMismatch`. A client SHOULD discard cached reads and stats of a URI after its own successful write to it or an updated notification for it. Write results are not cacheable. +**`ttlMs`.** `resources/stat` results carry `ttlMs` as a freshness hint for metadata, as SEP-2549 defines for other results. A cached revision is only ever a guess: the conditional write is the check, and a stale revision fails safely with `revisionMismatch`. A client SHOULD discard cached reads and stats of a URI after its own successful write to it or an updated notification for it. Write results are not cacheable. -**`cacheScope`.** `resources/stat` defaults to `"private"`. A version reveals that and when a resource changed, so a server whose resources differ per user MUST NOT mark `stat` results `"public"`. Open: whether `"public"` should be allowed at all for `stat`. +**`cacheScope`.** `resources/stat` defaults to `"private"`. A revision reveals that and when a resource changed, so a server whose resources differ per user MUST NOT mark `stat` results `"public"`. Open: whether `"public"` should be allowed at all for `stat`. ## SDK extension points The Python prototype is built only on the MCP Python SDK's public `Extension` and `MethodBinding` API (`mcp>=2,<3`, tested on 2.3.0), per [SEP-3371](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3371), without patching the SDK. It works, with these gaps, offered as feedback for SEP-3371: 1. **Extension method handlers cannot publish change events.** A `MethodBinding` handler receives a `ServerRequestContext`, which has no handle on the server's `SubscriptionBus`; only the `MCPServer`-level `Context` has `notify_resource_updated`. On 2026-07-28 connections, `session.send_resource_updated` is dropped by design (change notifications travel only on `subscriptions/listen` streams). The prototype works around this by having the server author pass the same bus to both `MCPServer(subscriptions=bus)` and the extension. A bus or notify API on the handler context would remove that wiring. -2. **Events carry no metadata.** `ResourceUpdated` holds only `uri`, so the `_meta` of `notifications/resources/updated` cannot carry a version or change-set entry through the bus, even though the wire notification allows `_meta`. -3. **Extensions cannot contribute to `resources/read`.** `MethodBinding` rejects spec methods (correctly, given the 2026-10-07 rule), `ResourceBinding` takes only static resources, resource templates take one static `meta` per template, and an `Extension` cannot contribute middleware. So the extension can neither make store-backed resources readable through `resources/read` nor stamp a per-read version into `contents[]._meta`; the server author has to register a template. A per-read hook for `_meta`, or letting an extension contribute a resource provider, would close this. +2. **Events carry no metadata.** `ResourceUpdated` holds only `uri`, so the `_meta` of `notifications/resources/updated` cannot carry a revision or change-set entry through the bus, even though the wire notification allows `_meta`. +3. **Extensions cannot contribute to `resources/read`.** `MethodBinding` rejects spec methods (correctly, given the 2026-10-07 rule), `ResourceBinding` takes only static resources, resource templates take one static `meta` per template, and an `Extension` cannot contribute middleware. So the extension can neither make store-backed resources readable through `resources/read` nor stamp a per-read revision into `contents[]._meta`; the server author has to register a template. A per-read hook for `_meta`, or letting an extension contribute a resource provider, would close this. 4. **One method, two extensions.** `MCPServer` raises if two extensions bind the same method. A server running both Skills (with `read_directory`) and this extension must bind `resources/directory/read` in exactly one of them; the prototype makes it opt-in (`directory_read=True`) for that reason. Shared methods across extensions need an ownership rule. 5. **Legacy wire.** `capabilities.extensions` exists only on 2026-07-28 in this SDK, and `MCPServer` does not serve `resources/subscribe`, which an extension cannot bind. The prototype therefore serves its methods on 2026-07-28 only. @@ -194,15 +194,15 @@ The Python prototype is built only on the MCP Python SDK's public `Extension` an Numbers refer to [open-questions.md](../open-questions.md); "new" marks questions this draft raises. - **#1** Files as Resource URIs: not addressed (see below). This draft assumes `resources/*` and would be renamed if the group chooses a `files/*` family. -- **#2** Concurrency: this draft proposes an opaque `version`, required `ifMatch` on update, optional on delete, and create-if-absent as the only create. Alternatives: optional `ifMatch` on update (unconditional overwrite allowed); HTTP-style `ifNoneMatch: "*"` on a single write method instead of separate create and update. -- **#3** Interactions: proposed above. Open: change-set entry in notification `_meta`; `cacheScope: "public"` for `stat`; whether `resources/read` SHOULD or MUST carry the version in `_meta`, and the key name. +- **#2** Concurrency: this draft proposes an opaque `revision`, required `ifMatch` on update, optional on delete, and create-if-absent as the only create. Alternatives: optional `ifMatch` on update (unconditional overwrite allowed); HTTP-style `ifNoneMatch: "*"` on a single write method instead of separate create and update. +- **#3** Interactions: proposed above. Open: change-set entry in notification `_meta`; `cacheScope: "public"` for `stat`; whether `resources/read` SHOULD or MUST carry the revision in `_meta`, and the key name. - **#4** Typed errors: proposed above. Open: where extension error codes are allocated; whether `notFound` deserves its own code despite core's `-32602`. - **#5** One SEP or several: this draft suggests one SEP for `stat` and writes, with listing settled jointly with Skills, and Query and Range in a later SEP. - **#6** Skills directory read: reuse as-is with a second `directoryRead` flag. Open: whether the definition moves here or to core. - **New** Server-assigned URIs (SEP-2571 style, create in a collection) alongside client-chosen URIs. - **New** Whether a server can declare `stat` without writes (a read-only setting) instead of answering `notSupported` per URI. - **New** The extension identifier and `_meta` key names, and whether experimental builds should use a different prefix. -- **New** Whether `version` and `changes` both belong in write results. +- **New** Whether `revision` and `changes` both belong in write results. ## Not addressed diff --git a/docs/use-cases.md b/docs/use-cases.md index afe6ccc..cfebd29 100644 --- a/docs/use-cases.md +++ b/docs/use-cases.md @@ -8,7 +8,7 @@ The charter names multi-agent and job orchestration systems as the motivating co ## Memory servers -A memory server stores facts as files that an agent reads and rewrites. Today a write is a tool call that replaces the file, so two sessions updating the same memory lose one update without either noticing. A conditional write (update only if the version is still the one I read) and create-if-absent would make that safe, and `notifications/resources/updated` would tell other sessions to re-read. +A memory server stores facts as files that an agent reads and rewrites. Today a write is a tool call that replaces the file, so two sessions updating the same memory lose one update without either noticing. A conditional write (update only if the revision is still the one I read) and create-if-absent would make that safe, and `notifications/resources/updated` would tell other sessions to re-read. ## Servers that ask for base64 content in prose diff --git a/python/sdk/README.md b/python/sdk/README.md index 5bd1d7e..8777cc7 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -30,16 +30,16 @@ Client: wrap a connected session. Errors arrive as typed exceptions. from mcp import Client from mcp_types import TextResourceContents -from mcp_ext_filesystems import ResourceOperationsClient, VersionMismatch +from mcp_ext_filesystems import ResourceOperationsClient, RevisionMismatch uri = "mem://notes/plan.md" async with Client(server) as client: ops = ResourceOperationsClient(client.session) created = await ops.create(uri, TextResourceContents(uri=uri, text="# Plan\n")) try: - await ops.update(uri, TextResourceContents(uri=uri, text="# Plan\n- ship\n"), if_match=created.version) - except VersionMismatch as conflict: - ... # someone else wrote first: re-read, merge, retry with conflict.current_version + await ops.update(uri, TextResourceContents(uri=uri, text="# Plan\n- ship\n"), if_match=created.revision) + except RevisionMismatch as conflict: + ... # someone else wrote first: re-read, merge, retry with conflict.current_revision ``` Store-backed resources are not readable through `resources/read` unless the server registers a resource template for them; see "SDK extension points" in the proposal. diff --git a/python/sdk/src/mcp_ext_filesystems/__init__.py b/python/sdk/src/mcp_ext_filesystems/__init__.py index 2e43578..cfdf4e6 100644 --- a/python/sdk/src/mcp_ext_filesystems/__init__.py +++ b/python/sdk/src/mcp_ext_filesystems/__init__.py @@ -11,10 +11,10 @@ OperationNotSupported, ResourceNotFound, ResourceOperationError, - VersionMismatch, + RevisionMismatch, ) from mcp_ext_filesystems.server import ResourceOperations from mcp_ext_filesystems.store import InMemoryStore, ResourceStore, Stat -from mcp_ext_filesystems.wire import EXTENSION_ID, VERSION_META_KEY, ResourceChange +from mcp_ext_filesystems.wire import EXTENSION_ID, REVISION_META_KEY, ResourceChange __version__ = version("mcp-ext-filesystems") diff --git a/python/sdk/src/mcp_ext_filesystems/client.py b/python/sdk/src/mcp_ext_filesystems/client.py index 8fedcf0..f4080c4 100644 --- a/python/sdk/src/mcp_ext_filesystems/client.py +++ b/python/sdk/src/mcp_ext_filesystems/client.py @@ -3,7 +3,7 @@ async with Client(server) as client: ops = ResourceOperationsClient(client.session) stat = await ops.stat("mem://notes/plan.md") - await ops.update("mem://notes/plan.md", TextResourceContents(uri=..., text="..."), if_match=stat.version) + await ops.update("mem://notes/plan.md", TextResourceContents(uri=..., text="..."), if_match=stat.revision) Wire errors come back as the typed exceptions in `mcp_ext_filesystems.errors`. """ diff --git a/python/sdk/src/mcp_ext_filesystems/errors.py b/python/sdk/src/mcp_ext_filesystems/errors.py index 64a0ec6..e5bc32b 100644 --- a/python/sdk/src/mcp_ext_filesystems/errors.py +++ b/python/sdk/src/mcp_ext_filesystems/errors.py @@ -14,7 +14,7 @@ from mcp_types import INVALID_PARAMS PRECONDITION_FAILED = -32010 -"""Provisional: `ifMatch` did not match, or `resources/create` found the URI taken. `data.currentVersion` is set.""" +"""Provisional: `ifMatch` did not match, or `resources/create` found the URI taken. `data.currentRevision` is set.""" NOT_SUPPORTED = -32011 """Provisional: the server does not support this operation on this URI.""" @@ -26,13 +26,13 @@ class ResourceOperationError(MCPError): code_value: int = INVALID_PARAMS reason: str = "" - def __init__(self, uri: str, message: str, *, current_version: str | None = None) -> None: + def __init__(self, uri: str, message: str, *, current_revision: str | None = None) -> None: data: dict[str, Any] = {"uri": uri, "reason": self.reason} - if current_version is not None: - data["currentVersion"] = current_version + if current_revision is not None: + data["currentRevision"] = current_revision super().__init__(code=self.code_value, message=message, data=data) self.uri = uri - self.current_version = current_version + self.current_revision = current_revision class ResourceNotFound(ResourceOperationError): @@ -49,20 +49,22 @@ def __init__(self, uri: str, detail: str = "not a valid resource URI") -> None: super().__init__(uri, f"Invalid resource URI {uri!r}: {detail}") -class VersionMismatch(ResourceOperationError): +class RevisionMismatch(ResourceOperationError): code_value = PRECONDITION_FAILED - reason = "versionMismatch" + reason = "revisionMismatch" - def __init__(self, uri: str, current_version: str) -> None: - super().__init__(uri, f"ifMatch does not match the current version of {uri}", current_version=current_version) + def __init__(self, uri: str, current_revision: str) -> None: + super().__init__( + uri, f"ifMatch does not match the current revision of {uri}", current_revision=current_revision + ) class AlreadyExists(ResourceOperationError): code_value = PRECONDITION_FAILED reason = "alreadyExists" - def __init__(self, uri: str, current_version: str) -> None: - super().__init__(uri, f"Resource already exists: {uri}", current_version=current_version) + def __init__(self, uri: str, current_revision: str) -> None: + super().__init__(uri, f"Resource already exists: {uri}", current_revision=current_revision) class OperationNotSupported(ResourceOperationError): @@ -76,7 +78,7 @@ def __init__(self, uri: str, detail: str = "operation not supported for this URI def from_mcp_error(error: MCPError) -> MCPError: """Map a wire error back to its typed exception; return `error` unchanged if it is not one of ours.""" data = error.data if isinstance(error.data, dict) else {} - uri, reason, current = data.get("uri"), data.get("reason"), data.get("currentVersion") + uri, reason, current = data.get("uri"), data.get("reason"), data.get("currentRevision") if not isinstance(uri, str): return error if error.code == INVALID_PARAMS and reason == "notFound": @@ -84,8 +86,8 @@ def from_mcp_error(error: MCPError) -> MCPError: if error.code == INVALID_PARAMS and reason == "invalidUri": return InvalidResourceUri(uri) if error.code == PRECONDITION_FAILED and isinstance(current, str): - if reason == "versionMismatch": - return VersionMismatch(uri, current) + if reason == "revisionMismatch": + return RevisionMismatch(uri, current) if reason == "alreadyExists": return AlreadyExists(uri, current) if error.code == NOT_SUPPORTED: diff --git a/python/sdk/src/mcp_ext_filesystems/server.py b/python/sdk/src/mcp_ext_filesystems/server.py index 9e69620..34b69da 100644 --- a/python/sdk/src/mcp_ext_filesystems/server.py +++ b/python/sdk/src/mcp_ext_filesystems/server.py @@ -83,28 +83,28 @@ def methods(self) -> Sequence[MethodBinding]: async def _stat(self, ctx: ServerRequestContext[Any, Any], params: StatParams) -> HandlerResult: _check_uri(params.uri) stat = await self._store.stat(params.uri) - return StatResult(resource=stat.resource, version=stat.version) + return StatResult(resource=stat.resource, revision=stat.revision) async def _create(self, ctx: ServerRequestContext[Any, Any], params: CreateParams) -> HandlerResult: _check_uri(params.uri, params.contents.uri) stat = await self._store.create(params.uri, params.contents) await self._publish(ResourcesListChanged()) - change = ResourceChange(uri=params.uri, effect="created", version=stat.version) - return CreateResult(resource=stat.resource, version=stat.version, changes=[change]) + change = ResourceChange(uri=params.uri, effect="created", revision=stat.revision) + return CreateResult(resource=stat.resource, revision=stat.revision, changes=[change]) async def _update(self, ctx: ServerRequestContext[Any, Any], params: UpdateParams) -> HandlerResult: _check_uri(params.uri, params.contents.uri) previous, stat = await self._store.update(params.uri, params.contents, params.if_match) await self._publish(ResourceUpdated(uri=params.uri)) - change = ResourceChange(uri=params.uri, effect="updated", previous_version=previous, version=stat.version) - return UpdateResult(resource=stat.resource, version=stat.version, changes=[change]) + change = ResourceChange(uri=params.uri, effect="updated", previous_revision=previous, revision=stat.revision) + return UpdateResult(resource=stat.resource, revision=stat.revision, changes=[change]) async def _delete(self, ctx: ServerRequestContext[Any, Any], params: DeleteParams) -> HandlerResult: _check_uri(params.uri) previous = await self._store.delete(params.uri, params.if_match) await self._publish(ResourceUpdated(uri=params.uri)) await self._publish(ResourcesListChanged()) - return DeleteResult(changes=[ResourceChange(uri=params.uri, effect="deleted", previous_version=previous)]) + return DeleteResult(changes=[ResourceChange(uri=params.uri, effect="deleted", previous_revision=previous)]) async def _read_directory(self, ctx: ServerRequestContext[Any, Any], params: ReadDirectoryParams) -> HandlerResult: _check_uri(params.uri) diff --git a/python/sdk/src/mcp_ext_filesystems/store.py b/python/sdk/src/mcp_ext_filesystems/store.py index 0d9a093..b40681f 100644 --- a/python/sdk/src/mcp_ext_filesystems/store.py +++ b/python/sdk/src/mcp_ext_filesystems/store.py @@ -16,23 +16,23 @@ InvalidResourceUri, OperationNotSupported, ResourceNotFound, - VersionMismatch, + RevisionMismatch, ) -from mcp_ext_filesystems.wire import DIRECTORY_MIME_TYPE, VERSION_META_KEY, ResourceContents +from mcp_ext_filesystems.wire import DIRECTORY_MIME_TYPE, REVISION_META_KEY, ResourceContents @dataclass(frozen=True) class Stat: - """A resource's metadata and its current version.""" + """A resource's metadata and its current revision.""" resource: Resource - version: str + revision: str class ResourceStore(Protocol): """Backend for the extension. - Each write MUST compare the version and apply the change atomically: the extension does not hold a lock + Each write MUST compare the revision and apply the change atomically: the extension does not hold a lock between a check and a write, so a store that checks then writes in two steps reintroduces lost updates. Methods raise the typed errors in `mcp_ext_filesystems.errors`. """ @@ -42,15 +42,15 @@ async def stat(self, uri: str) -> Stat: ... async def create(self, uri: str, contents: ResourceContents) -> Stat: - """Create `uri` only if it does not exist; raise `AlreadyExists` with the current version otherwise.""" + """Create `uri` only if it does not exist; raise `AlreadyExists` with the current revision otherwise.""" ... async def update(self, uri: str, contents: ResourceContents, if_match: str) -> tuple[str, Stat]: - """Replace `uri` only if its version equals `if_match`. Return the previous version and the new stat.""" + """Replace `uri` only if its revision equals `if_match`. Return the previous revision and the new stat.""" ... async def delete(self, uri: str, if_match: str | None) -> str: - """Delete `uri`, conditionally if `if_match` is set. Return the deleted version.""" + """Delete `uri`, conditionally if `if_match` is set. Return the deleted revision.""" ... async def read_directory(self, uri: str) -> list[Resource]: @@ -61,21 +61,21 @@ async def read_directory(self, uri: str) -> list[Resource]: @dataclass(frozen=True) class _Entry: contents: ResourceContents - version: str + revision: str last_modified: str class InMemoryStore: """A flat map from URI to contents. Directories are implicit: a URI is a directory if it has descendants. - Versions come from one counter shared by every URI, so a deleted and re-created resource never repeats a - version. Writes contain no `await`, which makes each compare-and-write atomic on a single event loop. + Revisions come from one counter shared by every URI, so a deleted and re-created resource never repeats a + revision. Writes contain no `await`, which makes each compare-and-write atomic on a single event loop. """ def __init__(self, scheme: str = "mem") -> None: self._scheme = scheme self._entries: dict[str, _Entry] = {} - self._versions = count(1) + self._revisions = count(1) async def stat(self, uri: str) -> Stat: return self._stat(uri) @@ -83,34 +83,34 @@ async def stat(self, uri: str) -> Stat: def _stat(self, uri: str) -> Stat: entry = self._entries.get(uri) if entry is not None: - return Stat(_describe(uri, entry), entry.version) + return Stat(_describe(uri, entry), entry.revision) children = self._children(uri) if not children: raise ResourceNotFound(uri) - # A directory's version changes whenever the set of its direct children or any child's version changes. - digest = hashlib.sha256(repr(sorted((c.uri, (c.meta or {}).get(VERSION_META_KEY)) for c in children)).encode()) + # A directory's revision changes whenever the set of its direct children or any child's revision changes. + digest = hashlib.sha256(repr(sorted((c.uri, (c.meta or {}).get(REVISION_META_KEY)) for c in children)).encode()) return Stat(_directory(uri), "d-" + digest.hexdigest()[:16]) async def create(self, uri: str, contents: ResourceContents) -> Stat: self._check_writable(uri) if uri in self._entries or self._children(uri): - raise AlreadyExists(uri, self._stat(uri).version) + raise AlreadyExists(uri, self._stat(uri).revision) entry = self._write(uri, contents) - return Stat(_describe(uri, entry), entry.version) + return Stat(_describe(uri, entry), entry.revision) async def update(self, uri: str, contents: ResourceContents, if_match: str) -> tuple[str, Stat]: current = self._file(uri) - if current.version != if_match: - raise VersionMismatch(uri, current.version) + if current.revision != if_match: + raise RevisionMismatch(uri, current.revision) entry = self._write(uri, contents) - return current.version, Stat(_describe(uri, entry), entry.version) + return current.revision, Stat(_describe(uri, entry), entry.revision) async def delete(self, uri: str, if_match: str | None) -> str: current = self._file(uri) - if if_match is not None and current.version != if_match: - raise VersionMismatch(uri, current.version) + if if_match is not None and current.revision != if_match: + raise RevisionMismatch(uri, current.revision) del self._entries[uri] - return current.version + return current.revision async def read_directory(self, uri: str) -> list[Resource]: if uri in self._entries: @@ -138,7 +138,7 @@ def _check_writable(self, uri: str) -> None: def _write(self, uri: str, contents: ResourceContents) -> _Entry: stamp = datetime.now(timezone.utc).isoformat() - entry = _Entry(contents.model_copy(update={"uri": uri}), str(next(self._versions)), stamp) + entry = _Entry(contents.model_copy(update={"uri": uri}), str(next(self._revisions)), stamp) self._entries[uri] = entry return entry @@ -169,7 +169,7 @@ def _describe(uri: str, entry: _Entry) -> Resource: mime_type=contents.mime_type, size=size, annotations=Annotations(last_modified=entry.last_modified), - _meta={VERSION_META_KEY: entry.version}, + _meta={REVISION_META_KEY: entry.revision}, ) diff --git a/python/sdk/src/mcp_ext_filesystems/wire.py b/python/sdk/src/mcp_ext_filesystems/wire.py index 5663400..ec2c4f4 100644 --- a/python/sdk/src/mcp_ext_filesystems/wire.py +++ b/python/sdk/src/mcp_ext_filesystems/wire.py @@ -21,8 +21,8 @@ EXTENSION_ID = "io.modelcontextprotocol/resource-operations" """Provisional identifier, advertised under `capabilities.extensions` (SEP-2133).""" -VERSION_META_KEY = "io.modelcontextprotocol/resource-version" -"""`_meta` key that carries a version on core shapes this extension may not retype (read contents, list entries).""" +REVISION_META_KEY = "io.modelcontextprotocol/resource-revision" +"""`_meta` key that carries a revision on core shapes this extension may not retype (read contents, list entries).""" DIRECTORY_MIME_TYPE = "inode/directory" """Marks a directory resource, as in SEP-2640.""" @@ -47,8 +47,8 @@ class ResourceChange(_WireModel): uri: str effect: Effect - previous_version: str | None = None - version: str | None = None + previous_revision: str | None = None + revision: str | None = None class StatParams(RequestParams): @@ -57,7 +57,7 @@ class StatParams(RequestParams): class StatResult(CacheableResult): resource: Resource - version: str + revision: str class CreateParams(RequestParams): @@ -67,7 +67,7 @@ class CreateParams(RequestParams): class CreateResult(Result): resource: Resource - version: str + revision: str changes: list[ResourceChange] @@ -79,7 +79,7 @@ class UpdateParams(RequestParams): class UpdateResult(Result): resource: Resource - version: str + revision: str changes: list[ResourceChange] diff --git a/python/sdk/tests/test_resource_operations.py b/python/sdk/tests/test_resource_operations.py index d81221c..bc7e23c 100644 --- a/python/sdk/tests/test_resource_operations.py +++ b/python/sdk/tests/test_resource_operations.py @@ -15,7 +15,7 @@ EXTENSION_ID, NOT_SUPPORTED, PRECONDITION_FAILED, - VERSION_META_KEY, + REVISION_META_KEY, AlreadyExists, InMemoryStore, InvalidResourceUri, @@ -23,7 +23,7 @@ ResourceNotFound, ResourceOperations, ResourceOperationsClient, - VersionMismatch, + RevisionMismatch, ) from mcp_ext_filesystems.wire import DIRECTORY_MIME_TYPE, StatParams, StatRequest, StatResult @@ -65,56 +65,60 @@ async def test_create_stat_update_delete() -> None: assert created.resource.size == 2 assert created.resource.annotations is not None and created.resource.annotations.last_modified assert [c.effect for c in created.changes] == ["created"] - assert created.changes[0].version == created.version + assert created.changes[0].revision == created.revision stat = await ops.stat(URI) - assert stat.version == created.version + assert stat.revision == created.revision assert stat.resource.mime_type == "text/markdown" - updated = await ops.update(URI, text("version two"), if_match=stat.version) - assert updated.version != stat.version - assert updated.resource.size == len("version two") + updated = await ops.update(URI, text("revision two"), if_match=stat.revision) + assert updated.revision != stat.revision + assert updated.resource.size == len("revision two") change = updated.changes[0] - assert (change.effect, change.previous_version, change.version) == ("updated", stat.version, updated.version) + assert (change.effect, change.previous_revision, change.revision) == ( + "updated", + stat.revision, + updated.revision, + ) - deleted = await ops.delete(URI, if_match=updated.version) + deleted = await ops.delete(URI, if_match=updated.revision) assert deleted.changes[0].effect == "deleted" - assert deleted.changes[0].previous_version == updated.version + assert deleted.changes[0].previous_revision == updated.revision with pytest.raises(ResourceNotFound): await ops.stat(URI) -async def test_create_if_absent_conflict_returns_the_current_version() -> None: +async def test_create_if_absent_conflict_returns_the_current_revision() -> None: async with Client(make_server()) as client: ops = ResourceOperationsClient(client.session) first = await ops.create(URI, text("mine")) with pytest.raises(AlreadyExists) as caught: await ops.create(URI, text("theirs")) assert caught.value.code == PRECONDITION_FAILED - assert caught.value.data == {"uri": URI, "reason": "alreadyExists", "currentVersion": first.version} + assert caught.value.data == {"uri": URI, "reason": "alreadyExists", "currentRevision": first.revision} -async def test_stale_if_match_is_rejected_with_the_current_version() -> None: +async def test_stale_if_match_is_rejected_with_the_current_revision() -> None: async with Client(make_server()) as client: ops = ResourceOperationsClient(client.session) - v1 = (await ops.create(URI, text("one"))).version - v2 = (await ops.update(URI, text("two"), if_match=v1)).version - with pytest.raises(VersionMismatch) as caught: + v1 = (await ops.create(URI, text("one"))).revision + v2 = (await ops.update(URI, text("two"), if_match=v1)).revision + with pytest.raises(RevisionMismatch) as caught: await ops.update(URI, text("stale"), if_match=v1) - assert (await ops.stat(URI)).version == v2 + assert (await ops.stat(URI)).revision == v2 assert caught.value.code == PRECONDITION_FAILED - assert caught.value.data == {"uri": URI, "reason": "versionMismatch", "currentVersion": v2} + assert caught.value.data == {"uri": URI, "reason": "revisionMismatch", "currentRevision": v2} async def test_delete_with_stale_if_match_keeps_the_resource() -> None: async with Client(make_server()) as client: ops = ResourceOperationsClient(client.session) - v1 = (await ops.create(URI, text("one"))).version - v2 = (await ops.update(URI, text("two"), if_match=v1)).version - with pytest.raises(VersionMismatch) as caught: + v1 = (await ops.create(URI, text("one"))).revision + v2 = (await ops.update(URI, text("two"), if_match=v1)).revision + with pytest.raises(RevisionMismatch) as caught: await ops.delete(URI, if_match=v1) - assert caught.value.current_version == v2 - assert (await ops.stat(URI)).version == v2 + assert caught.value.current_revision == v2 + assert (await ops.stat(URI)).revision == v2 await ops.delete(URI) with pytest.raises(ResourceNotFound): await ops.stat(URI) @@ -146,9 +150,9 @@ async def test_update_and_delete_notify_resource_subscribers() -> None: server = make_server() async with Client(server) as watcher, Client(server) as writer: ops = ResourceOperationsClient(writer.session) - version = (await ops.create(URI, text("one"))).version + revision = (await ops.create(URI, text("one"))).revision async with watcher.listen(resource_subscriptions=[URI]) as subscription: - await ops.update(URI, text("two"), if_match=version) + await ops.update(URI, text("two"), if_match=revision) with anyio.fail_after(2): assert await subscription.__anext__() == ResourceUpdated(uri=URI) await ops.delete(URI) @@ -157,7 +161,7 @@ async def test_update_and_delete_notify_resource_subscribers() -> None: async def test_lost_update_is_rejected() -> None: - """docs/use-cases.md, Memory servers: two sessions read the same version and both write it back.""" + """docs/use-cases.md, Memory servers: two sessions read the same revision and both write it back.""" server = make_server() async with Client(server) as first, Client(server) as second: a, b = ResourceOperationsClient(first.session), ResourceOperationsClient(second.session) @@ -165,16 +169,16 @@ async def test_lost_update_is_rejected() -> None: seen_by_a = await a.stat(URI) seen_by_b = await b.stat(URI) - assert seen_by_a.version == seen_by_b.version + assert seen_by_a.revision == seen_by_b.revision - written = await a.update(URI, text("- fact 0\n- fact A\n"), if_match=seen_by_a.version) - with pytest.raises(VersionMismatch) as caught: - await b.update(URI, text("- fact 0\n- fact B\n"), if_match=seen_by_b.version) - assert caught.value.current_version == written.version + written = await a.update(URI, text("- fact 0\n- fact A\n"), if_match=seen_by_a.revision) + with pytest.raises(RevisionMismatch) as caught: + await b.update(URI, text("- fact 0\n- fact B\n"), if_match=seen_by_b.revision) + assert caught.value.current_revision == written.revision - # The rejected writer re-reads, merges, and retries against the version it was told about. - retried = await b.update(URI, text("- fact 0\n- fact A\n- fact B\n"), if_match=caught.value.current_version) - assert retried.changes[0].previous_version == written.version + # The rejected writer re-reads, merges, and retries against the revision it was told about. + retried = await b.update(URI, text("- fact 0\n- fact A\n- fact B\n"), if_match=caught.value.current_revision) + assert retried.changes[0].previous_revision == written.revision async def test_directory_read_reuses_the_skills_method_shape() -> None: @@ -185,7 +189,7 @@ async def test_directory_read_reuses_the_skills_method_shape() -> None: children = {r.uri: r for r in await ops.read_directory("mem://notes")} assert set(children) == {URI, "mem://notes/archive"} - assert children[URI].meta == {VERSION_META_KEY: created.version} + assert children[URI].meta == {REVISION_META_KEY: created.revision} assert children["mem://notes/archive"].mime_type == DIRECTORY_MIME_TYPE assert (await ops.stat("mem://notes/archive")).resource.mime_type == DIRECTORY_MIME_TYPE From 5fc979556ef8a3a62b0a475d36eb35abea069525 Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:41:26 +0000 Subject: [PATCH 13/14] Use revision in the memory-server use case to match the problem statements Co-Authored-By: Claude Opus 5.5 --- docs/use-cases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/use-cases.md b/docs/use-cases.md index afe6ccc..cfebd29 100644 --- a/docs/use-cases.md +++ b/docs/use-cases.md @@ -8,7 +8,7 @@ The charter names multi-agent and job orchestration systems as the motivating co ## Memory servers -A memory server stores facts as files that an agent reads and rewrites. Today a write is a tool call that replaces the file, so two sessions updating the same memory lose one update without either noticing. A conditional write (update only if the version is still the one I read) and create-if-absent would make that safe, and `notifications/resources/updated` would tell other sessions to re-read. +A memory server stores facts as files that an agent reads and rewrites. Today a write is a tool call that replaces the file, so two sessions updating the same memory lose one update without either noticing. A conditional write (update only if the revision is still the one I read) and create-if-absent would make that safe, and `notifications/resources/updated` would tell other sessions to re-read. ## Servers that ask for base64 content in prose From db67f78a55efa89ad1f62cd716c61110e36aa34d Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:14:00 +0000 Subject: [PATCH 14/14] Address review: problem-statement refs, required ifMatch on delete, spec and prototype fixes - Cite problem-statement items (PSn.m) and the three surviving cross-cutting open questions instead of the old open-questions.md numbering. - Add open questions for PS4.4, PS4.5, PS5.5, PS6.3, PS6.4. - Require ifMatch on resources/delete, as on update. - Drop contents.uri from create/update params; show lastModified in write results. - Prototype: notSupported for foreign schemes on update/delete, accept any RFC 3986 absolute URI, cursor pagination for resources/directory/read. Co-Authored-By: Claude Opus 5.5 --- conformance/resource-operations-conflicts.md | 12 +- docs/decisions.md | 6 +- docs/proposals/resource-operations.md | 55 +++++---- python/sdk/README.md | 7 +- .../sdk/src/mcp_ext_filesystems/__init__.py | 2 +- python/sdk/src/mcp_ext_filesystems/client.py | 10 +- python/sdk/src/mcp_ext_filesystems/server.py | 18 +-- python/sdk/src/mcp_ext_filesystems/store.py | 63 +++++++---- python/sdk/src/mcp_ext_filesystems/wire.py | 29 ++++- python/sdk/tests/test_resource_operations.py | 106 +++++++++++++++--- 10 files changed, 218 insertions(+), 90 deletions(-) diff --git a/conformance/resource-operations-conflicts.md b/conformance/resource-operations-conflicts.md index 7b4bd4f..fe97b6a 100644 --- a/conformance/resource-operations-conflicts.md +++ b/conformance/resource-operations-conflicts.md @@ -13,8 +13,8 @@ Conventions: | # | Connection | Request | Expect | | :--- | :--- | :--- | :--- | -| 1 | A | `resources/create {uri: , contents: {uri: , text: "a"}}` | Result. `revision` is a non-empty string; save as ``. `changes` contains `{uri: , effect: "created", revision: }`. | -| 2 | B | `resources/create {uri: , contents: {uri: , text: "b"}}` | Error. Code is precondition failed. `data.uri` = ``, `data.reason` = `"alreadyExists"`, `data.currentRevision` = ``. | +| 1 | A | `resources/create {uri: , contents: {text: "a"}}` | Result. `revision` is a non-empty string; save as ``. `changes` contains `{uri: , effect: "created", revision: }`. | +| 2 | B | `resources/create {uri: , contents: {text: "b"}}` | Error. Code is precondition failed. `data.uri` = ``, `data.reason` = `"alreadyExists"`, `data.currentRevision` = ``. | | 3 | A | `resources/stat {uri: }` | Result. `revision` = ``. The second create changed nothing. | ## RO-CONFLICT-2: lost update @@ -23,13 +23,13 @@ The memory-server case from [use cases](../docs/use-cases.md): two sessions read | # | Connection | Request | Expect | | :--- | :--- | :--- | :--- | -| 1 | A | `resources/create {uri: , contents: {uri: , text: "0"}}` | Result. | +| 1 | A | `resources/create {uri: , contents: {text: "0"}}` | Result. | | 2 | A | `resources/stat {uri: }` | Result. Save `revision` as ``. | | 3 | B | `resources/stat {uri: }` | Result. `revision` = ``. | -| 4 | A | `resources/update {uri: , contents: {uri: , text: "0A"}, ifMatch: }` | Result. Save `revision` as ``; `` != ``. `changes` contains `{uri: , effect: "updated", previousRevision: , revision: }`. | -| 5 | B | `resources/update {uri: , contents: {uri: , text: "0B"}, ifMatch: }` | Error. Code is precondition failed. `data.reason` = `"revisionMismatch"`, `data.currentRevision` = ``. | +| 4 | A | `resources/update {uri: , contents: {text: "0A"}, ifMatch: }` | Result. Save `revision` as ``; `` != ``. `changes` contains `{uri: , effect: "updated", previousRevision: , revision: }`. | +| 5 | B | `resources/update {uri: , contents: {text: "0B"}, ifMatch: }` | Error. Code is precondition failed. `data.reason` = `"revisionMismatch"`, `data.currentRevision` = ``. | | 6 | B | `resources/stat {uri: }` | Result. `revision` = ``. The rejected write changed nothing. | -| 7 | B | `resources/update {uri: , contents: {uri: , text: "0AB"}, ifMatch: }` | Result. `changes` contains `previousRevision: `. | +| 7 | B | `resources/update {uri: , contents: {text: "0AB"}, ifMatch: }` | Result. `changes` contains `previousRevision: `. | | 8 | B | `resources/delete {uri: , ifMatch: }` | Error. Code is precondition failed. `data.reason` = `"revisionMismatch"`. | | 9 | A | `resources/stat {uri: }` | Result. The resource still exists. | diff --git a/docs/decisions.md b/docs/decisions.md index f68c1d9..30aa349 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -31,11 +31,11 @@ Add new entries below this section, oldest first. A pull request that adds an en **Status:** Proposed -**Context:** The [charter](https://modelcontextprotocol.io/community/working-groups/filesystems) asks for create, update, delete, and `stat`, typed errors, and optimistic concurrency control on writes including create-if-absent ([open questions](open-questions.md) 2 to 6). Options considered for concurrency: no precondition (last writer wins), timestamps (`lastModified`), and an opaque server-minted revision compared on write. For listing: a new method, or the `resources/directory/read` method already defined by the Skills extension (SEP-2640). +**Context:** The [charter](https://modelcontextprotocol.io/community/working-groups/filesystems) asks for create, update, delete, and `stat`, typed errors, and optimistic concurrency control on writes including create-if-absent (PS4.1, PS4.3, PS4.5, PS5.1 to PS5.3, PS6.1, and PS6.2 in the [problem statements](problem-statements.md), where PS*n*.*m* is item *m* of Problem Statement *n*; cross-cutting [open questions](open-questions.md) 1 and 3). Options considered for concurrency: no precondition (last writer wins), timestamps (`lastModified`), an opaque server-minted revision compared on write, and that revision optional on update or delete. For listing: a new method, or the `resources/directory/read` method already defined by the Skills extension (SEP-2640). -**Decision:** Use [docs/proposals/resource-operations.md](proposals/resource-operations.md) as the group's starting strawman: an extension `io.modelcontextprotocol/resource-operations` (provisional) with `resources/stat`, `resources/create` (create-if-absent only), `resources/update` (requires `ifMatch`), and `resources/delete` (optional `ifMatch`); an opaque `revision` as the concurrency token, the term [Problem Statement 5](problem-statements.md#problem-statement-5-optimistic-concurrency-control) uses; a precondition-failed error carrying `currentRevision`; and reuse of `resources/directory/read` unchanged for listing. Search, glob, bulk reads, and ranges are deferred. +**Decision:** Use [docs/proposals/resource-operations.md](proposals/resource-operations.md) as the group's starting strawman: an extension `io.modelcontextprotocol/resource-operations` (provisional) with `resources/stat`, `resources/create` (create-if-absent only), `resources/update` and `resources/delete` (both require `ifMatch`); an opaque `revision` as the concurrency token, the term [Problem Statement 5](problem-statements.md#problem-statement-5-optimistic-concurrency-control) uses; a precondition-failed error carrying `currentRevision`; and reuse of `resources/directory/read` unchanged for listing. Search, glob, bulk reads, and ranges are deferred. -**Rationale:** An opaque revision identifier is what HTTP ETags, S3, and GCS already use, survives coarse clocks, and lets each server choose a counter, digest, or backend generation. Required `ifMatch` on update rules out lost updates by omission. Reusing SEP-2640's method avoids two listing methods for the same tree. The change-set vocabulary (entries with `effect`) follows Kryspin Ziemski's working document shared in #filesystem-wg, so later Query and Range work can build on it; that document calls the token `version`, and this draft uses `revision` to match the problem statements. A Python prototype built on the SDK's `Extension`/`MethodBinding` API and conformance scenarios accompany the draft. +**Rationale:** An opaque revision identifier is what HTTP ETags, S3, and GCS already use, survives coarse clocks, and lets each server choose a counter, digest, or backend generation. Required `ifMatch` on update and delete rules out lost updates by omission. Reusing SEP-2640's method avoids two listing methods for the same tree. The change-set vocabulary (entries with `effect`) follows Kryspin Ziemski's working document shared in #filesystem-wg, so later Query and Range work can build on it; that document calls the token `version`, and this draft uses `revision` to match the problem statements. A Python prototype built on the SDK's `Extension`/`MethodBinding` API and conformance scenarios accompany the draft. **References:** - [Proposal](proposals/resource-operations.md), [Python prototype](../python/sdk/), [conformance scenarios](../conformance/) diff --git a/docs/proposals/resource-operations.md b/docs/proposals/resource-operations.md index d378385..2d07e2c 100644 --- a/docs/proposals/resource-operations.md +++ b/docs/proposals/resource-operations.md @@ -9,7 +9,7 @@ Resources can be listed and read, but not created, updated, deleted, or inspected without reading the content ([Problem Statement 4](../problem-statements.md#problem-statement-4-writes-metadata-and-typed-errors)). Servers that need writes expose them as tools with ad hoc names, arguments, and errors, and nothing stops two writers from overwriting each other ([Problem Statement 5](../problem-statements.md#problem-statement-5-optimistic-concurrency-control); [use cases](../use-cases.md): shared state written by several agents, memory servers). -This strawman covers the smallest slice of the charter's scope that fixes the write path: a metadata read (`resources/stat`), `resources/create` with create-if-absent semantics, `resources/update` conditional on a revision, `resources/delete` with an optional revision check, typed errors, and how these interact with existing cache and notification fields. Listing reuses `resources/directory/read` from SEP-2640. Search, glob, bulk reads, and ranges are deferred. +This strawman covers the smallest slice of the charter's scope that fixes the write path: a metadata read (`resources/stat`), `resources/create` with create-if-absent semantics, `resources/update` and `resources/delete` conditional on a revision, typed errors, and how these interact with existing cache and notification fields. Listing reuses `resources/directory/read` from SEP-2640. Search, glob, bulk reads, and ranges are deferred. Vocabulary follows Kryspin Ziemski's working document shared in #filesystem-wg ("MCP Resources as Addressable Domain State"), which proposes Create, Read, Update, Delete, Query, and Range (CRUDQR) over Resources and a change-set entry `{uri, effect, previousVersion?, version?}`. This draft covers C, U, D, plus `stat`, and uses the change-set entry in write results. It calls the concurrency token a `revision`, the term [Problem Statement 5](../problem-statements.md#problem-statement-5-optimistic-concurrency-control) uses, so the entry here is `{uri, effect, previousRevision?, revision?}`. Query and Range are deferred. @@ -43,12 +43,12 @@ A `revision` is the opaque revision identifier that [Problem Statement 5](../pro - Clients compare revisions only for exact string equality. Revisions have no order. - A revision MUST change whenever the content or `mimeType` of the resource changes. A revision MUST NOT be reused for a different state of the same URI, including after a delete and re-create. A digest over the content and `mimeType` is a valid revision. - Revisions are scoped to one server and one URI. -- Returned by `resources/stat`, `resources/create`, and `resources/update`. Core shapes that this extension may not retype carry it in `_meta` under `io.modelcontextprotocol/resource-revision`: entries in a `resources/directory/read` result (`Resource._meta`), and, when the server can provide it, each item of a `resources/read` result (`contents[]._meta`). The `_meta` key name is provisional. +- Returned by `resources/stat`, `resources/create`, and `resources/update`. Core shapes that this extension may not retype carry it in `_meta` under `io.modelcontextprotocol/resource-revision`: file entries in a `resources/directory/read` result (`Resource._meta`; directory entries carry none in this draft, see PS6.3 under Open questions), and, when the server can provide it, each item of a `resources/read` result (`contents[]._meta`). The `_meta` key name is provisional. - A directory resource's revision SHOULD change when its set of direct children changes. ## Methods -Content in `create` and `update` uses the core `TextResourceContents` or `BlobResourceContents` shape, the same one `resources/read` returns. `contents.uri` MUST equal `uri`. Content travels inline; moving large content is the File Uploads Working Group's job. +Content in `create` and `update` uses the core `TextResourceContents` or `BlobResourceContents` shape, the same one `resources/read` returns, without `uri`: the method's `uri` names the target. Content travels inline; moving large content is the File Uploads Working Group's job. ### `resources/stat` @@ -80,13 +80,16 @@ Creates the resource at `uri` only if nothing exists there (create-if-absent). I "method": "resources/create", "params": { "uri": "mem://notes/plan.md", - "contents": { "uri": "mem://notes/plan.md", "mimeType": "text/markdown", "text": "# Plan\n" } + "contents": { "mimeType": "text/markdown", "text": "# Plan\n" } } } ``` ```json { - "resource": { "uri": "mem://notes/plan.md", "name": "plan.md", "mimeType": "text/markdown", "size": 7 }, + "resource": { + "uri": "mem://notes/plan.md", "name": "plan.md", "mimeType": "text/markdown", "size": 7, + "annotations": { "lastModified": "2026-10-09T14:02:11Z" } + }, "revision": "17", "changes": [{ "uri": "mem://notes/plan.md", "effect": "created", "revision": "17" }] } @@ -103,14 +106,17 @@ Replaces the content of an existing resource, only if its current revision equal "method": "resources/update", "params": { "uri": "mem://notes/plan.md", - "contents": { "uri": "mem://notes/plan.md", "mimeType": "text/markdown", "text": "# Plan\n- ship\n" }, + "contents": { "mimeType": "text/markdown", "text": "# Plan\n- ship\n" }, "ifMatch": "17" } } ``` ```json { - "resource": { "uri": "mem://notes/plan.md", "name": "plan.md", "mimeType": "text/markdown", "size": 14 }, + "resource": { + "uri": "mem://notes/plan.md", "name": "plan.md", "mimeType": "text/markdown", "size": 14, + "annotations": { "lastModified": "2026-10-09T14:07:43Z" } + }, "revision": "18", "changes": [{ "uri": "mem://notes/plan.md", "effect": "updated", "previousRevision": "17", "revision": "18" }] } @@ -120,7 +126,7 @@ Update is full replacement. Partial updates (patch, append, range writes) are de ### `resources/delete` -Deletes the resource. With `ifMatch`, only if the current revision matches. Deleting a directory resource is out of scope for this draft (`notSupported`). +Deletes the resource, only if its current revision equals `ifMatch`. `ifMatch` is required, as on update. Deleting a directory resource is out of scope for this draft (`notSupported`). ```json { "method": "resources/delete", "params": { "uri": "mem://notes/plan.md", "ifMatch": "18" } } @@ -143,9 +149,9 @@ The lost-update case from [use cases](../use-cases.md) plays out as follows. Two The Skills extension ([SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640), Final) already defines `resources/directory/read`: request `{uri, cursor?}`, result `{resources: Resource[], nextCursor?}` listing direct children, with subdirectories as `inode/directory` resources and `-32602` for a missing or non-directory URI. SEP-2640 states that the method is not skill-specific and that a server MAY support it on any directory resource under any scheme. -This draft reuses that method unchanged and adds no listing method of its own. The only addition is a way to advertise it without the Skills extension: SEP-2640 gates the method behind `directoryRead` under `io.modelcontextprotocol/skills`, and declaring that extension commits a server to `skills/list` and `skills/get`, which a plain file server cannot honor. So this extension carries its own `directoryRead` setting with the same meaning. A client may call the method if either extension declares `directoryRead: true`. Entries carry their revision in `_meta` (see Revisions), which also gives SEP-2640 hosts the snapshot token that SEP-2640 notes it lacks. +This draft reuses that method unchanged and adds no listing method of its own. The only addition is a way to advertise it without the Skills extension: SEP-2640 gates the method behind `directoryRead` under `io.modelcontextprotocol/skills`, and declaring that extension commits a server to `skills/list` and `skills/get`, which a plain file server cannot honor. So this extension carries its own `directoryRead` setting with the same meaning. A client may call the method if either extension declares `directoryRead: true`. File entries carry their revision in `_meta` (see Revisions), which also gives SEP-2640 hosts the snapshot token that SEP-2640 notes it lacks. Directory entries do not in this draft. -The cleaner long-term shape is probably for one definition to live in one place (this extension, or core) with Skills referencing it. That is for the Skills Over MCP WG and this WG to agree on ([open question 6](../open-questions.md)). +The cleaner long-term shape is probably for one definition to live in one place (this extension, or core) with Skills referencing it. That is for the Skills Over MCP WG and this WG to agree on ([open question 3](../open-questions.md), PS1.2). ### Deferred @@ -159,7 +165,7 @@ The cleaner long-term shape is probably for one definition to live in one place | Condition | Code | `data.reason` | Other `data` | | :--- | :--- | :--- | :--- | | Resource does not exist | `-32602` Invalid params | `notFound` | `uri` | -| URI malformed, `contents.uri` mismatch, or not a directory (`resources/directory/read`) | `-32602` Invalid params | `invalidUri` | `uri` | +| URI malformed, or not a directory (`resources/directory/read`) | `-32602` Invalid params | `invalidUri` | `uri` | | `ifMatch` does not match | `-32010` (provisional) | `revisionMismatch` | `uri`, `currentRevision` | | `resources/create` on an existing URI | `-32010` (provisional) | `alreadyExists` | `uri`, `currentRevision` | | Operation not supported for this URI (read-only scheme, directory, ancestor is a file) | `-32011` (provisional) | `notSupported` | `uri` | @@ -175,7 +181,7 @@ The two new codes are placeholders in the implementation-defined band. The 2026- **`lastModified`.** Servers that track modification time MUST set `annotations.lastModified` on the `Resource` returned by `stat` and by writes, and update it on every write. It is informational only and MUST NOT be used as a precondition: timestamps have coarse resolution and clocks skew, so two writes can share one. -**`ttlMs`.** `resources/stat` results carry `ttlMs` as a freshness hint for metadata, as SEP-2549 defines for other results. A cached revision is only ever a guess: the conditional write is the check, and a stale revision fails safely with `revisionMismatch`. A client SHOULD discard cached reads and stats of a URI after its own successful write to it or an updated notification for it. Write results are not cacheable. +**`ttlMs`.** `resources/stat` results carry `ttlMs` as a freshness hint for metadata, as SEP-2549 defines for other results. A cached revision is only ever a guess: the conditional write is the check, and a stale revision fails safely with `revisionMismatch`. A client SHOULD discard cached reads and stats of a URI after its own successful write to it or an updated notification for it. Write results carry no `ttlMs` or `cacheScope`; see PS6.4 under Open questions. **`cacheScope`.** `resources/stat` defaults to `"private"`. A revision reveals that and when a resource changed, so a server whose resources differ per user MUST NOT mark `stat` results `"public"`. Open: whether `"public"` should be allowed at all for `stat`. @@ -191,16 +197,19 @@ The Python prototype is built only on the MCP Python SDK's public `Extension` an ## Open questions -Numbers refer to [open-questions.md](../open-questions.md); "new" marks questions this draft raises. - -- **#1** Files as Resource URIs: not addressed (see below). This draft assumes `resources/*` and would be renamed if the group chooses a `files/*` family. -- **#2** Concurrency: this draft proposes an opaque `revision`, required `ifMatch` on update, optional on delete, and create-if-absent as the only create. Alternatives: optional `ifMatch` on update (unconditional overwrite allowed); HTTP-style `ifNoneMatch: "*"` on a single write method instead of separate create and update. -- **#3** Interactions: proposed above. Open: change-set entry in notification `_meta`; `cacheScope: "public"` for `stat`; whether `resources/read` SHOULD or MUST carry the revision in `_meta`, and the key name. -- **#4** Typed errors: proposed above. Open: where extension error codes are allocated; whether `notFound` deserves its own code despite core's `-32602`. -- **#5** One SEP or several: this draft suggests one SEP for `stat` and writes, with listing settled jointly with Skills, and Query and Range in a later SEP. -- **#6** Skills directory read: reuse as-is with a second `directoryRead` flag. Open: whether the definition moves here or to core. -- **New** Server-assigned URIs (SEP-2571 style, create in a collection) alongside client-chosen URIs. -- **New** Whether a server can declare `stat` without writes (a read-only setting) instead of answering `notSupported` per URI. +PS*n*.*m* is item *m* under "A proposal must" in Problem Statement *n* of [problem-statements.md](../problem-statements.md); "open question *n*" refers to [open-questions.md](../open-questions.md); "new" marks questions this draft raises. + +- **Open question 2, PS4.6** Where an upload flow ends and a resource write begins: not addressed (see below). This draft assumes `resources/*` and would be renamed if the group chooses a `files/*` family. +- **PS5.2, PS5.4** Concurrency: this draft proposes an opaque `revision`, required `ifMatch` on update and delete, and create-if-absent as the only create, so a server that supports writes always enforces preconditions. Alternatives: optional `ifMatch` on update or delete (unconditional overwrite or delete allowed); HTTP-style `ifNoneMatch: "*"` on a single write method instead of separate create and update. +- **PS5.5** Reads pinned to a revision: not addressed. A client can tell that a resource changed by comparing the revision in `contents[]._meta`, but cannot ask for an older state; most backends keep no history, so the lean is an optional server capability, settled together with partial reads (PS3.4). +- **PS6.1, PS6.2, PS6.5, PS5.1** Interactions: proposed above. Open: change-set entry in notification `_meta`; `cacheScope: "public"` for `stat`; whether `resources/read` SHOULD or MUST carry the revision in `_meta`, and the key name. +- **PS6.3** Subscribing to a directory: not addressed. Subscriptions stay per URI, and a client watching a directory relies on `list_changed` or re-reads it. Directory entries in a listing carry no revision for now, because a directory revision is most useful alongside a directory subscription; both are settled together. +- **PS6.4** `ttlMs` and `cacheScope` on write results: this draft returns the new revision and leaves both out. A write result describes the state right after this write, and another writer can change it at any moment, so the cacheable thing is a fresh `stat` or read, which carry both fields. The new revision is what lets a client seed its cache safely: if it is stale, the next conditional write fails with `revisionMismatch`. +- **PS4.4** Marking a URI or subtree read-only, writable, or append-only: today a server answers `notSupported` per URI. Open: whether a server declares this up front (on `stat` results, listing entries, or a server-wide read-only setting that serves `stat` without writes). Append-only needs an append operation, deferred with partial updates. +- **PS4.5** Typed errors: proposed above. Open: permission denied and quota exceeded, not defined yet (the lean is two more `data.reason` values); where extension error codes are allocated; whether `notFound` deserves its own code despite core's `-32602`. +- **Open question 1** SEP-2571, SEP-2532, and one SEP or several: this draft folds in SEP-2571's create and delete and suggests one SEP for `stat` and writes, with listing settled jointly with Skills, and Query and Range (which overlaps SEP-2532) in a later SEP. +- **Open question 3, PS1.2** Skills directory read: reuse as-is with a second `directoryRead` flag. Open: whether the definition moves here or to core. +- **PS4.2** Server-assigned URIs (SEP-2571 style, create in a collection) alongside client-chosen URIs. - **New** The extension identifier and `_meta` key names, and whether experimental builds should use a different prefix. - **New** Whether `revision` and `changes` both belong in write results. @@ -208,5 +217,5 @@ Numbers refer to [open-questions.md](../open-questions.md); "new" marks question - **Content transfer.** Content travels inline. Out-of-band transfer, digests, size limits, and streaming belong to the File Uploads Working Group; `create` and `update` will need a way to reference transferred content once that exists. - **File inputs.** Marking tool or elicitation inputs as files belongs to the File Uploads Working Group. Nothing here depends on it. -- **Whether files are Resource URIs.** The charter's cross-cutting decision ([open question 1](../open-questions.md)). This draft operates on Resource URIs because that is what exists today; it takes no position on the decision. +- **Whether files are Resource URIs.** The charter's cross-cutting decision ([open question 2](../open-questions.md), PS4.6). This draft operates on Resource URIs because that is what exists today; it takes no position on the decision. - **Authorization.** Who may write what is out of the charter's scope beyond stating that the existing MCP authorization specification applies. diff --git a/python/sdk/README.md b/python/sdk/README.md index 8777cc7..4fd4e31 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -28,16 +28,15 @@ Client: wrap a connected session. Errors arrive as typed exceptions. ```python from mcp import Client -from mcp_types import TextResourceContents -from mcp_ext_filesystems import ResourceOperationsClient, RevisionMismatch +from mcp_ext_filesystems import ResourceOperationsClient, RevisionMismatch, TextContents uri = "mem://notes/plan.md" async with Client(server) as client: ops = ResourceOperationsClient(client.session) - created = await ops.create(uri, TextResourceContents(uri=uri, text="# Plan\n")) + created = await ops.create(uri, TextContents(text="# Plan\n")) try: - await ops.update(uri, TextResourceContents(uri=uri, text="# Plan\n- ship\n"), if_match=created.revision) + await ops.update(uri, TextContents(text="# Plan\n- ship\n"), if_match=created.revision) except RevisionMismatch as conflict: ... # someone else wrote first: re-read, merge, retry with conflict.current_revision ``` diff --git a/python/sdk/src/mcp_ext_filesystems/__init__.py b/python/sdk/src/mcp_ext_filesystems/__init__.py index cfdf4e6..0b295f4 100644 --- a/python/sdk/src/mcp_ext_filesystems/__init__.py +++ b/python/sdk/src/mcp_ext_filesystems/__init__.py @@ -15,6 +15,6 @@ ) from mcp_ext_filesystems.server import ResourceOperations from mcp_ext_filesystems.store import InMemoryStore, ResourceStore, Stat -from mcp_ext_filesystems.wire import EXTENSION_ID, REVISION_META_KEY, ResourceChange +from mcp_ext_filesystems.wire import EXTENSION_ID, REVISION_META_KEY, BlobContents, ResourceChange, TextContents __version__ = version("mcp-ext-filesystems") diff --git a/python/sdk/src/mcp_ext_filesystems/client.py b/python/sdk/src/mcp_ext_filesystems/client.py index f4080c4..e6d579c 100644 --- a/python/sdk/src/mcp_ext_filesystems/client.py +++ b/python/sdk/src/mcp_ext_filesystems/client.py @@ -3,7 +3,7 @@ async with Client(server) as client: ops = ResourceOperationsClient(client.session) stat = await ops.stat("mem://notes/plan.md") - await ops.update("mem://notes/plan.md", TextResourceContents(uri=..., text="..."), if_match=stat.revision) + await ops.update("mem://notes/plan.md", TextContents(text="..."), if_match=stat.revision) Wire errors come back as the typed exceptions in `mcp_ext_filesystems.errors`. """ @@ -28,13 +28,13 @@ ReadDirectoryParams, ReadDirectoryRequest, ReadDirectoryResult, - ResourceContents, StatParams, StatRequest, StatResult, UpdateParams, UpdateRequest, UpdateResult, + WriteContents, ) ResultT = TypeVar("ResultT", bound=Result) @@ -51,14 +51,14 @@ def supported(self) -> bool: async def stat(self, uri: str) -> StatResult: return await self._send(StatRequest(params=StatParams(uri=uri)), StatResult) - async def create(self, uri: str, contents: ResourceContents) -> CreateResult: + async def create(self, uri: str, contents: WriteContents) -> CreateResult: return await self._send(CreateRequest(params=CreateParams(uri=uri, contents=contents)), CreateResult) - async def update(self, uri: str, contents: ResourceContents, *, if_match: str) -> UpdateResult: + async def update(self, uri: str, contents: WriteContents, *, if_match: str) -> UpdateResult: params = UpdateParams(uri=uri, contents=contents, if_match=if_match) return await self._send(UpdateRequest(params=params), UpdateResult) - async def delete(self, uri: str, *, if_match: str | None = None) -> DeleteResult: + async def delete(self, uri: str, *, if_match: str) -> DeleteResult: return await self._send(DeleteRequest(params=DeleteParams(uri=uri, if_match=if_match)), DeleteResult) async def read_directory(self, uri: str) -> list[Resource]: diff --git a/python/sdk/src/mcp_ext_filesystems/server.py b/python/sdk/src/mcp_ext_filesystems/server.py index 34b69da..a2e9e95 100644 --- a/python/sdk/src/mcp_ext_filesystems/server.py +++ b/python/sdk/src/mcp_ext_filesystems/server.py @@ -10,6 +10,7 @@ from __future__ import annotations +import re from collections.abc import Sequence from typing import Any from urllib.parse import urlsplit @@ -86,14 +87,14 @@ async def _stat(self, ctx: ServerRequestContext[Any, Any], params: StatParams) - return StatResult(resource=stat.resource, revision=stat.revision) async def _create(self, ctx: ServerRequestContext[Any, Any], params: CreateParams) -> HandlerResult: - _check_uri(params.uri, params.contents.uri) + _check_uri(params.uri) stat = await self._store.create(params.uri, params.contents) await self._publish(ResourcesListChanged()) change = ResourceChange(uri=params.uri, effect="created", revision=stat.revision) return CreateResult(resource=stat.resource, revision=stat.revision, changes=[change]) async def _update(self, ctx: ServerRequestContext[Any, Any], params: UpdateParams) -> HandlerResult: - _check_uri(params.uri, params.contents.uri) + _check_uri(params.uri) previous, stat = await self._store.update(params.uri, params.contents, params.if_match) await self._publish(ResourceUpdated(uri=params.uri)) change = ResourceChange(uri=params.uri, effect="updated", previous_revision=previous, revision=stat.revision) @@ -108,20 +109,23 @@ async def _delete(self, ctx: ServerRequestContext[Any, Any], params: DeleteParam async def _read_directory(self, ctx: ServerRequestContext[Any, Any], params: ReadDirectoryParams) -> HandlerResult: _check_uri(params.uri) - return ReadDirectoryResult(resources=await self._store.read_directory(params.uri)) + resources, next_cursor = await self._store.read_directory(params.uri, params.cursor) + return ReadDirectoryResult(resources=resources, next_cursor=next_cursor) async def _publish(self, event: ResourceUpdated | ResourcesListChanged) -> None: if self._bus is not None: await self._bus.publish(event) -def _check_uri(uri: str, contents_uri: str | None = None) -> None: +# RFC 3986 absolute URI: scheme ":" hier-part, so `urn:` and other URIs without an authority are accepted. +_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:") + + +def _check_uri(uri: str) -> None: parts = urlsplit(uri) - if not parts.scheme or "://" not in uri: + if not _SCHEME.match(uri): raise InvalidResourceUri(uri, "not an absolute URI") if parts.fragment or uri.endswith("/"): raise InvalidResourceUri(uri, "fragments and trailing slashes are not allowed") if any(segment in (".", "..") for segment in parts.path.split("/")): raise InvalidResourceUri(uri, "dot segments are not allowed") - if contents_uri is not None and contents_uri != uri: - raise InvalidResourceUri(uri, f"contents.uri {contents_uri!r} does not match") diff --git a/python/sdk/src/mcp_ext_filesystems/store.py b/python/sdk/src/mcp_ext_filesystems/store.py index b40681f..82938a5 100644 --- a/python/sdk/src/mcp_ext_filesystems/store.py +++ b/python/sdk/src/mcp_ext_filesystems/store.py @@ -9,7 +9,7 @@ from itertools import count from typing import Protocol -from mcp_types import Annotations, BlobResourceContents, Resource +from mcp_types import Annotations, BlobResourceContents, Resource, TextResourceContents from mcp_ext_filesystems.errors import ( AlreadyExists, @@ -18,7 +18,13 @@ ResourceNotFound, RevisionMismatch, ) -from mcp_ext_filesystems.wire import DIRECTORY_MIME_TYPE, REVISION_META_KEY, ResourceContents +from mcp_ext_filesystems.wire import ( + DIRECTORY_MIME_TYPE, + REVISION_META_KEY, + BlobContents, + ResourceContents, + WriteContents, +) @dataclass(frozen=True) @@ -41,20 +47,20 @@ async def stat(self, uri: str) -> Stat: """Raise `ResourceNotFound` if `uri` does not exist.""" ... - async def create(self, uri: str, contents: ResourceContents) -> Stat: + async def create(self, uri: str, contents: WriteContents) -> Stat: """Create `uri` only if it does not exist; raise `AlreadyExists` with the current revision otherwise.""" ... - async def update(self, uri: str, contents: ResourceContents, if_match: str) -> tuple[str, Stat]: + async def update(self, uri: str, contents: WriteContents, if_match: str) -> tuple[str, Stat]: """Replace `uri` only if its revision equals `if_match`. Return the previous revision and the new stat.""" ... - async def delete(self, uri: str, if_match: str | None) -> str: - """Delete `uri`, conditionally if `if_match` is set. Return the deleted revision.""" + async def delete(self, uri: str, if_match: str) -> str: + """Delete `uri` only if its revision equals `if_match`. Return the deleted revision.""" ... - async def read_directory(self, uri: str) -> list[Resource]: - """Return the direct children of directory `uri`.""" + async def read_directory(self, uri: str, cursor: str | None) -> tuple[list[Resource], str | None]: + """Return one page of the direct children of directory `uri` and the cursor for the next page, if any.""" ... @@ -72,8 +78,9 @@ class InMemoryStore: revision. Writes contain no `await`, which makes each compare-and-write atomic on a single event loop. """ - def __init__(self, scheme: str = "mem") -> None: + def __init__(self, scheme: str = "mem", page_size: int = 100) -> None: self._scheme = scheme + self._page_size = page_size self._entries: dict[str, _Entry] = {} self._revisions = count(1) @@ -91,36 +98,40 @@ def _stat(self, uri: str) -> Stat: digest = hashlib.sha256(repr(sorted((c.uri, (c.meta or {}).get(REVISION_META_KEY)) for c in children)).encode()) return Stat(_directory(uri), "d-" + digest.hexdigest()[:16]) - async def create(self, uri: str, contents: ResourceContents) -> Stat: + async def create(self, uri: str, contents: WriteContents) -> Stat: self._check_writable(uri) if uri in self._entries or self._children(uri): raise AlreadyExists(uri, self._stat(uri).revision) entry = self._write(uri, contents) return Stat(_describe(uri, entry), entry.revision) - async def update(self, uri: str, contents: ResourceContents, if_match: str) -> tuple[str, Stat]: + async def update(self, uri: str, contents: WriteContents, if_match: str) -> tuple[str, Stat]: current = self._file(uri) if current.revision != if_match: raise RevisionMismatch(uri, current.revision) entry = self._write(uri, contents) return current.revision, Stat(_describe(uri, entry), entry.revision) - async def delete(self, uri: str, if_match: str | None) -> str: + async def delete(self, uri: str, if_match: str) -> str: current = self._file(uri) - if if_match is not None and current.revision != if_match: + if current.revision != if_match: raise RevisionMismatch(uri, current.revision) del self._entries[uri] return current.revision - async def read_directory(self, uri: str) -> list[Resource]: + async def read_directory(self, uri: str, cursor: str | None) -> tuple[list[Resource], str | None]: if uri in self._entries: raise InvalidResourceUri(uri, "not a directory resource") children = self._children(uri) if not children: raise ResourceNotFound(uri) - return children + # The cursor is the last URI of the previous page, so pages stay in order across concurrent writes. + remaining = [c for c in children if cursor is None or c.uri > cursor] + page = remaining[: self._page_size] + return page, page[-1].uri if len(remaining) > len(page) else None def _file(self, uri: str) -> _Entry: + self._check_scheme(uri) entry = self._entries.get(uri) if entry is None: if self._children(uri): @@ -128,17 +139,29 @@ def _file(self, uri: str) -> _Entry: raise ResourceNotFound(uri) return entry + def _check_scheme(self, uri: str) -> None: + if not uri.startswith(f"{self._scheme}://"): + raise OperationNotSupported(uri, f"this store only holds {self._scheme}:// resources") + def _check_writable(self, uri: str) -> None: + self._check_scheme(uri) root = f"{self._scheme}://" - if not uri.startswith(root): - raise OperationNotSupported(uri, f"this store only holds {root} resources") segments = uri.removeprefix(root).split("/") if any(root + "/".join(segments[:i]) in self._entries for i in range(1, len(segments))): raise OperationNotSupported(uri, "an ancestor is a file, not a directory") - def _write(self, uri: str, contents: ResourceContents) -> _Entry: + def _write(self, uri: str, contents: WriteContents) -> _Entry: stamp = datetime.now(timezone.utc).isoformat() - entry = _Entry(contents.model_copy(update={"uri": uri}), str(next(self._revisions)), stamp) + stored: ResourceContents + if isinstance(contents, BlobContents): + stored = BlobResourceContents( + uri=uri, mime_type=contents.mime_type, blob=contents.blob, _meta=contents.meta + ) + else: + stored = TextResourceContents( + uri=uri, mime_type=contents.mime_type, text=contents.text, _meta=contents.meta + ) + entry = _Entry(stored, str(next(self._revisions)), stamp) self._entries[uri] = entry return entry @@ -154,7 +177,7 @@ def _children(self, uri: str) -> list[Resource]: directories.add(prefix + head) else: files[key] = _describe(key, entry) - return [*files.values(), *(_directory(d) for d in sorted(directories))] + return sorted([*files.values(), *(_directory(d) for d in directories)], key=lambda r: r.uri) def _describe(uri: str, entry: _Entry) -> Resource: diff --git a/python/sdk/src/mcp_ext_filesystems/wire.py b/python/sdk/src/mcp_ext_filesystems/wire.py index ec2c4f4..7fc7c7b 100644 --- a/python/sdk/src/mcp_ext_filesystems/wire.py +++ b/python/sdk/src/mcp_ext_filesystems/wire.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal from mcp_types import ( BlobResourceContents, @@ -15,7 +15,7 @@ Result, TextResourceContents, ) -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel EXTENSION_ID = "io.modelcontextprotocol/resource-operations" @@ -42,6 +42,25 @@ class _WireModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) +class TextContents(_WireModel): + """Write payload: core `TextResourceContents` without `uri`. The method's `uri` names the target.""" + + mime_type: str | None = None + text: str + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + + +class BlobContents(_WireModel): + """Write payload: core `BlobResourceContents` without `uri`. The method's `uri` names the target.""" + + mime_type: str | None = None + blob: str + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + + +WriteContents = TextContents | BlobContents + + class ResourceChange(_WireModel): """One entry of a change set, in the vocabulary of Kryspin Ziemski's working document.""" @@ -62,7 +81,7 @@ class StatResult(CacheableResult): class CreateParams(RequestParams): uri: str - contents: ResourceContents + contents: WriteContents class CreateResult(Result): @@ -73,7 +92,7 @@ class CreateResult(Result): class UpdateParams(RequestParams): uri: str - contents: ResourceContents + contents: WriteContents if_match: str @@ -85,7 +104,7 @@ class UpdateResult(Result): class DeleteParams(RequestParams): uri: str - if_match: str | None = None + if_match: str class DeleteResult(Result): diff --git a/python/sdk/tests/test_resource_operations.py b/python/sdk/tests/test_resource_operations.py index bc7e23c..fa026a0 100644 --- a/python/sdk/tests/test_resource_operations.py +++ b/python/sdk/tests/test_resource_operations.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Literal + import anyio import pytest from mcp.client.client import Client @@ -9,7 +11,7 @@ from mcp.server.subscriptions import InMemorySubscriptionBus from mcp.shared.exceptions import MCPError from mcp.shared.subscriptions import ResourceUpdated -from mcp_types import METHOD_NOT_FOUND, TextResourceContents +from mcp_types import INVALID_PARAMS, METHOD_NOT_FOUND, Request, RequestParams from mcp_ext_filesystems import ( EXTENSION_ID, @@ -24,21 +26,31 @@ ResourceOperations, ResourceOperationsClient, RevisionMismatch, + TextContents, +) +from mcp_ext_filesystems.wire import ( + DIRECTORY_MIME_TYPE, + DeleteResult, + ReadDirectoryParams, + ReadDirectoryRequest, + ReadDirectoryResult, + StatParams, + StatRequest, + StatResult, ) -from mcp_ext_filesystems.wire import DIRECTORY_MIME_TYPE, StatParams, StatRequest, StatResult pytestmark = pytest.mark.anyio URI = "mem://notes/plan.md" -def text(body: str, uri: str = URI) -> TextResourceContents: - return TextResourceContents(uri=uri, text=body, mime_type="text/markdown") +def text(body: str) -> TextContents: + return TextContents(text=body, mime_type="text/markdown") -def make_server(*, directory_read: bool = False) -> MCPServer: +def make_server(*, directory_read: bool = False, store: InMemoryStore | None = None) -> MCPServer: bus = InMemorySubscriptionBus() - extension = ResourceOperations(InMemoryStore(), subscriptions=bus, directory_read=directory_read) + extension = ResourceOperations(store or InMemoryStore(), subscriptions=bus, directory_read=directory_read) return MCPServer("notes", subscriptions=bus, extensions=[extension]) @@ -119,7 +131,7 @@ async def test_delete_with_stale_if_match_keeps_the_resource() -> None: await ops.delete(URI, if_match=v1) assert caught.value.current_revision == v2 assert (await ops.stat(URI)).revision == v2 - await ops.delete(URI) + await ops.delete(URI, if_match=v2) with pytest.raises(ResourceNotFound): await ops.stat(URI) @@ -127,7 +139,7 @@ async def test_delete_with_stale_if_match_keeps_the_resource() -> None: async def test_not_found_uses_invalid_params_like_resources_read() -> None: async with Client(make_server()) as client: ops = ResourceOperationsClient(client.session) - for call in (ops.stat(URI), ops.update(URI, text("x"), if_match="1"), ops.delete(URI)): + for call in (ops.stat(URI), ops.update(URI, text("x"), if_match="1"), ops.delete(URI, if_match="1")): with pytest.raises(ResourceNotFound) as caught: await call assert caught.value.code == -32602 @@ -139,11 +151,54 @@ async def test_invalid_uri_and_unsupported_scheme() -> None: ops = ResourceOperationsClient(client.session) with pytest.raises(InvalidResourceUri): await ops.stat("plan.md") - with pytest.raises(InvalidResourceUri): - await ops.create(URI, text("x", uri="mem://notes/other.md")) - with pytest.raises(OperationNotSupported) as caught: - await ops.create("file:///etc/hosts", text("x", uri="file:///etc/hosts")) - assert caught.value.code == NOT_SUPPORTED + for call in ( + ops.create("file:///etc/hosts", text("x")), + ops.update("file:///etc/hosts", text("x"), if_match="1"), + ops.delete("file:///etc/hosts", if_match="1"), + ): + with pytest.raises(OperationNotSupported) as caught: + await call + assert caught.value.code == NOT_SUPPORTED + + +async def test_absolute_uris_without_an_authority_are_valid() -> None: + """RFC 3986 allows `scheme:path`; a foreign scheme is unsupported by this store, not malformed.""" + async with Client(make_server()) as client: + with pytest.raises(OperationNotSupported): + await ResourceOperationsClient(client.session).create("urn:example:plan", text("x")) + + +class _UriOnly(RequestParams): + uri: str + + +class _UriAndContents(RequestParams): + uri: str + contents: TextContents + + +class _UpdateWithoutIfMatch(Request[_UriAndContents, Literal["resources/update"]]): + method: Literal["resources/update"] = "resources/update" + params: _UriAndContents + + +class _DeleteWithoutIfMatch(Request[_UriOnly, Literal["resources/delete"]]): + method: Literal["resources/delete"] = "resources/delete" + params: _UriOnly + + +async def test_update_and_delete_require_if_match() -> None: + async with Client(make_server()) as client: + await ResourceOperationsClient(client.session).create(URI, text("one")) + requests = ( + _UpdateWithoutIfMatch(params=_UriAndContents(uri=URI, contents=text("two"))), + _DeleteWithoutIfMatch(params=_UriOnly(uri=URI)), + ) + for request in requests: + with pytest.raises(MCPError) as caught: + await client.session.send_request(request, DeleteResult) + assert caught.value.code == INVALID_PARAMS + assert (await ResourceOperationsClient(client.session).stat(URI)).resource.size == 3 async def test_update_and_delete_notify_resource_subscribers() -> None: @@ -152,10 +207,10 @@ async def test_update_and_delete_notify_resource_subscribers() -> None: ops = ResourceOperationsClient(writer.session) revision = (await ops.create(URI, text("one"))).revision async with watcher.listen(resource_subscriptions=[URI]) as subscription: - await ops.update(URI, text("two"), if_match=revision) + revision = (await ops.update(URI, text("two"), if_match=revision)).revision with anyio.fail_after(2): assert await subscription.__anext__() == ResourceUpdated(uri=URI) - await ops.delete(URI) + await ops.delete(URI, if_match=revision) with anyio.fail_after(2): assert await subscription.__anext__() == ResourceUpdated(uri=URI) @@ -185,7 +240,7 @@ async def test_directory_read_reuses_the_skills_method_shape() -> None: async with Client(make_server(directory_read=True)) as client: ops = ResourceOperationsClient(client.session) created = await ops.create(URI, text("plan")) - await ops.create("mem://notes/archive/old.md", text("old", uri="mem://notes/archive/old.md")) + await ops.create("mem://notes/archive/old.md", text("old")) children = {r.uri: r for r in await ops.read_directory("mem://notes")} assert set(children) == {URI, "mem://notes/archive"} @@ -195,3 +250,22 @@ async def test_directory_read_reuses_the_skills_method_shape() -> None: with pytest.raises(InvalidResourceUri): await ops.read_directory(URI) + + +async def test_directory_read_pages_with_a_cursor() -> None: + async with Client(make_server(directory_read=True, store=InMemoryStore(page_size=2))) as client: + ops = ResourceOperationsClient(client.session) + uris = [f"mem://notes/{name}.md" for name in "abc"] + for uri in uris: + await ops.create(uri, text(uri)) + + request = ReadDirectoryRequest(params=ReadDirectoryParams(uri="mem://notes")) + first = await client.session.send_request(request, ReadDirectoryResult) + assert [r.uri for r in first.resources] == uris[:2] + assert first.next_cursor is not None + request = ReadDirectoryRequest(params=ReadDirectoryParams(uri="mem://notes", cursor=first.next_cursor)) + second = await client.session.send_request(request, ReadDirectoryResult) + assert [r.uri for r in second.resources] == uris[2:] + assert second.next_cursor is None + + assert [r.uri for r in await ops.read_directory("mem://notes")] == uris