Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
- [ ] Safety scope / side effects / confirmation — [#87](https://github.com/askmy-stack/tool-semantics/issues/87) **P1**
- [ ] Expanded probe kinds — [#88](https://github.com/askmy-stack/tool-semantics/issues/88) **P2**
- [ ] Output-schema compatibility — [#89](https://github.com/askmy-stack/tool-semantics/issues/89) **P1**
- [ ] Prompt / resource / extension diffs — [#90](https://github.com/askmy-stack/tool-semantics/issues/90), [#91](https://github.com/askmy-stack/tool-semantics/issues/91) **P1/P2**
- [x] Prompt / resource / extension diffs — [#90](https://github.com/askmy-stack/tool-semantics/issues/90), [#91](https://github.com/askmy-stack/tool-semantics/issues/91) **P1/P2**
- [ ] Integrity monitoring — [#113](https://github.com/askmy-stack/tool-semantics/issues/113) **P2**
- [ ] Efficiency regression — [#114](https://github.com/askmy-stack/tool-semantics/issues/114) **P2**

Expand Down
36 changes: 36 additions & 0 deletions docs/adr-mcp-tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# ADR: MCP long-running task testing (stub)

**Status:** Proposed / deferred
**Related:** [#91](https://github.com/askmy-stack/tool-semantics/issues/91)

## Context

Some MCP extensions expose long-running **task** operations (start, status,
complete, cancel). Behavioral compatibility for tasks differs from synchronous
`tools/call`: duration, cancellation, and intermediate status matter.

## Decision (first PR)

- **Do not** implement full task runtime orchestration yet.
- Capture/diff only the **advertisement** that task-related extensions exist
(via `metadata.extensions` from `initialize`).
- Reserve follow-on work for a fixture harness that records:

| Phase | Intent |
| --- | --- |
| `start` | Task accepted; returns task id |
| `status` | Poll / stream progress without mutating success criteria |
| `complete` | Terminal success / failure payload |
| `cancel` | Cooperative cancellation observed |

## Consequences

- Extension removal for task-capable servers already surfaces as
`extension.removed` (#91).
- A future issue can add `task.*` probe kinds and fake task servers without
blocking extension diffs.

## Non-goals (this ADR)

- Live scheduling / worker pools
- Automatic execution of discovered task tools during compare
3 changes: 3 additions & 0 deletions docs/change-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ Severities **`breaking`** and **`critical`** fail CI (`compare` exits `1`).
| `parameter.schema_changed` | breaking | Parameter JSON Schema changed (excluding `default`; non-enum or unstructured) |
| `parameter.enum_values_removed` | breaking | One or more enum values were removed |
| `parameter.enum_values_added` | info | One or more enum values were added |
| `extension.removed` | breaking | MCP extension advertised at initialize was removed (#91) |
| `extension.added` | info | New MCP extension advertised |
| `extension.version_changed` | breaking / warning | Extension version string changed |

## Notes for contributors

Expand Down
67 changes: 67 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# MCP extensions (#91)

Capture and diff **extensions** / experimental capabilities advertised at MCP
`initialize`. Support is **best-effort** across protocol generations — unknown
shapes are ignored rather than invented.

## Snapshot field

Live captures store a normalized map under:

```json
{
"metadata": {
"extensions": {
"io.modelcontextprotocol/sampling": {
"name": "io.modelcontextprotocol/sampling",
"version": "1.0.0",
"source": "capabilities.extensions",
"details": {}
}
}
}
}
```

### Sources (in order of ingest)

| Source | Notes |
| --- | --- |
| `initialize.extensions` | Top-level object or list when present |
| `capabilities.extensions` | Nested under server capabilities |
| `capabilities.experimental` | Treated as experimental extension stubs |

Manifest-only snapshots typically omit `extensions`; compare skips the layer
when **both** sides lack it (no false noise).

## Diff codes

| Code | Severity | Meaning |
| --- | --- | --- |
| `extension.removed` | breaking | Extension present in baseline, absent in candidate |
| `extension.added` | info | New extension advertised |
| `extension.version_changed` | breaking / warning | Version string changed (breaking when both sides pinned) |

## Partial / unknown support

- Older protocol generations may not advertise extensions at all — absence is
not treated as “empty set” unless the other side has extensions.
- Unrecognized value shapes are stored with best-effort `details` rather than
failing capture.
- Clients should not assume every MCP server implements the same extension
vocabulary.

## Library

```python
from tool_semantics.extensions import extract_extensions_from_initialize
from tool_semantics.diff import compare_snapshots

exts = extract_extensions_from_initialize(initialize_result)
report = compare_snapshots(baseline_snap, candidate_snap)
```

## Task-capability stub (follow-on)

Long-running MCP **tasks** (start / status / complete / cancel) are **out of
scope** for this PR. See [adr-mcp-tasks.md](adr-mcp-tasks.md) for the stub ADR.
3 changes: 2 additions & 1 deletion docs/mcp-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ assuming a single fixed version.
| `2025-11-25` | Streamable HTTP | Accepted when returned by `initialize` |

Negotiated `protocolVersion` is stored in snapshot metadata as
`protocol_version`, along with `transport` and `server_capabilities`. Auth
`protocol_version`, along with `transport`, `server_capabilities`, and
best-effort `extensions` (see [extensions.md](extensions.md)). Auth
header **values** are never persisted.

## Unsupported
Expand Down
51 changes: 51 additions & 0 deletions src/tool_semantics/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from pydantic import BaseModel, Field

from tool_semantics.extensions import extensions_from_snapshot_metadata
from tool_semantics.models import InterfaceSnapshot, ToolContract, ToolParameter


Expand Down Expand Up @@ -383,6 +384,55 @@ def _compare_tool_pair(
)


def _compare_extensions(
report: CompatibilityReport,
baseline: InterfaceSnapshot,
candidate: InterfaceSnapshot,
) -> None:
"""Diff MCP extensions advertised at initialize (#91)."""
base = extensions_from_snapshot_metadata(baseline.metadata)
cand = extensions_from_snapshot_metadata(candidate.metadata)
# Skip when neither side recorded extensions (manifests / older captures).
if not base and not cand:
return
for name in sorted(base.keys() - cand.keys()):
report.changes.append(
Change(
severity=Severity.BREAKING,
code="extension.removed",
subject=name,
message=(
f"MCP extension '{name}' was removed (was advertised via {base[name].source})."
),
)
)
for name in sorted(cand.keys() - base.keys()):
report.changes.append(
Change(
severity=Severity.INFO,
code="extension.added",
subject=name,
message=(f"MCP extension '{name}' was added (advertised via {cand[name].source})."),
)
)
for name in sorted(base.keys() & cand.keys()):
left = base[name]
right = cand[name]
if left.version != right.version:
severity = Severity.BREAKING if left.version and right.version else Severity.WARNING
report.changes.append(
Change(
severity=severity,
code="extension.version_changed",
subject=name,
message=(
f"MCP extension '{name}' version changed from "
f"{left.version!r} to {right.version!r}."
),
)
)


def compare_snapshots(
baseline: InterfaceSnapshot,
candidate: InterfaceSnapshot,
Expand All @@ -393,6 +443,7 @@ def compare_snapshots(
baseline=baseline.server_version or baseline.server_name,
candidate=candidate.server_version or candidate.server_name,
)
_compare_extensions(report, baseline, candidate)
before = {tool.name: tool for tool in baseline.tools}
after = {tool.name: tool for tool in candidate.tools}

Expand Down
114 changes: 114 additions & 0 deletions src/tool_semantics/extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""MCP extension capture and compatibility helpers (#91).

Best-effort extraction of extensions / experimental capabilities advertised at
``initialize``, normalized into snapshot metadata for diffing.
"""

from __future__ import annotations

from typing import Any

from pydantic import BaseModel, Field


class ExtensionInfo(BaseModel):
"""Normalized extension advertisement."""

name: str
version: str | None = None
# Where the advertisement was found (for docs / debugging).
source: str = "unknown"
# Raw payload fragment when not a simple version string.
details: dict[str, Any] = Field(default_factory=dict)


def _as_extension_info(
name: str,
value: Any,
*,
source: str,
) -> ExtensionInfo:
if value is True or value is None:
return ExtensionInfo(name=name, version=None, source=source)
if isinstance(value, str):
return ExtensionInfo(name=name, version=value, source=source)
if isinstance(value, dict):
version = value.get("version")
version_s = str(version) if version is not None else None
details = {key: val for key, val in value.items() if key != "version"}
return ExtensionInfo(
name=name,
version=version_s,
source=source,
details=details if details else {},
)
return ExtensionInfo(
name=name,
version=None,
source=source,
details={"value": value},
)


def extract_extensions_from_initialize(init: Any) -> dict[str, ExtensionInfo]:
"""Best-effort extension map from an MCP ``initialize`` result.

Protocol generations differ; we accept several shapes without inventing
extensions when none are advertised:

- top-level ``extensions`` object
- ``capabilities.extensions``
- ``capabilities.experimental`` (treated as experimental extension stubs)
"""
if not isinstance(init, dict):
return {}

found: dict[str, ExtensionInfo] = {}

def ingest(raw: Any, *, source: str) -> None:
if isinstance(raw, dict):
for name, value in raw.items():
if not isinstance(name, str) or not name:
continue
# Later, more-specific sources can override.
found[name] = _as_extension_info(name, value, source=source)
elif isinstance(raw, list):
for item in raw:
if isinstance(item, str) and item:
found[item] = ExtensionInfo(name=item, source=source)
elif isinstance(item, dict) and isinstance(item.get("name"), str):
name = item["name"]
found[name] = _as_extension_info(name, item, source=source)

ingest(init.get("extensions"), source="initialize.extensions")
caps = init.get("capabilities")
if isinstance(caps, dict):
ingest(caps.get("extensions"), source="capabilities.extensions")
ingest(caps.get("experimental"), source="capabilities.experimental")

return found


def extensions_metadata(init: Any) -> dict[str, dict[str, Any]]:
"""JSON-serializable ``metadata['extensions']`` payload for snapshots."""
return {
name: info.model_dump(mode="json")
for name, info in sorted(extract_extensions_from_initialize(init).items())
}


def extensions_from_snapshot_metadata(
metadata: dict[str, Any],
) -> dict[str, ExtensionInfo]:
raw = metadata.get("extensions")
if not isinstance(raw, dict):
return {}
out: dict[str, ExtensionInfo] = {}
for name, value in raw.items():
if not isinstance(name, str):
continue
if isinstance(value, dict):
out[name] = ExtensionInfo.model_validate({"name": name, **value})
else:
out[name] = _as_extension_info(name, value, source="snapshot.metadata")
return out
10 changes: 10 additions & 0 deletions src/tool_semantics/mcp_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@ def _server_capabilities(init: Any) -> dict[str, Any]:
return caps if isinstance(caps, dict) else {}


def _server_extensions_metadata(init: Any) -> dict[str, Any]:
"""Best-effort extensions advertised at initialize (#91)."""
from tool_semantics.extensions import extensions_metadata

return extensions_metadata(init)


def _list_interface_via_rpc(
rpc: Any,
notify: Any,
Expand Down Expand Up @@ -329,6 +336,7 @@ def notify(method: str, params: dict[str, Any] | None = None) -> None:
"command": command,
"protocol_version": negotiated,
"server_capabilities": _server_capabilities(init),
"extensions": _server_extensions_metadata(init),
},
)
return redact_snapshot(snapshot) if redact else snapshot
Expand Down Expand Up @@ -872,6 +880,7 @@ def capture_mcp_sse(
"request_header_names": _headers_for_metadata(request_headers),
"protocol_version": negotiated,
"server_capabilities": _server_capabilities(init),
"extensions": _server_extensions_metadata(init),
}
return _snapshot_from_lists(
protocol="mcp-sse",
Expand Down Expand Up @@ -929,6 +938,7 @@ def capture_mcp_http(
"request_header_names": _headers_for_metadata(request_headers),
"protocol_version": negotiated,
"server_capabilities": _server_capabilities(init),
"extensions": _server_extensions_metadata(init),
"mcp_session": session.has_session,
}
return _snapshot_from_lists(
Expand Down
Loading