diff --git a/README.md b/README.md index 286692f..3bd466a 100644 --- a/README.md +++ b/README.md @@ -15,6 +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 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 | @@ -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's success criteria call for reference implementations in at least two Tier 1 SDKs. 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..fe97b6a --- /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 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. + +## RO-CONFLICT-1: create-if-absent + +| # | Connection | Request | Expect | +| :--- | :--- | :--- | :--- | +| 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 + +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: {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: {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: {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 db22e0e..30aa349 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,7 +4,7 @@ This document records significant decisions made by the Filesystems Working Grou 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 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 (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` 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 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/) +- [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..2d07e2c --- /dev/null +++ b/docs/proposals/resource-operations.md @@ -0,0 +1,221 @@ +# Resource operations: strawman + +**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-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 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` 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. + +## 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. + +## Revisions + +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 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`: 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, 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` + +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" } } +``` +```json +{ + "resource": { + "uri": "mem://notes/plan.md", "name": "plan.md", "mimeType": "text/markdown", "size": 7, + "annotations": { "lastModified": "2026-10-09T14:02:11Z" } + }, + "revision": "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 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 +{ + "method": "resources/create", + "params": { + "uri": "mem://notes/plan.md", + "contents": { "mimeType": "text/markdown", "text": "# Plan\n" } + } +} +``` +```json +{ + "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" }] +} +``` + +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 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 +{ + "method": "resources/update", + "params": { + "uri": "mem://notes/plan.md", + "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, + "annotations": { "lastModified": "2026-10-09T14:07:43Z" } + }, + "revision": "18", + "changes": [{ "uri": "mem://notes/plan.md", "effect": "updated", "previousRevision": "17", "revision": "18" }] +} +``` + +Update is full replacement. Partial updates (patch, append, range writes) are deferred. + +### `resources/delete` + +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" } } +``` +```json +{ "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. `revision` duplicates the target entry's `revision` for convenience; whether to keep both is open. + +### Concurrency + +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 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`. 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 3](../open-questions.md), PS1.2). + +### 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 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 + +| Condition | Code | `data.reason` | Other `data` | +| :--- | :--- | :--- | :--- | +| Resource does not exist | `-32602` Invalid params | `notFound` | `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` | +| 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. 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 `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 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`. + +## 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 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. + +## Open questions + +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. + +## Not addressed + +- **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 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 d5cd338..4fd4e31 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -2,7 +2,46 @@ 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_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, TextContents(text="# Plan\n")) + try: + 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 +``` + +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..0b295f4 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, + 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, 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 new file mode 100644 index 0000000..e6d579c --- /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", TextContents(text="..."), if_match=stat.revision) + +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, + StatParams, + StatRequest, + StatResult, + UpdateParams, + UpdateRequest, + UpdateResult, + WriteContents, +) + +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: WriteContents) -> CreateResult: + return await self._send(CreateRequest(params=CreateParams(uri=uri, contents=contents)), CreateResult) + + 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) -> 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..e5bc32b --- /dev/null +++ b/python/sdk/src/mcp_ext_filesystems/errors.py @@ -0,0 +1,95 @@ +"""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.currentRevision` 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_revision: str | None = None) -> None: + data: dict[str, Any] = {"uri": uri, "reason": self.reason} + 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_revision = current_revision + + +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 RevisionMismatch(ResourceOperationError): + code_value = PRECONDITION_FAILED + reason = "revisionMismatch" + + 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_revision: str) -> None: + super().__init__(uri, f"Resource already exists: {uri}", current_revision=current_revision) + + +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("currentRevision") + 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 == "revisionMismatch": + return RevisionMismatch(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..a2e9e95 --- /dev/null +++ b/python/sdk/src/mcp_ext_filesystems/server.py @@ -0,0 +1,131 @@ +"""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 + +import re +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, revision=stat.revision) + + async def _create(self, ctx: ServerRequestContext[Any, Any], params: CreateParams) -> HandlerResult: + _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) + 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) + 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_revision=previous)]) + + async def _read_directory(self, ctx: ServerRequestContext[Any, Any], params: ReadDirectoryParams) -> HandlerResult: + _check_uri(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) + + +# 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 _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") 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..82938a5 --- /dev/null +++ b/python/sdk/src/mcp_ext_filesystems/store.py @@ -0,0 +1,200 @@ +"""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, TextResourceContents + +from mcp_ext_filesystems.errors import ( + AlreadyExists, + InvalidResourceUri, + OperationNotSupported, + ResourceNotFound, + RevisionMismatch, +) +from mcp_ext_filesystems.wire import ( + DIRECTORY_MIME_TYPE, + REVISION_META_KEY, + BlobContents, + ResourceContents, + WriteContents, +) + + +@dataclass(frozen=True) +class Stat: + """A resource's metadata and its current revision.""" + + resource: Resource + revision: str + + +class ResourceStore(Protocol): + """Backend for the extension. + + 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`. + """ + + async def stat(self, uri: str) -> Stat: + """Raise `ResourceNotFound` if `uri` does not exist.""" + ... + + 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: 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) -> str: + """Delete `uri` only if its revision equals `if_match`. Return the deleted revision.""" + ... + + 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.""" + ... + + +@dataclass(frozen=True) +class _Entry: + contents: ResourceContents + 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. + + 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", page_size: int = 100) -> None: + self._scheme = scheme + self._page_size = page_size + self._entries: dict[str, _Entry] = {} + self._revisions = 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.revision) + children = self._children(uri) + if not children: + raise ResourceNotFound(uri) + # 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: 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: 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) -> str: + current = self._file(uri) + if current.revision != if_match: + raise RevisionMismatch(uri, current.revision) + del self._entries[uri] + return current.revision + + 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) + # 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): + raise OperationNotSupported(uri, "directories cannot be updated or deleted") + 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}://" + 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: WriteContents) -> _Entry: + stamp = datetime.now(timezone.utc).isoformat() + 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 + + 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 sorted([*files.values(), *(_directory(d) for d in directories)], key=lambda r: r.uri) + + +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={REVISION_META_KEY: entry.revision}, + ) + + +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..7fc7c7b --- /dev/null +++ b/python/sdk/src/mcp_ext_filesystems/wire.py @@ -0,0 +1,144 @@ +"""Wire types for the Resource operations strawman (docs/proposals/resource-operations.md).""" + +from __future__ import annotations + +from typing import Any, Literal + +from mcp_types import ( + BlobResourceContents, + CacheableResult, + PaginatedRequestParams, + PaginatedResult, + Request, + RequestParams, + Resource, + Result, + TextResourceContents, +) +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +EXTENSION_ID = "io.modelcontextprotocol/resource-operations" +"""Provisional identifier, advertised under `capabilities.extensions` (SEP-2133).""" + +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.""" + +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 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.""" + + uri: str + effect: Effect + previous_revision: str | None = None + revision: str | None = None + + +class StatParams(RequestParams): + uri: str + + +class StatResult(CacheableResult): + resource: Resource + revision: str + + +class CreateParams(RequestParams): + uri: str + contents: WriteContents + + +class CreateResult(Result): + resource: Resource + revision: str + changes: list[ResourceChange] + + +class UpdateParams(RequestParams): + uri: str + contents: WriteContents + if_match: str + + +class UpdateResult(Result): + resource: Resource + revision: str + changes: list[ResourceChange] + + +class DeleteParams(RequestParams): + uri: str + if_match: str + + +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 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..fa026a0 --- /dev/null +++ b/python/sdk/tests/test_resource_operations.py @@ -0,0 +1,271 @@ +"""End-to-end tests over the SDK's in-process transport (`Client(server)`).""" + +from __future__ import annotations + +from typing import Literal + +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 INVALID_PARAMS, METHOD_NOT_FOUND, Request, RequestParams + +from mcp_ext_filesystems import ( + EXTENSION_ID, + NOT_SUPPORTED, + PRECONDITION_FAILED, + REVISION_META_KEY, + AlreadyExists, + InMemoryStore, + InvalidResourceUri, + OperationNotSupported, + ResourceNotFound, + ResourceOperations, + ResourceOperationsClient, + RevisionMismatch, + TextContents, +) +from mcp_ext_filesystems.wire import ( + DIRECTORY_MIME_TYPE, + DeleteResult, + ReadDirectoryParams, + ReadDirectoryRequest, + ReadDirectoryResult, + StatParams, + StatRequest, + StatResult, +) + +pytestmark = pytest.mark.anyio + +URI = "mem://notes/plan.md" + + +def text(body: str) -> TextContents: + return TextContents(text=body, mime_type="text/markdown") + + +def make_server(*, directory_read: bool = False, store: InMemoryStore | None = None) -> MCPServer: + bus = InMemorySubscriptionBus() + extension = ResourceOperations(store or 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].revision == created.revision + + stat = await ops.stat(URI) + assert stat.revision == created.revision + assert stat.resource.mime_type == "text/markdown" + + 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_revision, change.revision) == ( + "updated", + stat.revision, + updated.revision, + ) + + deleted = await ops.delete(URI, if_match=updated.revision) + assert deleted.changes[0].effect == "deleted" + 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_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", "currentRevision": first.revision} + + +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"))).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)).revision == v2 + assert caught.value.code == PRECONDITION_FAILED + 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"))).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_revision == v2 + assert (await ops.stat(URI)).revision == v2 + await ops.delete(URI, if_match=v2) + 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, if_match="1")): + 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") + 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: + server = make_server() + async with Client(server) as watcher, Client(server) as writer: + ops = ResourceOperationsClient(writer.session) + revision = (await ops.create(URI, text("one"))).revision + async with watcher.listen(resource_subscriptions=[URI]) as subscription: + 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, if_match=revision) + 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 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) + 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.revision == seen_by_b.revision + + 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 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: + 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")) + + children = {r.uri: r for r in await ops.read_directory("mem://notes")} + assert set(children) == {URI, "mem://notes/archive"} + 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 + + 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