From f539763282ca1fc7c47c09591062577ad8614e83 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 22:24:19 +0300 Subject: [PATCH 01/16] docs: design spec for the mgraphctl MCP server --- docs/specs/2026-09-07-mcp-server-design.md | 261 +++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 docs/specs/2026-09-07-mcp-server-design.md diff --git a/docs/specs/2026-09-07-mcp-server-design.md b/docs/specs/2026-09-07-mcp-server-design.md new file mode 100644 index 0000000..fbff057 --- /dev/null +++ b/docs/specs/2026-09-07-mcp-server-design.md @@ -0,0 +1,261 @@ +# mgraphctl MCP server — design specification + +Status: approved 2026-09-07. Implemented on `feat/mcp-server`. + +This document specifies `mgraphctl mcp serve`: a Model Context Protocol server that exposes the +mgraphctl CLI verbs as MCP tools. It extends the base design +(`docs/specs/2026-09-02-mgraphctl-design.md`), whose section numbers are cited as "base §N". + +## 1. Goals and non-goals + +Goals: + +- Every CLI verb reachable as an MCP tool, generated from the command tree rather than restated, + so a new verb becomes a new tool with no second surface to maintain. +- The MCP revision in force is **2026-07-28**. No deprecated feature is implemented. +- Bounded context cost: the operator chooses which command groups are exposed, and no single tool + result can flood the client. +- Bounded blast radius: read-only by default, one identity, loopback only. + +Non-goals: + +- OAuth 2.1 authorization (§7.3 says why it cannot be done honestly here). +- The deprecated HTTP+SSE transport (2024-11-05), protocol sessions, resumable streams, or + Dynamic Client Registration. +- Interactive sign-in as a tool. `login` opens a browser; it stays a CLI verb. + +## 2. Protocol revision + +The server targets MCP **2026-07-28** and speaks the earlier revisions the SDK negotiates, so +hosts that have not moved yet still work. Consequences of that revision, all of which the +implementation follows rather than works around: + +- No `initialize` handshake; per-request metadata in `_meta.io.modelcontextprotocol/*`. +- No protocol-level session, so no `Mcp-Session-Id` is minted or echoed. +- No standalone GET stream and no `Last-Event-ID` resumption. GET and DELETE on the MCP endpoint + answer `405 Method Not Allowed`. +- Server-to-client interaction, if it is ever needed, is `InputRequiredResult` under MRTR, never a + server-initiated JSON-RPC request. The current tool set needs none. + +## 3. Packaging + +`mcp>=2.1,<3` is an **optional extra**, `mgraphctl[mcp]`, never a base dependency: the SDK pulls in +pydantic, starlette, uvicorn, jsonschema, pyjwt, opentelemetry and `httpx2`, none of which the CLI +itself should carry. `mgraphctl mcp serve` without the extra installed fails with a usage error +naming the install command. + +New modules under `src/mgraphctl/mcp/`: + +| Module | Holds | +|---|---| +| `discover.py` | Walks the Click tree; yields one `ToolSpec` per verb, with its capability and mutation class. | +| `schema.py` | Click params → JSON Schema; argument-name normalisation. | +| `output.py` | `Result` → MCP content: text, structured, spill-to-file, resource links. | +| `server.py` | Builds the `MCPServer`, registers tools and resources, dispatches calls. | +| `http_app.py` | Streamable HTTP: loopback guard, bearer check, Origin validation. | + +`commands/mcp_cmd.py` registers the `mcp` noun (`serve`, `tools`). It is attached in `build_app` +alongside `config_cmd`, not through the `NOUNS` table, because it makes no Graph call. + +## 4. Tool generation + +### 4.1 Discovery + +`discover.py` walks the Click group tree exactly as `tests/test_surface.py` does — that walk moves +into `discover.py` and the test imports it, so the two cannot drift. Each leaf command yields a +`ToolSpec`: + +| Field | From | +|---|---| +| `name` | Path joined with `_`: `mail list` → `mail_list`, `mail drafts create` → `mail_drafts_create`. | +| `title` | The path as typed: `mail drafts create`. | +| `description` | The command's help text (its docstring). | +| `capability` | The first path segment, or `core` for a top-level verb. | +| `input_schema` | `schema.py`, from the Click params. | +| `annotations` | §4.4. | +| `scopes` | `__graph_scopes__`, already stashed by `graph_command`. | +| `fn` | `__graph_fn__`, added to `graph_command` by this change. | + +Tool names satisfy the spec's character rules and are unique by construction (the CLI path is). + +### 4.2 Input schema + +Every Click param becomes one property. `--body-file` → `body_file`; `--all` → `all`; a positional +`ID` argument → a required property named after the parameter. Types map `STRING`→`string`, +`INT`→`integer`, `BOOL` flag→`boolean`, `Path`→`string`, `multiple=True`→`array` of the item type. +`description` is the Click help; `default` is carried when it is not `None`. Required properties are +the Click arguments plus any option with `required=True`. The schema is closed +(`additionalProperties: false`) so a client cannot smuggle unknown kwargs into the callback. + +Two params are **not** derived from Click, because the CLI's `--json` does not translate directly +(§5): `output_format` and `output_file` are added to every tool. `--json` itself is excluded from +the generated schema. + +### 4.3 Capabilities + +A capability is a command group: `mail`, `calendar`, `teams`, … plus two synthetic ones — `core` +(`me`, `status`, `claims`, `version`) and `api` (the raw Graph escape hatch of base §8.1). + +`--capabilities mail,calendar,teams` selects them. Omitted, the default is +`core,mail,calendar,people,chats` — a working assistant without loading all 123 verbs. `--capabilities all` +is accepted and means every capability except `api`; `api` is only ever exposed by naming it. +An unknown name is a usage error listing the valid ones. + +`mgraphctl mcp tools [--capabilities ...] [--json]` prints the resolved tool list without starting a +server, so the context cost is inspectable before wiring the server into a host. + +### 4.4 Read-only default and annotations + +A verb is **mutating** if it issues any request whose method is not GET. That classification is +derived once and asserted by a test, so a new mutating verb cannot silently become an exposed +read tool. + +Only non-mutating tools are exposed unless `--allow-write` is passed. Annotations follow: +`readOnlyHint` true for non-mutating verbs; `destructiveHint` true for verbs whose path ends in +`delete`; `idempotentHint` true for `update`/`set`-shaped verbs. Annotations are hints for the +host's confirmation UI — the server's own gate is `--allow-write`, since the spec tells clients to +treat annotations as untrusted. + +Mutating tools additionally expose `dry_run`, mapping to the CLI's `--dry-run` (base §6.4). + +## 5. Results: what happens to `--json` + +`--json` is a rendering switch on a stream the CLI owns. MCP has three distinct channels for the +same information, so the flag becomes a choice among them rather than a boolean. + +Dispatch is **in-process**. `graph_command` gains `__graph_fn__`, so the server opens a client with +the verb's declared scopes and calls the undecorated function, receiving the same `Result` dataclass +the CLI renders (base §7.3). Nothing touches stdout — which is load-bearing, because on stdio +transport stdout *is* the JSON-RPC channel. To make that possible `render.emit` splits into pure +functions: + +```python +def to_text(result: Result) -> str # the body the CLI prints to stdout +def notes(result: Result) -> list[str] # the truncation lines the CLI prints to stderr +def to_json(result: Result) -> Any # today's private _to_json, made public +``` + +`emit` keeps its exact current behaviour by composing them; the CLI is unchanged. + +Every tool takes `output_format`: + +- `text` (default) — one `TextContent` block: `to_text` plus any `notes`. This is the compact table + a human sees, and it is far cheaper in context than the JSON. +- `json` — `structuredContent` set to `to_json(result)`, plus the serialized JSON as a + `TextContent` block, as the spec asks for backwards compatibility. + +and `output_file`: an optional **relative** name. Given one, the full result is written under the +server's output directory and the tool returns a `resource_link` plus a one-line summary (item +count, whether the fetch was truncated) instead of the payload. The path is relative and resolved +inside the output directory; an absolute path or one escaping the directory is a tool error. The +server never writes where the caller asks it to — only where the operator configured. + +`--output-dir` defaults to `~/.cache/mgraphctl/mcp/` (`$XDG_CACHE_HOME` honoured), created +`0o700` on first use. + +**Auto-spill.** Any result whose rendering exceeds `--max-inline-bytes` (default 25000) is written +to the output directory and returned as a `resource_link` with a head excerpt, whatever the +caller asked for. A runaway `mail list --all` therefore cannot flood the client. + +**Files the verbs already produce.** `FileResult` verbs (`onedrive download`, `me --photo`, …) have +their destination forced inside the output directory and return the same `resource_link` shape. + +**Resources.** Everything the server writes is registered as an MCP resource under a `file://` URI +and served by `resources/read`, so a host with no filesystem access can still fetch what a +`resource_link` points at. The resource list is exactly the files this server produced; it does not +expose the rest of the filesystem. + +**Errors.** A `MsgraphError` becomes a tool result with `isError: true` carrying `format_error`'s +block (base §6.5) — it already states the code, the message and an actionable hint, which is what +the spec wants a model to be able to self-correct from. An unknown tool name is a JSON-RPC +protocol error, not a tool result. + +## 6. Transports + +### 6.1 stdio + +The primary path, and the one the README documents first. Credentials come from the environment +(the MSAL cache, base §4.3), which is what the authorization spec prescribes for stdio: it says +implementations on stdio **SHOULD NOT** follow the OAuth flow and should take credentials from the +environment. + +### 6.2 Streamable HTTP + +`--transport http [--host 127.0.0.1] [--port N]`. One POST endpoint at `/mcp`. GET and DELETE +answer `405`. No session id is minted, no `Last-Event-ID` honoured. + +Three guards, in this order: + +1. **Loopback.** A `--host` that is not a loopback literal is a usage error, not a warning. The + server refuses to start. +2. **Bearer token.** Required. Taken from `MGRAPHCTL_MCP_TOKEN`, else generated with + `secrets.token_urlsafe(32)` and printed to stderr at startup with the URL to paste into the host + config. Compared with `secrets.compare_digest`. A missing or wrong token gets `401` with + `WWW-Authenticate: Bearer`. +3. **Origin.** A request carrying an `Origin` header not in `--allow-origin` (empty by default) gets + `403`. This is the DNS-rebinding defence the transport spec mandates for local servers: without + it, any web page the user visits can drive their mailbox through localhost. + +## 7. Authentication, and its limits over HTTP + +### 7.1 One identity + +The server acts as exactly one user: whoever's MSAL refresh token is in the local cache. It has no +way to tell callers apart. An HTTP listener therefore converts that cache into an ambient +credential for anything that can reach the port — which is the whole reason for the loopback and +bearer guards in §6.2, and the reason stdio is the recommended transport. + +### 7.2 No cached sign-in + +The server starts, warns on stderr, and every tool call returns an actionable error telling the +operator to run `mgraphctl login`. It is not a startup failure: a token may appear later, and a +host that supervises the process should not crash-loop. + +### 7.3 Why the MCP authorization spec is not implemented + +Conforming would mean acting as an OAuth 2.1 resource server: RFC 9728 protected resource metadata, +authorization-server discovery, and access tokens **validated for this server as their audience**. + +The obvious shortcut — accepting the caller's Entra token and forwarding it to Graph — is precisely +the token passthrough the spec prohibits, and it fails audience validation by construction: a Graph +token's audience is Graph, not this server. + +Doing it properly needs a *second* Entra app registered as an API with its own scope, plus an +on-behalf-of exchange to trade that token for a Graph token. On-behalf-of requires a **confidential** +client, i.e. a client secret. A locally installed CLI cannot hold a secret (base §4.2 is a public +client for exactly this reason), and the registration needs tenant admin work that the tool cannot +assume. + +So the HTTP transport ships with a local shared secret and a loopback bind, and this is stated +plainly in the README rather than dressed up as OAuth. The server advertises no +`/.well-known/oauth-protected-resource` document, because it is not an OAuth resource server and +claiming otherwise would send clients into a flow that cannot complete. + +## 8. Surface + +``` +mgraphctl mcp serve [--capabilities NAMES] [--allow-write] [--transport stdio|http] + [--host HOST] [--port PORT] [--allow-origin ORIGIN]... + [--output-dir DIR] [--max-inline-bytes N] +mgraphctl mcp tools [--capabilities NAMES] [--allow-write] [--json] +``` + +`mcp serve` is the one verb in the CLI that cannot satisfy the `--json` invariant of base §11: it +starts a server and prints nothing. `tests/test_surface.py` carries a single documented exemption +for it. `mcp tools` satisfies the invariant normally. + +## 9. Tests + +| Area | Assertion | +|---|---| +| `discover` | Every verb in the Click walk produces exactly one `ToolSpec`; names are unique and character-legal. | +| `schema` | Click params map to the documented JSON Schema; `--json` is absent; `output_format`/`output_file` present. | +| Capabilities | Filtering by name; `all`; `api` excluded from `all`; unknown name is a usage error. | +| Read-only | No mutating tool is exposed without `--allow-write`; the mutation classification matches the verbs' HTTP methods. | +| Output | `text` vs `json`; `output_file` writes inside the output dir and returns a `resource_link`; a path escaping the dir is refused; auto-spill fires past the threshold; the spilled file is readable through `resources/read`. | +| Errors | `MsgraphError` becomes `isError` with the `format_error` block; unknown tool is a protocol error. | +| HTTP | Non-loopback host refused; missing/wrong bearer is `401`; a foreign `Origin` is `403`; GET and DELETE are `405`. | +| Dispatch | A tool call reaches the verb's function with the right kwargs, against the recorded fixtures — no network. | + +Tests use the SDK's in-memory client/server pair, so no socket is opened except in the HTTP guard +tests. From c12c368a1ff4e07b50d0b76f06514ec164d59fe4 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 22:26:01 +0300 Subject: [PATCH 02/16] refactor(render): split emit into pure to_text/notes/to_json The MCP server renders results without owning stdout - on stdio transport stdout carries JSON-RPC - so the rendering has to be reachable as strings. emit composes the three and behaves exactly as before. --- src/mgraphctl/render.py | 113 +++++++++++++++++++++++----------------- tests/test_render.py | 45 ++++++++++++++-- 2 files changed, 106 insertions(+), 52 deletions(-) diff --git a/src/mgraphctl/render.py b/src/mgraphctl/render.py index 46b0de0..be2120e 100644 --- a/src/mgraphctl/render.py +++ b/src/mgraphctl/render.py @@ -6,6 +6,7 @@ from __future__ import annotations import functools +import io import json import os import re @@ -15,7 +16,7 @@ from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from pathlib import Path -from typing import Any +from typing import IO, Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from rich.console import Console @@ -521,10 +522,10 @@ def has_time_of_day(dt: datetime | None, tz: str, *, end_of_day: bool) -> bool: return (local.hour, local.minute, local.second) != boundary or local.microsecond != 0 -def _console(natural_width: int) -> Console: +def _console(natural_width: int, file: IO[str]) -> Console: width = max(int(os.environ.get("COLUMNS") or 200), natural_width) return Console( - file=sys.stdout, + file=file, width=width, force_terminal=False, no_color="NO_COLOR" in os.environ, @@ -543,33 +544,64 @@ def _cell(col: Column, item: dict) -> str: return str(value) -def _emit_list(res: ListResult) -> None: +def _write_list(res: ListResult, out: IO[str]) -> None: if not res.items: - sys.stdout.write(res.empty_text + "\n") - else: - rows = [[_cell(c, it) for c in res.columns] for it in res.items] - widths = [max(len(c.header), *(len(r[i]) for r in rows)) for i, c in enumerate(res.columns)] - table = Table( - box=None, show_edge=False, pad_edge=False, padding=(0, 2), header_style="bold" - ) - for c in res.columns: - table.add_column(c.header, no_wrap=True, overflow="ignore") - for r in rows: - table.add_row(*r) - # padding=(0, 2) with pad_edge=False puts a 2-char pad on each side of every internal - # column boundary (4 chars per gap) and none at the table's outer edges. - natural_width = sum(widths) + 4 * (len(widths) - 1) - _console(natural_width).print(table) - if res.truncated: - if res.hit_cap: - note(f"(hit the {res.hit_cap}-item cap — narrow the query)") - elif res.supports_all: - note("(more results available — rerun with --all)") - else: - note("(more results available — raise --limit)") - - -def _to_json(result: Result) -> Any: + out.write(res.empty_text + "\n") + return + rows = [[_cell(c, it) for c in res.columns] for it in res.items] + widths = [max(len(c.header), *(len(r[i]) for r in rows)) for i, c in enumerate(res.columns)] + table = Table(box=None, show_edge=False, pad_edge=False, padding=(0, 2), header_style="bold") + for c in res.columns: + table.add_column(c.header, no_wrap=True, overflow="ignore") + for r in rows: + table.add_row(*r) + # padding=(0, 2) with pad_edge=False puts a 2-char pad on each side of every internal + # column boundary (4 chars per gap) and none at the table's outer edges. + natural_width = sum(widths) + 4 * (len(widths) - 1) + _console(natural_width, out).print(table) + + +def _write(result: Result, out: IO[str]) -> None: + """The body of `result`, written to `out`. Notes are `notes()`; they are not part of it.""" + match result: + case ListResult(): + _write_list(result, out) + case ObjectResult(): + width = max(len(label) for label, _ in result.fields) + for label, path in result.fields: + value = path(result.obj) if callable(path) else dig(result.obj, path) + out.write(f"{label:<{width}} : {'' if value is None else value}\n") + if result.body is not None: + out.write("\n" + result.body.rstrip("\n") + "\n") + case TextResult(): + out.write(result.text.rstrip("\n") + "\n") + case WriteResult() | FileResult(): + out.write(result.message + "\n") + case DryRunResult(): + from mgraphctl.http import plan_to_text + + out.write(plan_to_text(result.plan)) + + +def to_text(result: Result) -> str: + """The text body `emit` writes to stdout, as a string.""" + buffer = io.StringIO() + _write(result, buffer) + return buffer.getvalue() + + +def notes(result: Result) -> list[str]: + """The diagnostic lines `emit` writes to stderr: how the fetch was cut short, if it was.""" + if not isinstance(result, ListResult) or not result.truncated: + return [] + if result.hit_cap: + return [f"(hit the {result.hit_cap}-item cap — narrow the query)"] + if result.supports_all: + return ["(more results available — rerun with --all)"] + return ["(more results available — raise --limit)"] + + +def to_json(result: Result) -> Any: match result: case ListResult(): envelope: dict[str, Any] = { @@ -605,23 +637,8 @@ def _to_json(result: Result) -> Any: def emit(result: Result, *, json_mode: bool) -> None: """Render `result` to stdout: one JSON document, or the appropriate text form.""" if json_mode: - sys.stdout.write(json.dumps(_to_json(result), indent=2, ensure_ascii=False) + "\n") + sys.stdout.write(json.dumps(to_json(result), indent=2, ensure_ascii=False) + "\n") return - match result: - case ListResult(): - _emit_list(result) - case ObjectResult(): - width = max(len(label) for label, _ in result.fields) - for label, path in result.fields: - value = path(result.obj) if callable(path) else dig(result.obj, path) - sys.stdout.write(f"{label:<{width}} : {'' if value is None else value}\n") - if result.body is not None: - sys.stdout.write("\n" + result.body.rstrip("\n") + "\n") - case TextResult(): - sys.stdout.write(result.text.rstrip("\n") + "\n") - case WriteResult() | FileResult(): - sys.stdout.write(result.message + "\n") - case DryRunResult(): - from mgraphctl.http import plan_to_text - - sys.stdout.write(plan_to_text(result.plan)) + _write(result, sys.stdout) + for line in notes(result): + note(line) diff --git a/tests/test_render.py b/tests/test_render.py index 2afd0c1..633c0fd 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -2,6 +2,7 @@ import json from datetime import datetime, timedelta, timezone +from pathlib import Path from zoneinfo import ZoneInfo import pytest @@ -306,12 +307,12 @@ def test_list_envelope_takes_cap_fetched_and_truncated_from_the_page(): def test_list_envelope_reports_fetched_above_count_after_a_post_filter(): page = PageResult(items=[{"id": "1"}, {"id": "2"}], truncated=False, pages=1, cap=50) kept = filter_page(page, lambda item: item["id"] == "1") - doc = render._to_json(render.ListResult(items=kept.items, columns=[], page=kept)) + doc = render.to_json(render.ListResult(items=kept.items, columns=[], page=kept)) assert doc["count"] == 1 and doc["fetched"] == 2 and doc["cap"] == 50 def test_list_envelope_window_is_present_with_both_bounds_unset(): - doc = render._to_json(render.ListResult(items=[], columns=[], window=render.Window())) + doc = render.to_json(render.ListResult(items=[], columns=[], window=render.Window())) assert doc["window"] == {"after": None, "before": None} @@ -321,7 +322,7 @@ def test_list_envelope_window_renders_iso_offsets(): after=render.parse_dt("2026-08-01", tz), before=render.parse_dt("2026-08-31", tz, end_of_day=True), ) - doc = render._to_json(render.ListResult(items=[], columns=[], window=window)) + doc = render.to_json(render.ListResult(items=[], columns=[], window=window)) assert doc["window"] == { "after": "2026-08-01T00:00:00+02:00", "before": "2026-08-31T23:59:59+02:00", @@ -329,7 +330,7 @@ def test_list_envelope_window_renders_iso_offsets(): def test_list_envelope_omits_window_and_query_when_the_command_has_none(): - doc = render._to_json(render.ListResult(items=[], columns=[])) + doc = render.to_json(render.ListResult(items=[], columns=[])) assert "window" not in doc and "query" not in doc assert doc["fetched"] == 0 and doc["cap"] is None @@ -339,3 +340,39 @@ def test_list_envelope_page_overrides_an_explicit_truncated(): page = PageResult(items=[{"id": "1"}], truncated=False, pages=1, cap=10) res = render.ListResult(items=[], columns=[], page=page, truncated=True) assert res.truncated is False + + +def test_to_text_returns_the_body_emit_writes_to_stdout(capsys): + """`emit` composes `to_text`; a caller that owns stdout (the MCP server) can take the string.""" + res = render.ListResult( + items=[{"id": "1", "subject": "Hello"}], + columns=[render.Column("Id", "id"), render.Column("Subject", "subject")], + ) + render.emit(res, json_mode=False) + assert render.to_text(res) == capsys.readouterr().out + + +def test_to_text_covers_every_result_type(capsys): + for res in ( + render.ListResult(items=[], columns=[], empty_text="Nothing."), + render.ObjectResult(obj={"a": "1"}, fields=[("Alpha", "a")], body="body text"), + render.TextResult(text="plain", json_obj={"k": "v"}), + render.WriteResult(obj=None, message="Done."), + render.FileResult(path=Path("/tmp/x"), bytes=3, meta={}, message="Saved."), + ): + render.emit(res, json_mode=False) + assert render.to_text(res) == capsys.readouterr().out + + +def test_notes_returns_the_truncation_lines_emit_writes_to_stderr(capsys): + res = render.ListResult( + items=[{"id": "1"}], columns=[render.Column("Id", "id")], truncated=True + ) + render.emit(res, json_mode=False) + assert capsys.readouterr().err.splitlines() == render.notes(res) + assert render.notes(res) == ["(more results available — rerun with --all)"] + + +def test_notes_is_empty_when_the_fetch_was_complete(): + assert render.notes(render.ListResult(items=[], columns=[])) == [] + assert render.notes(render.TextResult(text="x", json_obj=None)) == [] From f10122f6425f0c74b0a8bb7274391e8b6b01fae7 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 22:30:57 +0300 Subject: [PATCH 03/16] feat(mcp): discover tools from the Click tree and derive their JSON Schema walk() moves out of test_surface.py into discover.py and the test imports it, so the CLI's coverage invariants and the MCP tool set describe one command tree. Mutation is an explicit table rather than a scope check: Graph has no read-only Tasks scope, so planner/todo reads must declare Tasks.ReadWrite, while chats create writes holding nothing matching ReadWrite. Tests fail on an unclassified verb, a mutating verb without a write-capable scope, and a write-capable verb that is neither classified nor named in the excuse list. --- src/mgraphctl/mcp/__init__.py | 5 + src/mgraphctl/mcp/discover.py | 246 ++++++++++++++++++++++++++++++++++ src/mgraphctl/mcp/schema.py | 96 +++++++++++++ tests/test_mcp_discover.py | 129 ++++++++++++++++++ tests/test_mcp_schema.py | 80 +++++++++++ tests/test_surface.py | 19 +-- 6 files changed, 557 insertions(+), 18 deletions(-) create mode 100644 src/mgraphctl/mcp/__init__.py create mode 100644 src/mgraphctl/mcp/discover.py create mode 100644 src/mgraphctl/mcp/schema.py create mode 100644 tests/test_mcp_discover.py create mode 100644 tests/test_mcp_schema.py diff --git a/src/mgraphctl/mcp/__init__.py b/src/mgraphctl/mcp/__init__.py new file mode 100644 index 0000000..52965d7 --- /dev/null +++ b/src/mgraphctl/mcp/__init__.py @@ -0,0 +1,5 @@ +"""The MCP server: the CLI's verbs as Model Context Protocol tools. + +Design: `docs/specs/2026-09-07-mcp-server-design.md`. Only `discover` and `schema` are importable +without the `mgraphctl[mcp]` extra; `server` and `http_app` need the SDK. +""" diff --git a/src/mgraphctl/mcp/discover.py b/src/mgraphctl/mcp/discover.py new file mode 100644 index 0000000..68a5231 --- /dev/null +++ b/src/mgraphctl/mcp/discover.py @@ -0,0 +1,246 @@ +"""The Click tree becomes tool specs (MCP spec §4). + +`walk` is the single definition of "every registered verb": `tests/test_surface.py` imports it, so +the CLI's coverage invariants and the MCP tool set cannot describe different command trees. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import typer +import typer.main + +from mgraphctl.errors import UsageError +from mgraphctl.mcp import schema + +# `login` and `logout` drive a browser and a credential store; `config` edits the operator's file. +# Both belong to the person running the server, not to the model talking to it. +EXCLUDED = { + "login", + "logout", + "config path", + "config show", + "config init", + "config set", + "config unset", +} + +# Verbs that change something. Kept explicit because a scope is not a reliable signal in either +# direction: Graph has no read-only Tasks scope, so `planner tasks` must ask for `Tasks.ReadWrite` +# to read, while `chats create` writes holding nothing that matches "ReadWrite". +# `test_mcp_discover.py` fails if a verb is unclassified, if a mutating verb lacks a scope that +# could write, or if a verb that could write is neither listed here nor in READ_WITH_WRITE_SCOPE. +MUTATING = frozenset( + { + "api", # takes --method; a POST or DELETE is one argument away + "calendar create", + "calendar delete", + "calendar respond", + "calendar update", + "chats create", + "chats dm", + "chats send", + "mail delete", + "mail drafts create", + "mail drafts send", + "mail forward", + "mail mark", + "mail move", + "mail reply", + "mail send", + "mailbox oof set", + "onedrive delete", + "onedrive mkdir", + "onedrive move", + "onedrive rename", + "onedrive share", + "onedrive upload", + "onenote create", + "planner complete", + "planner create", + "planner delete", + "planner update", + "presence clear", + "presence set", + "sharepoint upload", + "teams channel send", + "todo complete", + "todo create", + "todo delete", + "todo from-mail", + "todo update", + } +) + +# Reads that have to declare a write-capable scope because Graph offers no narrower one. +READ_WITH_WRITE_SCOPE = frozenset( + { + "planner buckets", + "planner plan", + "planner plans", + "planner task", + "planner tasks", + "todo lists", + "todo task", + "todo tasks", + } +) + +WRITE_SCOPE_MARKERS = ("ReadWrite", ".Send", ".Create", ".Write") + +# Command groups the operator can switch on, plus the two synthetic ones. `core` is the top-level +# verbs; `api` is the raw Graph escape hatch and is never part of `all`. +CORE = "core" +API = "api" +DEFAULT = ("core", "mail", "calendar", "people", "chats") + + +@dataclass(frozen=True) +class ToolSpec: + """One CLI verb, as much as the server needs to expose and to call it.""" + + name: str + path: str + description: str + capability: str + mutating: bool + scopes: list[str] + command: Any + input_schema: dict[str, Any] + annotations: dict[str, bool] + # Schema property name -> the callback's Python parameter name (`from` -> `from_`). + params: dict[str, str] + # The undecorated `fn(client, **kwargs)` of a `@graph_command`; None for a verb that answers + # locally (`status`, `claims`, `version`) and is invoked through its Click callback instead. + fn: Callable[..., Any] | None + + @property + def title(self) -> str: + return self.path + + +def walk(app: Any) -> dict[str, Any]: + """Every leaf verb in the tree, keyed by the path a user would type.""" + group = app if hasattr(app, "commands") else typer.main.get_group(app) + out: dict[str, Any] = {} + + def rec(cmd: Any, prefix: str) -> None: + for name, sub in cmd.commands.items(): + full = f"{prefix} {name}".strip() + if hasattr(sub, "commands"): + rec(sub, full) + else: + out[full] = sub + + rec(group, "") + return out + + +# Every capability name. `test_mcp_discover.py` checks this against the groups the CLI registers, +# so a new noun cannot be silently unreachable. +ALL = frozenset( + { + CORE, + API, + "calendar", + "chats", + "groups", + "mail", + "mailbox", + "meetings", + "onedrive", + "onenote", + "org", + "people", + "planner", + "presence", + "sharepoint", + "teams", + "todo", + } +) + + +def capabilities_of(app: Any) -> set[str]: + """The capability names the registered command tree actually offers.""" + groups = {path.split(" ")[0] for path in walk(app) if " " in path} + return groups - {"config"} | {CORE, API} + + +def resolve(value: str | None) -> list[str]: + """`--capabilities` to a list of names. Omitted is the default set; `all` excludes `api`.""" + if value is None: + return list(DEFAULT) + names = [n.strip() for n in value.split(",") if n.strip()] + if names == ["all"]: + return sorted(ALL - {API}) + unknown = [n for n in names if n not in ALL] + if unknown: + raise UsageError( + "USAGE", + f"unknown capability {', '.join(sorted(unknown))}; " + f"choose from {', '.join(sorted(ALL))}, or 'all'", + ) + return list(dict.fromkeys(names)) + + +def can_write(scopes: list[str]) -> bool: + """Whether the declared scopes permit anything but a read.""" + return any(marker in scope for scope in scopes for marker in WRITE_SCOPE_MARKERS) + + +def _annotations(path: str, mutating: bool) -> dict[str, bool]: + """Hints for the host's confirmation UI. The server's own gate is `--allow-write`.""" + if not mutating: + return {"readOnlyHint": True, "openWorldHint": True} + verb = path.rsplit(" ", 1)[-1] + return { + "readOnlyHint": False, + "destructiveHint": verb == "delete", + "idempotentHint": verb in ("update", "set", "mark", "complete", "rename", "clear"), + "openWorldHint": True, + } + + +def spec_for(path: str, command: Any) -> ToolSpec: + callback = command.callback + scopes = list(getattr(callback, "__graph_scopes__", []) or []) + properties, params = schema.build(command) + mutating = path in MUTATING + return ToolSpec( + name=path.replace(" ", "_"), + path=path, + description=(command.help or command.short_help or path).strip(), + capability=path.split(" ")[0] if " " in path else (API if path == API else CORE), + mutating=mutating, + scopes=scopes, + command=command, + input_schema=properties, + annotations=_annotations(path, mutating), + params=params, + fn=getattr(callback, "__wrapped__", None) + if hasattr(callback, "__graph_scopes__") + else None, + ) + + +def discover( + app: Any, + *, + capabilities: list[str] | frozenset[str], + allow_write: bool = False, +) -> list[ToolSpec]: + """The tools this server exposes, in a deterministic order (the spec asks for one).""" + wanted = set(capabilities) + out = [] + for path, command in sorted(walk(app).items()): + if path in EXCLUDED: + continue + spec = spec_for(path, command) + if spec.capability not in wanted or (spec.mutating and not allow_write): + continue + out.append(spec) + return out diff --git a/src/mgraphctl/mcp/schema.py b/src/mgraphctl/mcp/schema.py new file mode 100644 index 0000000..5f95c93 --- /dev/null +++ b/src/mgraphctl/mcp/schema.py @@ -0,0 +1,96 @@ +"""Click parameters become a tool's JSON Schema (MCP spec §4.2, §5). + +The schema is closed: a client cannot smuggle a keyword the callback never declared. +""" + +from __future__ import annotations + +from typing import Any + +# The CLI's `--json` is a switch on a stream the CLI owns; MCP has three channels for the same +# information, so it becomes `output_format` and `output_file` instead (MCP spec §5). +DROPPED = {"json_"} + +OUTPUT_FORMAT = { + "type": "string", + "enum": ["text", "json"], + "default": "text", + "description": ( + "'text' returns the compact table the CLI prints — far cheaper to read. " + "'json' returns the full Graph payload as structured content." + ), +} +OUTPUT_FILE = { + "type": "string", + "description": ( + "Optional file name, relative to the server's output directory. Given one, the full " + "result is written there and a link is returned instead of the payload — use it for " + "large fetches you want to keep out of the conversation." + ), +} + +_SCALARS = { + "text": "string", + "str": "string", + "integer": "integer", + "int": "integer", + "int range": "integer", + "boolean": "boolean", + "path": "string", + "file": "string", + "filename": "string", + "choice": "string", +} + + +def property_name(param: Any) -> str: + """`from_` -> `from`: the callback's name freed of the underscore Python forced on it.""" + return param.name.rstrip("_") if param.name.endswith("_") else param.name + + +def _type_of(param: Any) -> dict[str, Any]: + kind = _SCALARS.get(param.type.name, "string") + node: dict[str, Any] = {"type": kind} + choices = getattr(param.type, "choices", None) + if choices: + node["enum"] = list(choices) + if param.multiple: + return {"type": "array", "items": node} + return node + + +def _describe(param: Any) -> str: + help_text = (getattr(param, "help", None) or "").strip() + if param.param_type_name == "argument": + return help_text or f"{param.name} (positional argument)" + flags = ", ".join(param.opts) + return f"{help_text} ({flags})" if help_text else flags + + +def build(command: Any) -> tuple[dict[str, Any], dict[str, str]]: + """The tool's `inputSchema`, and the schema-name -> Python-name map the dispatcher needs.""" + properties: dict[str, Any] = {} + required: list[str] = [] + params: dict[str, str] = {} + for param in command.params: + if param.name in DROPPED or param.name == "ctx": + continue + name = property_name(param) + node = _type_of(param) + node["description"] = _describe(param) + if param.default is not None and not param.required and not param.multiple: + node["default"] = param.default + properties[name] = node + params[name] = param.name + if param.required: + required.append(name) + properties["output_format"] = dict(OUTPUT_FORMAT) + properties["output_file"] = dict(OUTPUT_FILE) + schema: dict[str, Any] = { + "type": "object", + "properties": properties, + "additionalProperties": False, + } + if required: + schema["required"] = required + return schema, params diff --git a/tests/test_mcp_discover.py b/tests/test_mcp_discover.py new file mode 100644 index 0000000..b29f670 --- /dev/null +++ b/tests/test_mcp_discover.py @@ -0,0 +1,129 @@ +"""Tool discovery: the Click tree becomes MCP tools (MCP spec §4).""" + +import pytest + +from mgraphctl.errors import UsageError +from mgraphctl.mcp import discover + + +@pytest.fixture +def specs(app): + return {s.path: s for s in discover.discover(app, capabilities=discover.ALL, allow_write=True)} + + +def test_every_verb_becomes_exactly_one_tool(app, specs): + verbs = set(discover.walk(app)) + assert verbs - discover.EXCLUDED == set(specs) + assert len(specs) == len({s.name for s in specs.values()}) + + +def test_tool_names_follow_the_spec_character_rules(specs): + legal = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.") + for spec in specs.values(): + assert set(spec.name) <= legal, spec.name + assert 1 <= len(spec.name) <= 128 + + +def test_tool_name_is_the_verb_path_joined_with_underscores(specs): + assert specs["mail list"].name == "mail_list" + assert specs["mail drafts create"].name == "mail_drafts_create" + assert specs["calendar find-times"].name == "calendar_find-times" + assert specs["me"].name == "me" + + +def test_capability_is_the_command_group_and_top_level_verbs_are_core(specs): + assert specs["mail list"].capability == "mail" + assert specs["mail drafts create"].capability == "mail" + assert specs["me"].capability == "core" + assert specs["search"].capability == "core" + assert specs["api"].capability == "api" + + +def test_interactive_and_host_local_verbs_are_never_exposed(app): + """`login` opens a browser; `config` edits the operator's file. Neither is the model's job.""" + assert { + "login", + "logout", + "config path", + "config show", + "config init", + "config set", + "config unset", + } == discover.EXCLUDED + assert set(discover.walk(app)) >= discover.EXCLUDED + + +def test_description_comes_from_the_verbs_own_help(specs): + assert specs["mail list"].description.startswith("List messages") + + +def test_capability_filtering_selects_whole_command_groups(app): + got = discover.discover(app, capabilities=["mail", "calendar"], allow_write=True) + assert {s.capability for s in got} == {"mail", "calendar"} + assert "mail list" in {s.path for s in got} + + +def test_resolve_capabilities_defaults_and_all(app): + assert discover.resolve(None) == list(discover.DEFAULT) + assert "api" not in discover.resolve("all") + assert set(discover.resolve("all")) == set(discover.ALL) - {"api"} + assert discover.resolve("mail,calendar") == ["mail", "calendar"] + # The escape hatch is reachable, but only by naming it. + assert discover.resolve("api") == ["api"] + + +def test_resolve_capabilities_rejects_an_unknown_name(): + with pytest.raises(UsageError) as excinfo: + discover.resolve("mail,mailx") + assert "mailx" in str(excinfo.value) + + +def test_read_only_is_the_default_and_allow_write_opens_the_rest(app): + read_only = {s.path for s in discover.discover(app, capabilities=discover.ALL)} + everything = { + s.path for s in discover.discover(app, capabilities=discover.ALL, allow_write=True) + } + assert not any(discover.MUTATING & {p} for p in read_only) + assert everything - read_only == discover.MUTATING + + +def test_every_verb_is_classified_and_the_classification_is_sound(app, specs): + """Scopes bound what a verb can do: a verb holding only read scopes cannot be mutating.""" + for path, spec in specs.items(): + assert spec.mutating is (path in discover.MUTATING) + if spec.mutating and path != "api": + assert discover.can_write(spec.scopes), path + + +def test_a_verb_that_could_write_is_either_mutating_or_knowingly_excused(specs): + """A new write verb fails here until it is classified; the excuse list is finite and named.""" + for path, spec in specs.items(): + if discover.can_write(spec.scopes) and not spec.mutating: + assert path in discover.READ_WITH_WRITE_SCOPE, path + assert set(specs) >= discover.READ_WITH_WRITE_SCOPE + + +def test_annotations_describe_the_verb(specs): + assert specs["mail list"].annotations == {"readOnlyHint": True, "openWorldHint": True} + delete = specs["calendar delete"].annotations + assert delete["readOnlyHint"] is False and delete["destructiveHint"] is True + update = specs["calendar update"].annotations + assert update["destructiveHint"] is False and update["idempotentHint"] is True + + +def test_graph_verbs_carry_the_undecorated_function_and_their_scopes(specs): + spec = specs["mail list"] + assert spec.scopes == ["Mail.Read"] + assert spec.fn is not None and "client" in spec.fn.__code__.co_varnames + + +def test_local_verbs_have_no_graph_function(specs): + """`status`, `claims` and `version` answer from the cache; they run through the CLI callback.""" + for path in ("status", "claims", "version"): + assert specs[path].fn is None + + +def test_the_capability_list_matches_the_command_groups_the_cli_registers(app): + """A new noun must be reachable: adding one without naming it here fails the build.""" + assert discover.capabilities_of(app) == set(discover.ALL) + assert set(discover.DEFAULT) <= set(discover.ALL) diff --git a/tests/test_mcp_schema.py b/tests/test_mcp_schema.py new file mode 100644 index 0000000..36ce5c1 --- /dev/null +++ b/tests/test_mcp_schema.py @@ -0,0 +1,80 @@ +"""Click parameters become JSON Schema (MCP spec §4.2).""" + +import pytest + +from mgraphctl.mcp import discover, schema + + +@pytest.fixture +def commands(app): + return discover.walk(app) + + +def built(commands, path): + return schema.build(commands[path]) + + +def test_the_schema_is_a_closed_object(commands): + doc, _ = built(commands, "mail list") + assert doc["type"] == "object" and doc["additionalProperties"] is False + + +def test_options_become_properties_with_their_help_and_flags(commands): + doc, _ = built(commands, "mail list") + folder = doc["properties"]["folder"] + assert folder["type"] == "string" + assert folder["default"] == "inbox" + assert folder["description"] == "Folder name, id, or 'all'. (--folder)" + assert doc["properties"]["unread"]["type"] == "boolean" + assert doc["properties"]["limit"]["type"] == "integer" + + +def test_a_repeatable_option_becomes_an_array(commands): + doc, _ = built(commands, "mail list") + assert doc["properties"]["to"] == { + "type": "array", + "items": {"type": "string"}, + "description": "Recipient address (repeatable). (--to)", + } + + +def test_a_trailing_underscore_is_dropped_and_mapped_back(commands): + """`--from` had to be `from_` in Python; the schema shows the flag the user knows.""" + doc, params = built(commands, "mail list") + assert "from" in doc["properties"] and "from_" not in doc["properties"] + assert params["from"] == "from_" + assert params["all"] == "all_" + + +def test_a_positional_argument_is_required(commands): + doc, _ = built(commands, "mail read") + assert doc["required"] == ["message_id"] + assert doc["properties"]["message_id"]["type"] == "string" + assert doc["properties"]["message_id"]["description"] == "Message id." + + +def test_a_command_with_no_arguments_has_no_required_key(commands): + doc, _ = built(commands, "mail list") + assert "required" not in doc + + +def test_the_json_flag_is_replaced_by_the_two_output_params(commands): + """`--json` is a switch on a stream the CLI owns; MCP has three channels for it.""" + for path in ("mail list", "me", "calendar list"): + doc, params = built(commands, path) + assert "json" not in doc["properties"] and "json_" not in params + assert doc["properties"]["output_format"]["enum"] == ["text", "json"] + assert doc["properties"]["output_format"]["default"] == "text" + assert doc["properties"]["output_file"]["type"] == "string" + + +def test_dry_run_survives_on_the_verbs_that_offer_it(commands): + doc, _ = built(commands, "mail send") + assert doc["properties"]["dry_run"]["type"] == "boolean" + + +def test_every_generated_schema_is_valid_json_schema(app): + """The SDK advertises the schema without checking it; a bad one only fails at the client.""" + jsonschema = pytest.importorskip("jsonschema") + for spec in discover.discover(app, capabilities=discover.ALL, allow_write=True): + jsonschema.Draft202012Validator.check_schema(spec.input_schema) diff --git a/tests/test_surface.py b/tests/test_surface.py index 58caedf..f2a5b2e 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -3,9 +3,8 @@ import importlib from pathlib import Path -import typer.main - import helpers +from mgraphctl.mcp.discover import walk # `@covers` fills helpers.VERBS_TESTED at import time, so this file has to pull every CLI test # module in itself — otherwise `pytest tests/test_surface.py` alone sees an empty registry. @@ -13,22 +12,6 @@ importlib.import_module(_path.stem) -def walk(app): - group = typer.main.get_group(app) - out = {} - - def rec(cmd, prefix): - for name, sub in cmd.commands.items(): - full = f"{prefix} {name}".strip() - if hasattr(sub, "commands"): - rec(sub, full) - else: - out[full] = sub - - rec(group, "") - return out - - def test_every_verb_is_covered(app): missing = sorted(v for v in walk(app) if v not in helpers.VERBS_TESTED) assert not missing, f"verbs without a @covers test: {missing}" From 86c86eb2af1712bd2320b33efeab522aa6342fb2 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 22:34:23 +0300 Subject: [PATCH 04/16] feat(mcp): result output - text, structured content, or a file link --json becomes a choice among MCP's three channels. Results past the inline limit spill to a file whatever the caller asked, so one runaway --all cannot fill the client's context. Every path a tool argument names resolves inside the server's output directory, symlinks included. --- pyproject.toml | 7 +- src/mgraphctl/mcp/output.py | 193 +++++++++++++ tests/test_mcp_output.py | 140 +++++++++ uv.lock | 553 ++++++++++++++++++++++++++++++++++++ 4 files changed, 892 insertions(+), 1 deletion(-) create mode 100644 src/mgraphctl/mcp/output.py create mode 100644 tests/test_mcp_output.py diff --git a/pyproject.toml b/pyproject.toml index cb555e1..ce5a4c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,11 @@ dependencies = [ "tomli>=2,<3; python_version < '3.11'", ] +[project.optional-dependencies] +# `mgraphctl mcp serve` only. The SDK brings pydantic, starlette, uvicorn, jsonschema, pyjwt, +# opentelemetry and a second HTTP client; the CLI itself must not carry any of that. +mcp = ["mcp>=2.1,<3"] + [project.urls] Homepage = "https://github.com/svd/mgraphctl" Source = "https://github.com/svd/mgraphctl" @@ -43,7 +48,7 @@ Issues = "https://github.com/svd/mgraphctl/issues" mgraphctl = "mgraphctl.cli:main" [dependency-groups] -dev = ["pytest>=8", "respx>=0.23", "ruff>=0.13"] +dev = ["pytest>=8", "pytest-asyncio>=1", "respx>=0.23", "ruff>=0.13", "mcp>=2.1,<3"] [build-system] requires = ["uv_build>=0.8,<1"] diff --git a/src/mgraphctl/mcp/output.py b/src/mgraphctl/mcp/output.py new file mode 100644 index 0000000..d8165ca --- /dev/null +++ b/src/mgraphctl/mcp/output.py @@ -0,0 +1,193 @@ +"""A `Result` becomes what a tool call returns (MCP spec §5). + +The CLI's `--json` is a switch on a stream the CLI owns. MCP has three channels for the same +information — a text block, `structuredContent`, and a link to a file the client can read later — +so the flag becomes a choice among them. This module makes that choice and owns every path the +server touches; it holds no MCP types, so `server.py` decides how to put it on the wire. +""" + +from __future__ import annotations + +import json +import mimetypes +import os +import secrets +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from mgraphctl import render +from mgraphctl.errors import UsageError + +# Past this, a result is written to a file whatever the caller asked for, so that one runaway +# `--all` cannot fill the client's context. +DEFAULT_MAX_INLINE_BYTES = 25_000 + +# How much of a spilled result is quoted back, so the model can see what it got. +EXCERPT_LINES = 12 + +MIME_TEXT = "text/plain" +MIME_JSON = "application/json" + + +def default_root() -> Path: + cache = os.environ.get("XDG_CACHE_HOME") + base = Path(cache) if cache else Path.home() / ".cache" + return base / "mgraphctl" / "mcp" + + +@dataclass(frozen=True) +class FileLink: + """A file this server produced, as a tool result points at it.""" + + path: Path + uri: str + name: str + mime: str + bytes: int + + +@dataclass(frozen=True) +class ToolOutput: + """What one tool call produced, before `server.py` turns it into MCP content.""" + + text: str + payload: Any | None = None + link: FileLink | None = None + + +class OutputStore: + """The one directory the server reads and writes. + + Every path a tool argument names is resolved inside it, and so is every file the server + spills. A caller cannot reach the rest of the filesystem through a tool argument, in either + direction: the server writes where the operator configured, not where the caller asks. + """ + + def __init__(self, root: Path, *, max_inline_bytes: int = DEFAULT_MAX_INLINE_BYTES) -> None: + self.root = Path(root).expanduser() + self.max_inline_bytes = max_inline_bytes + + def ensure(self) -> Path: + self.root.mkdir(parents=True, exist_ok=True, mode=0o700) + return self.root + + def resolve(self, name: str | Path) -> Path: + """A caller-supplied name as a path inside the store, or a usage error.""" + candidate = Path(name) + if candidate.is_absolute(): + raise UsageError( + "USAGE", + f"{name!r} is an absolute path; name a file relative to the server's " + "output directory instead", + ) + self.ensure() + # resolve() follows symlinks, so a link planted inside the store cannot lead out of it. + target = (self.root / candidate).resolve() + root = self.root.resolve() + if target != root and root not in target.parents: + raise UsageError("USAGE", f"{name!r} leaves the server's output directory") + return target + + def contains(self, path: Path) -> bool: + resolved = Path(path).resolve() + root = self.root.resolve() + return resolved == root or root in resolved.parents + + def write(self, name: str | Path, text: str) -> Path: + path = self.resolve(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path + + def files(self) -> list[Path]: + if not self.root.exists(): + return [] + return sorted(p for p in self.root.rglob("*") if p.is_file()) + + def read(self, uri: str) -> tuple[str, str]: + """The text of a `file://` URI this store produced, with its media type.""" + prefix = "file://" + if not uri.startswith(prefix): + raise UsageError("USAGE", f"{uri!r} is not a file:// URI") + from urllib.parse import unquote, urlparse + + path = Path(unquote(urlparse(uri).path)) + if not self.contains(path): + raise UsageError("USAGE", f"{uri!r} is outside the server's output directory") + if not path.is_file(): + raise UsageError("USAGE", f"{uri!r} does not exist") + return path.read_text(), mime_for(path) + + +def mime_for(path: Path) -> str: + guessed, _ = mimetypes.guess_type(path.name) + return guessed or MIME_TEXT + + +def link_to(store: OutputStore, path: Path) -> FileLink: + return FileLink( + path=path, + uri=path.as_uri(), + name=str(path.relative_to(store.root.resolve())) if store.contains(path) else path.name, + mime=mime_for(path), + bytes=path.stat().st_size, + ) + + +def _summary(result: render.Result, payload: Any) -> str: + """One line saying what a file holds, so the link does not have to be opened to know.""" + if isinstance(result, render.ListResult): + count = len(result.items) + noun = "item" if count == 1 else "items" + more = ", truncated" if result.truncated else "" + return f"{count} {noun}{more}" + if isinstance(payload, list): + return f"{len(payload)} items" + return "1 item" + + +def _excerpt(text: str) -> str: + lines = text.splitlines() + head = "\n".join(lines[:EXCERPT_LINES]) + return head if len(lines) <= EXCERPT_LINES else head + "\n…" + + +def build( + result: render.Result, + *, + store: OutputStore, + tool_name: str, + output_format: str = "text", + output_file: str | None = None, +) -> ToolOutput: + """Turn a verb's result into a text block, structured content, or a link to a file.""" + as_json = output_format == "json" + payload = render.to_json(result) + body = "".join([render.to_text(result), *(f"{line}\n" for line in render.notes(result))]) + document = json.dumps(payload, indent=2, ensure_ascii=False) + "\n" if as_json else body + summary = _summary(result, payload) + + # A verb that wrote a file of its own (a download, a photo) is already a link. + if isinstance(result, render.FileResult) and output_file is None: + return ToolOutput(text=body, link=link_to(store, Path(result.path))) + + if output_file is not None: + path = store.write(output_file, document) + link = link_to(store, path) + return ToolOutput(text=f"{summary} written to {link.name} ({link.uri})\n", link=link) + + if len(document.encode()) > store.max_inline_bytes: + suffix = ".json" if as_json else ".txt" + name = f"{tool_name}-{secrets.token_hex(4)}{suffix}" + path = store.write(name, document) + link = link_to(store, path) + return ToolOutput( + text=( + f"{summary}, too large to inline — written to {link.name} ({link.uri}).\n" + f"First {EXCERPT_LINES} lines:\n{_excerpt(document)}\n" + ), + link=link, + ) + + return ToolOutput(text=document, payload=payload if as_json else None) diff --git a/tests/test_mcp_output.py b/tests/test_mcp_output.py new file mode 100644 index 0000000..8ebaed4 --- /dev/null +++ b/tests/test_mcp_output.py @@ -0,0 +1,140 @@ +"""What a tool call returns: text, structured content, or a file link (MCP spec §5).""" + +import json +from pathlib import Path + +import pytest + +from mgraphctl import render +from mgraphctl.errors import UsageError +from mgraphctl.mcp import output + + +@pytest.fixture +def store(tmp_path): + return output.OutputStore(tmp_path / "out", max_inline_bytes=2000) + + +def a_list(n=1): + return render.ListResult( + items=[{"id": str(i), "subject": f"Subject {i}"} for i in range(n)], + columns=[render.Column("Id", "id"), render.Column("Subject", "subject")], + ) + + +def test_the_store_is_created_private(store): + store.ensure() + assert store.root.is_dir() + assert store.root.stat().st_mode & 0o777 == 0o700 + + +def test_a_relative_name_resolves_inside_the_store(store): + assert store.resolve("run.json") == store.root / "run.json" + assert store.resolve("nested/run.json") == store.root / "nested" / "run.json" + + +@pytest.mark.parametrize("name", ["/etc/passwd", "../escape.json", "nested/../../escape.json"]) +def test_a_path_that_leaves_the_store_is_refused(store, name): + """The server writes where the operator configured, never where the caller asks.""" + with pytest.raises(UsageError): + store.resolve(name) + + +def test_a_symlink_out_of_the_store_is_refused(store, tmp_path): + store.ensure() + (store.root / "escape").symlink_to(tmp_path) + with pytest.raises(UsageError): + store.resolve("escape/run.json") + + +def test_text_is_the_default_and_carries_no_structured_content(store): + out = output.build(a_list(), store=store, tool_name="mail_list") + assert out.text == render.to_text(a_list()) + assert out.payload is None and out.link is None + + +def test_notes_travel_with_the_text(store): + res = a_list() + res.truncated = True + out = output.build(res, store=store, tool_name="mail_list") + assert out.text.endswith("(more results available — rerun with --all)\n") + + +def test_json_returns_the_cli_document_as_structured_content(store): + out = output.build(a_list(2), store=store, tool_name="mail_list", output_format="json") + assert out.payload == render.to_json(a_list(2)) + # The spec asks for the serialized JSON alongside it, for clients that ignore the field. + assert json.loads(out.text) == out.payload + assert out.link is None + + +def test_output_file_writes_the_result_and_returns_a_link_instead_of_the_payload(store): + out = output.build( + a_list(3), store=store, tool_name="mail_list", output_format="json", output_file="run.json" + ) + assert out.link is not None + assert json.loads(out.link.path.read_text()) == render.to_json(a_list(3)) + assert out.link.uri == out.link.path.as_uri() and out.link.mime == "application/json" + assert out.payload is None + # The summary says what is in the file without repeating it. + assert "3 items" in out.text and "run.json" in out.text + + +def test_output_file_honours_the_text_format_too(store): + out = output.build(a_list(), store=store, tool_name="mail_list", output_file="run.txt") + assert out.link.mime == "text/plain" + assert out.link.path.read_text() == render.to_text(a_list()) + + +def test_a_result_past_the_inline_limit_spills_to_a_file_on_its_own(store): + """A runaway `--all` cannot flood the client, whatever the caller asked for.""" + out = output.build(a_list(200), store=store, tool_name="mail_list") + assert out.link is not None and out.link.path.exists() + assert len(out.text) < 2000 + assert "200 items" in out.text + # The head excerpt is there so the model can see what it got. + assert "Subject 0" in out.text + + +def test_a_small_result_does_not_spill(store): + out = output.build(a_list(1), store=store, tool_name="mail_list") + assert out.link is None + + +def test_spilled_names_do_not_collide(store): + first = output.build(a_list(200), store=store, tool_name="mail_list").link + second = output.build(a_list(200), store=store, tool_name="mail_list").link + assert first.path != second.path + + +def test_a_file_the_verb_itself_wrote_is_returned_as_a_link(store): + store.ensure() + target = store.root / "photo.jpg" + target.write_bytes(b"\xff\xd8\xff") + res = render.FileResult(path=target, bytes=3, meta={}, message=f"Saved {target}") + out = output.build(res, store=store, tool_name="me") + assert out.link.path == target and out.link.mime == "image/jpeg" + assert out.text == res.message + "\n" + + +def test_the_store_lists_and_reads_back_what_it_holds(store): + out = output.build( + a_list(3), store=store, tool_name="mail_list", output_format="json", output_file="run.json" + ) + assert store.files() == [out.link.path] + text, mime = store.read(out.link.uri) + assert json.loads(text)["count"] == 3 and mime == "application/json" + + +def test_reading_outside_the_store_is_refused(store, tmp_path): + outsider = tmp_path / "secret.txt" + outsider.write_text("nope") + with pytest.raises(UsageError): + store.read(Path(outsider).as_uri()) + + +def test_the_default_root_follows_xdg(monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + assert output.default_root() == tmp_path / "mgraphctl" / "mcp" + monkeypatch.delenv("XDG_CACHE_HOME") + assert output.default_root() == Path.home() / ".cache" / "mgraphctl" / "mcp" diff --git a/uv.lock b/uv.lock index 394503b..1cb0693 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.14" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", + "python_full_version < '3.11'", +] [options] exclude-newer = "2026-09-01T00:00:00Z" @@ -14,6 +19,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, ] +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "anyio" version = "4.14.2" @@ -28,6 +42,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -215,6 +247,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -302,6 +343,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -317,6 +371,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "idna" version = "3.19" @@ -392,6 +472,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "keyring" version = "25.7.0" @@ -435,6 +543,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/10/fa543d484e8b1199243fe20eedd02cc5af050edebce98a7293a5773df592/markdownify-1.2.3-py3-none-any.whl", hash = "sha256:a189a0bedfd14009030fde5f85bb6f77c56897cb839b5c25315dd7d4e3e290ba", size = 15732, upload-time = "2026-06-30T20:27:38.094Z" }, ] +[[package]] +name = "mcp" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/6e/21fb8e5d579dbe21d96ea4d5034200d46d8bdf2261053b5bd041f3c2f612/mcp-2.1.1.tar.gz", hash = "sha256:50b7ba1ebbe117008ea7bdd288234043e69c20b403d6851d19661e6d431a75ef", size = 3984589, upload-time = "2026-08-25T16:14:02.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/af/8644cc5fa26a59afd2df2e98eeb19e72926887fa4b7441aba4ff661140db/mcp-2.1.1-py3-none-any.whl", hash = "sha256:1c6c31c5d6471c58db76af3af8af67f46d11d01f0a59077d0a308cbdb3d3e915", size = 357912, upload-time = "2026-08-25T16:13:59.024Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/dd/1c4417dc0b722c23a1669032d5f044e41170fe5d4773b488a50fcce98c32/mcp_types-2.1.1.tar.gz", hash = "sha256:77dcbe48fba73cca71a673f2646a5f037a017b7a0a07ac89cec1113028890eda", size = 66674, upload-time = "2026-08-25T16:14:03.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/d0/242e63c510f4a17381f55b1549a3f94f5687a0595984febd2b6f87a687a0/mcp_types-2.1.1-py3-none-any.whl", hash = "sha256:26f9f7f03f2a5730717a5b98e2ab7eb640ac352d05a00cdc725c311864778295", size = 69656, upload-time = "2026-08-25T16:14:00.667Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -458,9 +604,16 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] +[package.optional-dependencies] +mcp = [ + { name = "mcp" }, +] + [package.dev-dependencies] dev = [ + { name = "mcp" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "respx" }, { name = "ruff" }, ] @@ -470,15 +623,19 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28,<1" }, { name = "keyring", specifier = ">=24,<26" }, { name = "markdownify", specifier = ">=1.2,<2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2.1,<3" }, { name = "msal", specifier = ">=1.38,<2" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2,<3" }, { name = "typer", specifier = ">=0.27,<1" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] +provides-extras = ["mcp"] [package.metadata.requires-dev] dev = [ + { name = "mcp", specifier = ">=2.1,<3" }, { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=1" }, { name = "respx", specifier = ">=0.23" }, { name = "ruff", specifier = ">=0.13" }, ] @@ -506,6 +663,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/ca/d768f77a27d81ed0a6884f2458f8613c31c79b2eb95defbeca2273fd0754/msal-1.38.0-py3-none-any.whl", hash = "sha256:765b9b98b6aa380ee8b8f1c75636e08863edaf0a953498955bd668650dde5d49", size = 131057, upload-time = "2026-08-24T10:22:47.485Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -533,6 +702,107 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -577,6 +847,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -586,6 +898,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -626,6 +953,171 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "ruff" version = "0.16.5" @@ -691,6 +1183,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -727,6 +1245,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.27.2" @@ -751,6 +1278,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "tzdata" version = "2026.3" @@ -769,6 +1308,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] + [[package]] name = "zipp" version = "4.1.0" From 543794f28b313caa69adb252fd21df2dd5f6ce5a Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 22:40:12 +0300 Subject: [PATCH 05/16] feat(mcp): the server - tool listing, in-process dispatch, resources Uses the low-level Server: the tool set is generated, and MCPServer can only infer a schema from a Python signature. That means the SDK neither validates arguments nor turns an exception into isError, so the server does both itself. graph_command gains __graph_fn__ because __wrapped__ cannot serve the purpose: typer wraps our wrapper, so the chain's first link is the wrapper itself. --- pyproject.toml | 2 + src/mgraphctl/cli.py | 4 + src/mgraphctl/mcp/discover.py | 9 +- src/mgraphctl/mcp/schema.py | 10 ++ src/mgraphctl/mcp/server.py | 241 ++++++++++++++++++++++++++++++++++ tests/test_mcp_server.py | 188 ++++++++++++++++++++++++++ 6 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 src/mgraphctl/mcp/server.py create mode 100644 tests/test_mcp_server.py diff --git a/pyproject.toml b/pyproject.toml index ce5a4c6..8718515 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,8 @@ known-first-party = ["mgraphctl", "helpers"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" +# The MCP SDK's client is async-only; `auto` spares every test the explicit marker. +asyncio_mode = "auto" markers = [ "real_auth: module tests auth itself; do not install the fake token provider", "scopes(list): scopes carried by the synthetic token for this test", diff --git a/src/mgraphctl/cli.py b/src/mgraphctl/cli.py index b813b24..3ebf393 100644 --- a/src/mgraphctl/cli.py +++ b/src/mgraphctl/cli.py @@ -167,6 +167,10 @@ def wrapper(ctx: typer.Context, **kwargs: Any) -> None: annotations["ctx"] = typer.Context wrapper.__annotations__ = annotations wrapper.__graph_scopes__ = list(scopes) + # The MCP server calls the verb directly to get its `Result` back instead of printed. + # `__wrapped__` cannot serve: typer wraps this wrapper again, so the chain's first link + # is the wrapper itself. `functools.wraps` copies `__dict__`, so this attribute survives. + wrapper.__graph_fn__ = fn return wrapper # type: ignore[return-value] return decorate diff --git a/src/mgraphctl/mcp/discover.py b/src/mgraphctl/mcp/discover.py index 68a5231..ee09c7f 100644 --- a/src/mgraphctl/mcp/discover.py +++ b/src/mgraphctl/mcp/discover.py @@ -113,6 +113,8 @@ class ToolSpec: annotations: dict[str, bool] # Schema property name -> the callback's Python parameter name (`from` -> `from_`). params: dict[str, str] + # Property names holding a filesystem path; the server confines each to its output directory. + path_params: frozenset[str] # The undecorated `fn(client, **kwargs)` of a `@graph_command`; None for a verb that answers # locally (`status`, `claims`, `version`) and is invoked through its Click callback instead. fn: Callable[..., Any] | None @@ -221,9 +223,10 @@ def spec_for(path: str, command: Any) -> ToolSpec: input_schema=properties, annotations=_annotations(path, mutating), params=params, - fn=getattr(callback, "__wrapped__", None) - if hasattr(callback, "__graph_scopes__") - else None, + path_params=schema.path_params(command), + # `@graph_command` stashes the undecorated `fn(client, **kwargs)`; a verb that answers + # locally has none and is invoked through its Click callback instead. + fn=getattr(callback, "__graph_fn__", None), ) diff --git a/src/mgraphctl/mcp/schema.py b/src/mgraphctl/mcp/schema.py index 5f95c93..3d577c4 100644 --- a/src/mgraphctl/mcp/schema.py +++ b/src/mgraphctl/mcp/schema.py @@ -43,6 +43,16 @@ } +# Click types whose value is a filesystem path. The server resolves every one of them inside its +# own output directory, so a tool argument cannot reach the rest of the filesystem. +PATH_TYPES = {"path", "file", "filename"} + + +def path_params(command: Any) -> frozenset[str]: + """The schema property names whose value is a path.""" + return frozenset(property_name(p) for p in command.params if p.type.name in PATH_TYPES) + + def property_name(param: Any) -> str: """`from_` -> `from`: the callback's name freed of the underscore Python forced on it.""" return param.name.rstrip("_") if param.name.endswith("_") else param.name diff --git a/src/mgraphctl/mcp/server.py b/src/mgraphctl/mcp/server.py new file mode 100644 index 0000000..ab51a6f --- /dev/null +++ b/src/mgraphctl/mcp/server.py @@ -0,0 +1,241 @@ +"""The MCP server itself: tool listing, dispatch, and the files it hands back (MCP spec §4, §5). + +Dispatch is in-process. `@graph_command` wraps the verb's real function with `functools.wraps`, so +`__wrapped__` is `fn(client, **kwargs)` returning a `render.Result` — the same object the CLI +renders. Nothing here writes to stdout, which is load-bearing: on stdio transport stdout carries +JSON-RPC. + +The tool list is built from an explicit JSON Schema per verb, so this uses the low-level `Server` +rather than `MCPServer`, whose only registration path infers a schema from a Python signature. +Two consequences of that, both handled below: the SDK does not validate arguments against the +advertised schema, and an exception raised out of a handler becomes an opaque protocol error +instead of the `isError` result a model can recover from. +""" + +from __future__ import annotations + +import contextlib +import functools +import inspect +import io +import json +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import anyio +import jsonschema +import mcp.types as types +import typer +import typer.main +from mcp.server.lowlevel import Server + +from mgraphctl import __version__, auth, config, render +from mgraphctl.cli import Globals, open_client +from mgraphctl.errors import MsgraphError, UsageError, format_error +from mgraphctl.mcp import discover, output + +log = logging.getLogger("mgraphctl.mcp") + +# `status` and `claims` read `ctx.find_root().obj`; they are invoked through a Click context +# holding the same `Globals` the CLI would have built. +LOCAL_VERBS = ("status", "claims", "version") + + +@dataclass +class Settings: + """Everything `mcp serve` decided, as the server needs it.""" + + capabilities: list[str] = field(default_factory=lambda: list(discover.DEFAULT)) + allow_write: bool = False + output_dir: Path | None = None + max_inline_bytes: int = output.DEFAULT_MAX_INLINE_BYTES + + def store(self) -> output.OutputStore: + root = self.output_dir if self.output_dir is not None else output.default_root() + return output.OutputStore(root, max_inline_bytes=self.max_inline_bytes) + + +def _tool(spec: discover.ToolSpec) -> types.Tool: + return types.Tool( + name=spec.name, + title=spec.title, + description=spec.description, + input_schema=spec.input_schema, + annotations=types.ToolAnnotations( + read_only_hint=spec.annotations.get("readOnlyHint"), + destructive_hint=spec.annotations.get("destructiveHint"), + idempotent_hint=spec.annotations.get("idempotentHint"), + open_world_hint=spec.annotations.get("openWorldHint"), + ), + ) + + +def _globals() -> Globals: + settings = config.settings() + zone = settings.tz or render.local_tz() + return Globals(debug=settings.debug, tz=zone, beta=False) + + +def _kwargs(spec: discover.ToolSpec, arguments: dict[str, Any], store: output.OutputStore) -> dict: + """Schema names to callback names, with every path argument confined to the output dir.""" + out: dict[str, Any] = {} + for name, value in arguments.items(): + if value is None: + continue + if name in spec.path_params: + value = ( + [store.resolve(v) for v in value] + if isinstance(value, list) + else store.resolve(value) + ) + out[spec.params[name]] = value + return out + + +def _run_local(spec: discover.ToolSpec, kwargs: dict[str, Any], *, as_json: bool) -> render.Result: + """A verb that answers from the local cache, run through its Click callback.""" + callback = spec.command.callback + # `status` and `claims` read the root context for the timezone and debug level; `version` + # takes no context at all. + if "ctx" in inspect.signature(callback).parameters: + group = typer.main.get_group(_app()) + root_ctx = typer.Context(group, obj=_globals()) + kwargs["ctx"] = typer.Context(spec.command, parent=root_ctx, info_name=spec.path) + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + callback(json_=as_json, **kwargs) + text = buffer.getvalue() + return render.TextResult(text=text, json_obj=json.loads(text) if as_json else None) + + +@functools.lru_cache(maxsize=1) +def _app() -> typer.Typer: + from mgraphctl.cli import build_app + + return build_app() + + +def invoke( + spec: discover.ToolSpec, + arguments: dict[str, Any], + *, + store: output.OutputStore, +) -> output.ToolOutput: + """One tool call, start to finish. Runs on a worker thread: every Graph call is synchronous.""" + try: + jsonschema.validate(arguments, spec.input_schema) + except jsonschema.ValidationError as exc: + raise UsageError("USAGE", exc.message) from exc + + arguments = dict(arguments) + output_format = arguments.pop("output_format", "text") + output_file = arguments.pop("output_file", None) + kwargs = _kwargs(spec, arguments, store) + + try: + if spec.fn is None: + result = _run_local(spec, kwargs, as_json=output_format == "json") + else: + with open_client(_globals(), spec.scopes) as client: + result = spec.fn(client, **kwargs) + finally: + auth.save_cache() + + if result is None: # a verb that only prints; nothing left to render + result = render.TextResult(text="", json_obj=None) + return output.build( + result, + store=store, + tool_name=spec.name, + output_format=output_format, + output_file=output_file, + ) + + +def _error(text: str) -> types.CallToolResult: + """A tool execution error: the spec wants these readable, so the model can correct itself.""" + return types.CallToolResult(content=[types.TextContent(text=text)], is_error=True) + + +def _resource_link(link: output.FileLink) -> types.ResourceLink: + return types.ResourceLink( + name=link.name, + uri=link.uri, + title=link.name, + description=f"{link.bytes} bytes written by mgraphctl", + mime_type=link.mime, + size=link.bytes, + ) + + +def build(settings: Settings | None = None, app: typer.Typer | None = None) -> Server: + """The server, with its tool set fixed at startup by the operator's `--capabilities`.""" + settings = settings or Settings() + app = app or _app() + store = settings.store() + specs = { + spec.name: spec + for spec in discover.discover( + app, capabilities=settings.capabilities, allow_write=settings.allow_write + ) + } + + async def list_tools(ctx: Any, params: Any) -> types.ListToolsResult: + return types.ListToolsResult(tools=[_tool(spec) for spec in specs.values()]) + + async def call_tool(ctx: Any, params: types.CallToolRequestParams) -> types.CallToolResult: + spec = specs.get(params.name) + if spec is None: + # A name that was never listed is a protocol error, not something to self-correct. + raise ValueError(f"unknown tool: {params.name}") + call = functools.partial(invoke, spec, params.arguments or {}, store=store) + try: + result = await anyio.to_thread.run_sync(call) + except MsgraphError as exc: + # format_error already states the code, the message and an actionable hint. + return _error(format_error(exc)) + except OSError as exc: + return _error(format_error(MsgraphError("IO", str(exc)))) + except Exception as exc: # noqa: BLE001 - the model gets to see what went wrong + log.debug("tool %s failed", params.name, exc_info=True) + return _error(format_error(MsgraphError("INTERNAL", f"{type(exc).__name__}: {exc}"))) + content: list[types.ContentBlock] = [types.TextContent(text=result.text)] + if result.link is not None: + content.append(_resource_link(result.link)) + return types.CallToolResult(content=content, structured_content=result.payload) + + async def list_resources(ctx: Any, params: Any) -> types.ListResourcesResult: + return types.ListResourcesResult( + resources=[ + types.Resource( + name=str(path.relative_to(store.root.resolve())), + uri=path.as_uri(), + mime_type=output.mime_for(path), + size=path.stat().st_size, + ) + for path in store.files() + ] + ) + + async def read_resource(ctx: Any, params: types.ReadResourceRequestParams) -> Any: + text, mime = store.read(str(params.uri)) + return types.ReadResourceResult( + contents=[types.TextResourceContents(uri=str(params.uri), mime_type=mime, text=text)] + ) + + return Server( + "mgraphctl", + version=__version__, + title="mgraphctl", + instructions=( + "Microsoft Graph through the mgraphctl CLI. Tools return a compact text table by " + "default; pass output_format='json' for the full payload, or output_file='name.json' " + "to have a large result written to a file and linked instead of inlined." + ), + on_list_tools=list_tools, + on_call_tool=call_tool, + on_list_resources=list_resources, + on_read_resource=read_resource, + ) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..730d348 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,188 @@ +"""The server end to end, over the SDK's in-memory transport (MCP spec §4, §5).""" + +import json + +import httpx +import pytest +from mcp import Client + +from helpers import GRAPH, graph_error, mock_graph +from mgraphctl.mcp import server + +# anyio's plugin runs a fixture's setup and teardown in the same task; pytest-asyncio does not, +# and the SDK's cancel scopes require it. +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +def settings(tmp_path): + return server.Settings( + capabilities=["core", "mail"], output_dir=tmp_path / "out", max_inline_bytes=2000 + ) + + +@pytest.fixture +async def client(app, settings): + async with Client(server.build(settings, app)) as connected: + yield connected + + +def text_of(result): + return "".join(b.text for b in result.content if b.type == "text") + + +def links_of(result): + return [b for b in result.content if b.type == "resource_link"] + + +async def test_the_tool_list_is_what_the_capabilities_asked_for(client): + listed = await client.list_tools() + names = {tool.name for tool in listed.tools} + assert "mail_list" in names and "me" in names + assert not any(name.startswith("calendar") for name in names) + # Read-only by default: no mutating verb is on the wire. + assert "mail_send" not in names and "mail_delete" not in names + + +async def test_a_tool_advertises_its_schema_and_annotations(client): + listed = await client.list_tools() + tool = next(t for t in listed.tools if t.name == "mail_list") + assert tool.title == "mail list" + assert tool.input_schema["properties"]["folder"]["default"] == "inbox" + assert tool.input_schema["additionalProperties"] is False + assert tool.annotations.read_only_hint is True + + +async def test_the_tool_list_is_ordered_the_same_every_time(client): + first = [t.name for t in (await client.list_tools()).tools] + second = [t.name for t in (await client.list_tools()).tools] + assert first == second == sorted(first) + + +async def test_a_call_reaches_graph_and_comes_back_as_a_text_table(client, graph): + mock_graph(graph, "mail/list") + result = await client.call_tool("mail_list", {}) + assert result.is_error is False + body = text_of(result) + assert "Sprint review" in body and "AAMk-msg-0001" in body + assert result.structured_content is None + + +async def test_json_returns_the_cli_document_as_structured_content(client, graph): + mock_graph(graph, "mail/list") + result = await client.call_tool("mail_list", {"output_format": "json"}) + assert result.structured_content["count"] == 2 + assert result.structured_content["items"][0]["id"] == "AAMk-msg-0001" + assert json.loads(text_of(result)) == result.structured_content + + +async def test_arguments_reach_the_verb(client, graph): + """`--unread` shapes the request; `--limit` caps what comes back (`$top` is the page size).""" + route = graph.get(f"{GRAPH}/v1.0/me/mailFolders/inbox/messages").mock( + return_value=httpx.Response(200, json={"value": [{"id": "1"}, {"id": "2"}]}) + ) + result = await client.call_tool( + "mail_list", {"limit": 1, "unread": True, "output_format": "json"} + ) + assert "isRead" in str(route.calls[0].request.url) + assert result.structured_content["count"] == 1 + + +async def test_a_flag_whose_python_name_had_to_be_escaped_still_works(client, graph): + """`--from` is `from_` in Python and `from` on the wire.""" + route = graph.get(f"{GRAPH}/v1.0/me/mailFolders/inbox/messages").mock( + return_value=httpx.Response(200, json={"value": []}) + ) + await client.call_tool("mail_list", {"from": ["ada@example.com"]}) + assert "ada%40example.com" in str(route.calls[0].request.url) + + +async def test_output_file_writes_the_result_and_links_it(client, graph, settings): + mock_graph(graph, "mail/list") + result = await client.call_tool( + "mail_list", {"output_format": "json", "output_file": "inbox.json"} + ) + link = links_of(result)[0] + assert link.uri.endswith("/inbox.json") + assert result.structured_content is None + written = (settings.store().root / "inbox.json").read_text() + assert json.loads(written)["count"] == 2 + + +async def test_a_written_file_is_readable_as_a_resource(client, graph): + mock_graph(graph, "mail/list") + result = await client.call_tool( + "mail_list", {"output_format": "json", "output_file": "inbox.json"} + ) + uri = links_of(result)[0].uri + listed = await client.list_resources() + assert uri in {str(r.uri) for r in listed.resources} + read = await client.read_resource(uri) + assert json.loads(read.contents[0].text)["count"] == 2 + + +async def test_a_file_path_that_escapes_the_output_directory_is_a_tool_error(client, graph): + mock_graph(graph, "mail/list") + result = await client.call_tool( + "mail_list", {"output_format": "json", "output_file": "../escape.json"} + ) + assert result.is_error is True + assert "output directory" in text_of(result) + + +async def test_an_invalid_argument_is_a_tool_error_the_model_can_read(client): + """The SDK advertises the schema but never applies it; the server validates.""" + result = await client.call_tool("mail_list", {"limit": "loads"}) + assert result.is_error is True + assert "loads" in text_of(result) + + +async def test_an_unknown_argument_is_refused(client): + result = await client.call_tool("mail_list", {"nonsense": 1}) + assert result.is_error is True + + +async def test_a_graph_failure_comes_back_with_its_hint(client, graph): + graph.get(f"{GRAPH}/v1.0/me/mailFolders/inbox/messages").mock( + return_value=graph_error(403, "ErrorAccessDenied") + ) + result = await client.call_tool("mail_list", {}) + assert result.is_error is True + assert "error[" in text_of(result) + + +async def test_an_unknown_tool_is_a_protocol_error(client): + """Not an `isError` result: a name that was never listed is nothing to self-correct from.""" + from mcp import MCPError + + with pytest.raises(MCPError): + await client.call_tool("mail_launch_missiles", {}) + + +async def test_a_local_verb_answers_without_touching_graph(client): + result = await client.call_tool("version", {"output_format": "json"}) + assert result.is_error is False + assert result.structured_content["version"] and result.structured_content["python"] + + +async def test_allow_write_puts_the_mutating_verbs_on_the_wire(app, tmp_path): + settings = server.Settings(capabilities=["mail"], allow_write=True, output_dir=tmp_path / "out") + async with Client(server.build(settings, app)) as client: + names = {t.name for t in (await client.list_tools()).tools} + assert "mail_send" in names + send = next(t for t in (await client.list_tools()).tools if t.name == "mail_send") + assert send.annotations.read_only_hint is False + assert "dry_run" in send.input_schema["properties"] + + +async def test_the_server_speaks_the_current_protocol_revision(app, settings): + import mcp_types.version as version + + assert version.LATEST_PROTOCOL_VERSION == "2026-07-28" + async with Client(server.build(settings, app)) as client: + assert (await client.list_tools()).tools From 15fa1e495cef7a279bc62d156b7599710c2436b3 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 22:45:39 +0300 Subject: [PATCH 06/16] feat(mcp): mcp serve and mcp tools, with the HTTP transport's guards Streamable HTTP on the 2026-07-28 shape: one POST endpoint, no session id, no GET stream, no resumable streams. Three guards stand in for the authorization spec, which cannot be met honestly here: loopback-only bind, a required bearer token, and Origin validation against DNS rebinding. The mcp noun excludes itself from its own tool surface - a tool that could restart the server with --allow-write would defeat the read-only default. --- skills/mgraphctl/reference/commands.md | 1 + skills/mgraphctl/reference/commands/mcp.md | 67 +++++++ src/mgraphctl/cli.py | 3 +- src/mgraphctl/commands/mcp_cmd.py | 204 +++++++++++++++++++++ src/mgraphctl/mcp/discover.py | 9 +- src/mgraphctl/mcp/http_app.py | 128 +++++++++++++ tests/test_cli_mcp.py | 87 +++++++++ tests/test_mcp_discover.py | 11 +- tests/test_mcp_http.py | 117 ++++++++++++ tests/test_surface.py | 13 ++ 10 files changed, 635 insertions(+), 5 deletions(-) create mode 100644 skills/mgraphctl/reference/commands/mcp.md create mode 100644 src/mgraphctl/commands/mcp_cmd.py create mode 100644 src/mgraphctl/mcp/http_app.py create mode 100644 tests/test_cli_mcp.py create mode 100644 tests/test_mcp_http.py diff --git a/skills/mgraphctl/reference/commands.md b/skills/mgraphctl/reference/commands.md index b504207..acee8c5 100644 --- a/skills/mgraphctl/reference/commands.md +++ b/skills/mgraphctl/reference/commands.md @@ -60,6 +60,7 @@ below apply to all of them. |---|---| | [`commands/top-level.md`](commands/top-level.md) | `login`, `logout`, `status`, `claims`, `me`, `version`, `api`, `search` | | [`commands/config.md`](commands/config.md) | `config path`, `config show`, `config init`, `config set`, `config unset` | +| [`commands/mcp.md`](commands/mcp.md) | `mcp tools`, `mcp serve` | | [`commands/mail.md`](commands/mail.md) | `mail list`, `mail read`, `mail attachments`, `mail send`, `mail reply`, `mail forward`, `mail folders`, `mail mark`, `mail move`, `mail delete`, `mail drafts list`, `mail drafts create`, `mail drafts send`, `mail rules list`, `mail categories` | | [`commands/mailbox.md`](commands/mailbox.md) | `mailbox settings`, `mailbox oof get`, `mailbox oof set`, `mailbox focused` | | [`commands/calendar.md`](commands/calendar.md) | `calendar list`, `calendar calendars`, `calendar get`, `calendar create`, `calendar update`, `calendar delete`, `calendar respond`, `calendar availability`, `calendar find-times` | diff --git a/skills/mgraphctl/reference/commands/mcp.md b/skills/mgraphctl/reference/commands/mcp.md new file mode 100644 index 0000000..3036216 --- /dev/null +++ b/skills/mgraphctl/reference/commands/mcp.md @@ -0,0 +1,67 @@ +# `mcp` + +Conventions, argument resolution, paging defaults, exit codes and environment variables +are in [`../commands.md`](../commands.md). + +These two verbs are for the person configuring an MCP host, not for a task. They serve the other +verbs in this reference to an MCP client; they read no Microsoft 365 data themselves. The server +needs the optional dependency: install `mgraphctl[mcp]`. + +### `mcp tools` + +| Option | Default | Meaning | +|---|---|---| +| `--capabilities NAMES` | `core,mail,calendar,people,chats` | Comma-separated command groups to expose, or `all`. | +| `--allow-write` | off | Also count the verbs that change something. | +| `--json` | off | Print JSON instead of text. | + +- **Graph:** none; it reads the command tree. +- **Scopes:** none. +- **Notes:** prints the tools `mcp serve` would expose, so the client's context cost can be seen + before the server is wired into a host. + +### `mcp serve` + +| Option | Default | Meaning | +|---|---|---| +| `--capabilities NAMES` | `core,mail,calendar,people,chats` | Command groups to expose, or `all`. Each costs the client context. | +| `--allow-write` | off | Also expose the verbs that change something. | +| `--transport stdio\|http` | `stdio` | `stdio` is the recommended transport. | +| `--host HOST` | `127.0.0.1` | Loopback only; anything else is a usage error. | +| `--port PORT` | `8765` | Port for `--transport http`. | +| `--allow-origin ORIGIN` | none | Browser origin allowed to call the HTTP endpoint (repeatable). | +| `--output-dir DIR` | `~/.cache/mgraphctl/mcp` | Where results are written, and the only place tool paths may point. | +| `--max-inline-bytes N` | `25000` | Larger results are written to a file and linked instead of inlined. | + +- **Graph:** none directly; each tool call runs the verb it names. +- **Scopes:** whatever the called verb declares, checked per call as on the command line. +- **Notes:** the only verb with no `--json` — it hands stdout to the MCP protocol and blocks. + Capabilities are command groups plus `core` (`me`, `status`, `claims`, `version`) and `api`, the + raw Graph escape hatch, which `all` deliberately omits. `login` and this noun are never exposed + as tools. + +**Tool arguments.** Every tool takes the verb's own options plus `output_format` +(`text`, the default, or `json`) and `output_file`. `--json` is not a tool argument: `text` +returns the compact table, `json` returns the full payload as structured content, and +`output_file` writes the result under `--output-dir` and returns a link to it instead of the +payload. A result past `--max-inline-bytes` is written and linked whatever the caller asked, so a +single wide fetch cannot flood the client. Every path a tool argument names resolves inside +`--output-dir`; one that escapes it is a tool error. + +**Protocol.** MCP revision `2026-07-28`, plus the earlier revisions the SDK negotiates. The +deprecated HTTP+SSE transport, protocol sessions, the standalone GET stream and resumable streams +are not implemented; `GET` and `DELETE` on the endpoint answer `405`. + +**HTTP and its limits.** The server acts as exactly one user — whoever's sign-in is cached — and +cannot tell callers apart, so any reachable port is that person's mailbox. Hence: loopback only; a +bearer token is required (`MGRAPHCTL_MCP_TOKEN`, else one is generated and printed to stderr at +startup); a request carrying an unlisted `Origin` gets `403`, which is the DNS-rebinding defence +the transport spec requires of local servers. + +That token is a local shared secret, **not** OAuth. Conforming to the MCP authorization spec would +mean validating tokens issued for this server as their audience; forwarding the caller's Entra +token instead is the token passthrough the spec prohibits, and doing it properly needs a second +Entra app registered as an API plus an on-behalf-of exchange, which requires a client secret a +locally installed CLI cannot hold. So no protected-resource metadata is advertised, and `stdio` +remains the recommended transport — where, as the spec prescribes, credentials come from the +environment. diff --git a/src/mgraphctl/cli.py b/src/mgraphctl/cli.py index 3ebf393..5858edc 100644 --- a/src/mgraphctl/cli.py +++ b/src/mgraphctl/cli.py @@ -250,11 +250,12 @@ def _root( ctx.obj = Globals(debug=level, tz=zone, beta=beta, flag_keys=frozenset(flag_keys)) _noargs_help(ctx) - from mgraphctl.commands import api, config_cmd, register_all, top + from mgraphctl.commands import api, config_cmd, mcp_cmd, register_all, top top.register(root) api.register(root) config_cmd.register(root) + mcp_cmd.register(root) register_all(root) return root diff --git a/src/mgraphctl/commands/mcp_cmd.py b/src/mgraphctl/commands/mcp_cmd.py new file mode 100644 index 0000000..8b8e726 --- /dev/null +++ b/src/mgraphctl/commands/mcp_cmd.py @@ -0,0 +1,204 @@ +"""`mgraphctl mcp`: run the MCP server, or list what it would expose. + +The design is `docs/specs/2026-09-07-mcp-server-design.md`. `tools` needs nothing beyond the CLI +itself; `serve` needs the `mgraphctl[mcp]` extra, and says so when it is missing. +""" + +import os +import sys +from pathlib import Path +from typing import Annotated + +import typer + +from mgraphctl.cli import JsonFlag, make_noun_app +from mgraphctl.errors import MsgraphError, UsageError +from mgraphctl.mcp import discover, output +from mgraphctl.render import Column, ListResult, emit + +app = make_noun_app("Serve these verbs to an MCP client, or list what would be served.") + +INSTALL_HINT = ( + "the MCP server needs the optional dependency: install `mgraphctl[mcp]` " + "(uv add 'mgraphctl[mcp]', or pip install 'mgraphctl[mcp]')" +) + +TOKEN_ENV = "MGRAPHCTL_MCP_TOKEN" + +CapabilitiesOpt = Annotated[ + str | None, + typer.Option( + "--capabilities", + help="Comma-separated command groups to expose, or 'all'. Default: " + + ",".join(discover.DEFAULT) + + ". Each one costs the client context, so name only what is needed.", + ), +] +AllowWriteFlag = Annotated[ + bool, + typer.Option("--allow-write", help="Also expose the verbs that change something."), +] +OutputDirOpt = Annotated[ + Path | None, + typer.Option( + "--output-dir", + help="Where results are written and the only place tool paths may point " + "(default: ~/.cache/mgraphctl/mcp).", + ), +] +MaxInlineOpt = Annotated[ + int, + typer.Option( + "--max-inline-bytes", + min=1024, + help="Results larger than this are written to a file and linked instead of inlined.", + ), +] + + +def _settings( + capabilities: str | None, + allow_write: bool, + output_dir: Path | None, + max_inline_bytes: int, +): + from mgraphctl.mcp.server import Settings + + return Settings( + capabilities=discover.resolve(capabilities), + allow_write=allow_write, + output_dir=output_dir, + max_inline_bytes=max_inline_bytes, + ) + + +def _require_sdk(): + try: + from mgraphctl.mcp import server + except ImportError as exc: # pragma: no cover - exercised by test_cli_mcp with a hidden module + raise UsageError("USAGE", INSTALL_HINT) from exc + return server + + +@app.command("tools") +def tools( + capabilities: CapabilitiesOpt = None, + allow_write: AllowWriteFlag = False, + json_: JsonFlag = False, +) -> None: + """List the tools the server would expose, without starting it.""" + from mgraphctl.cli import build_app + + specs = discover.discover( + build_app(), capabilities=discover.resolve(capabilities), allow_write=allow_write + ) + emit( + ListResult( + items=[ + { + "name": spec.name, + "verb": spec.path, + "capability": spec.capability, + "write": "yes" if spec.mutating else "", + "description": spec.description.splitlines()[0], + } + for spec in specs + ], + columns=[ + Column("Tool", "name"), + Column("Verb", "verb"), + Column("Capability", "capability"), + Column("Write", "write"), + ], + empty_text="No tools; check --capabilities.", + ), + json_mode=json_, + ) + + +@app.command("serve") +def serve( + capabilities: CapabilitiesOpt = None, + allow_write: AllowWriteFlag = False, + transport: Annotated[ + str, typer.Option("--transport", help="stdio (default) or http.") + ] = "stdio", + host: Annotated[ + str, typer.Option("--host", help="Loopback address for --transport http.") + ] = "127.0.0.1", + port: Annotated[int, typer.Option("--port", help="Port for --transport http.")] = 8765, + allow_origin: Annotated[ + list[str] | None, + typer.Option( + "--allow-origin", + help="Browser origin allowed to call the HTTP endpoint (repeatable). " + "None by default: any request carrying an Origin header is refused.", + ), + ] = None, + output_dir: OutputDirOpt = None, + max_inline_bytes: MaxInlineOpt = output.DEFAULT_MAX_INLINE_BYTES, +) -> None: + """Run the MCP server over stdio, or over Streamable HTTP on the loopback interface.""" + import anyio + + server = _require_sdk() + if transport not in ("stdio", "http"): + raise UsageError("USAGE", f"--transport must be stdio or http (got {transport!r})") + settings = _settings(capabilities, allow_write, output_dir, max_inline_bytes) + built = server.build(settings) + _warn_if_signed_out() + + if transport == "stdio": + # stdout carries JSON-RPC from here on; everything else the server says goes to stderr. + anyio.run(_serve_stdio, built) + return + _serve_http(built, host=host, port=port, allow_origin=list(allow_origin or [])) + + +async def _serve_stdio(built) -> None: + import mcp.server.stdio + + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await built.run(read_stream, write_stream, built.create_initialization_options()) + + +def _serve_http(built, *, host: str, port: int, allow_origin: list[str]) -> None: + import uvicorn + + from mgraphctl.mcp import http_app + + http_app.check_host(host) + token = os.environ.get(TOKEN_ENV) or http_app.new_token() + if not os.environ.get(TOKEN_ENV): + sys.stderr.write( + f"mgraphctl mcp: generated a bearer token for this run.\n" + f" url: http://{host}:{port}{http_app.MCP_PATH}\n" + f" token: {token}\n" + f"Set {TOKEN_ENV} to keep one across restarts. This is a local shared secret, " + f"not OAuth: anyone holding it acts as the signed-in user.\n" + ) + uvicorn.run( + http_app.build(built, token=token, allow_origin=allow_origin), + host=host, + port=port, + log_level="warning", + ) + + +def _warn_if_signed_out() -> None: + """Not fatal: a token may appear later, and a supervised server should not crash-loop.""" + from mgraphctl import auth + + try: + signed_in = auth.cached_access_token() is not None + except MsgraphError: + signed_in = False + if not signed_in: + sys.stderr.write( + "mgraphctl mcp: no cached sign-in; every tool call will fail until " + "`mgraphctl login` has been run.\n" + ) + + +def register(root: typer.Typer) -> None: + root.add_typer(app, name="mcp", help="The MCP server.") diff --git a/src/mgraphctl/mcp/discover.py b/src/mgraphctl/mcp/discover.py index ee09c7f..bd2f63d 100644 --- a/src/mgraphctl/mcp/discover.py +++ b/src/mgraphctl/mcp/discover.py @@ -16,8 +16,9 @@ from mgraphctl.errors import UsageError from mgraphctl.mcp import schema -# `login` and `logout` drive a browser and a credential store; `config` edits the operator's file. -# Both belong to the person running the server, not to the model talking to it. +# `login` and `logout` drive a browser and a credential store; `config` edits the operator's file; +# `mcp` runs this server. All three belong to the person running the server, not to the model +# talking to it — a tool that could restart the server with `--allow-write` would defeat the gate. EXCLUDED = { "login", "logout", @@ -26,6 +27,8 @@ "config init", "config set", "config unset", + "mcp serve", + "mcp tools", } # Verbs that change something. Kept explicit because a scope is not a reliable signal in either @@ -169,7 +172,7 @@ def rec(cmd: Any, prefix: str) -> None: def capabilities_of(app: Any) -> set[str]: """The capability names the registered command tree actually offers.""" groups = {path.split(" ")[0] for path in walk(app) if " " in path} - return groups - {"config"} | {CORE, API} + return groups - {"config", "mcp"} | {CORE, API} def resolve(value: str | None) -> list[str]: diff --git a/src/mgraphctl/mcp/http_app.py b/src/mgraphctl/mcp/http_app.py new file mode 100644 index 0000000..6426ce0 --- /dev/null +++ b/src/mgraphctl/mcp/http_app.py @@ -0,0 +1,128 @@ +"""Streamable HTTP, on the 2026-07-28 shape (MCP spec §6.2, §7). + +That revision dropped protocol sessions, the standalone GET stream and resumable streams, so none +of them appear here. The deprecated HTTP+SSE transport of 2024-11-05 is not implemented at all. + +The three guards below are what stands between a local MCP server and the network. They matter +more here than in most servers because this one has exactly one identity — whoever's token is in +the local MSAL cache — so any reachable listener is that person's mailbox. See spec §7.3 for why +the MCP authorization spec is deliberately not implemented. +""" + +from __future__ import annotations + +import secrets +from typing import Any + +from mgraphctl.errors import UsageError + +LOOPBACK = {"127.0.0.1", "::1", "localhost"} +MCP_PATH = "/mcp" + + +def check_host(host: str) -> str: + """Refuse to start off the loopback interface. The transport spec says SHOULD; this is MUST. + + A non-loopback bind hands every caller on the network the signed-in user's Microsoft 365 + account, with no way for the server to tell callers apart. + """ + if host not in LOOPBACK: + raise UsageError( + "USAGE", + f"--host {host} is not a loopback address. This server acts as one signed-in user " + "and cannot authenticate callers; bind 127.0.0.1 and reach it through a tunnel if " + "you need it from elsewhere", + ) + return host + + +def new_token() -> str: + return secrets.token_urlsafe(32) + + +class RequireBearer: + """A static bearer token, checked on every request. + + Not OAuth: the token is a local shared secret, and the server advertises no protected-resource + metadata because it is not an OAuth resource server (spec §7.3). + """ + + def __init__(self, app: Any, token: str) -> None: + self.app = app + self.token = token + + async def __call__(self, scope: dict, receive: Any, send: Any) -> None: + if scope["type"] != "http": # lifespan must pass through, or the server never starts + await self.app(scope, receive, send) + return + header = _header(scope, b"authorization") or "" + scheme, _, value = header.partition(" ") + if scheme.lower() != "bearer" or not secrets.compare_digest(value, self.token): + await _reject( + send, + 401, + "unauthorized", + headers=[(b"www-authenticate", b'Bearer realm="mgraphctl"')], + ) + return + await self.app(scope, receive, send) + + +class RequireOrigin: + """The DNS-rebinding defence the transport spec mandates for local servers. + + Without it, any page the user visits can drive this server through localhost. + """ + + def __init__(self, app: Any, allowed: list[str]) -> None: + self.app = app + self.allowed = set(allowed) + + async def __call__(self, scope: dict, receive: Any, send: Any) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + origin = _header(scope, b"origin") + # A browser always sends Origin; a CLI client never does. An unlisted one is refused. + if origin is not None and origin not in self.allowed: + await _reject(send, 403, f"origin {origin!r} is not allowed") + return + await self.app(scope, receive, send) + + +def _header(scope: dict, name: bytes) -> str | None: + for key, value in scope.get("headers", []): + if key.lower() == name: + return value.decode("latin-1") + return None + + +async def _reject(send: Any, status: int, message: str, headers: list | None = None) -> None: + body = f'{{"error":{message!r}}}'.replace("'", '"').encode() + await send( + { + "type": "http.response.start", + "status": status, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + *(headers or []), + ], + } + ) + await send({"type": "http.response.body", "body": body}) + + +def build(server: Any, *, token: str, allow_origin: list[str] | None = None) -> Any: + """The ASGI app: the SDK's Streamable HTTP endpoint behind the origin and bearer guards.""" + from mcp.server.transport_security import TransportSecuritySettings + + app = server.streamable_http_app( + streamable_http_path=MCP_PATH, + host="127.0.0.1", + # `RequireOrigin` is this server's DNS-rebinding defence, and it is the one under test. + # The SDK's own check also validates Host against a port-bearing allowlist, which rejects + # legitimate loopback requests; running both would mean two allowlists to keep in step. + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), + ) + return RequireOrigin(RequireBearer(app, token), allow_origin or []) diff --git a/tests/test_cli_mcp.py b/tests/test_cli_mcp.py new file mode 100644 index 0000000..e2f5d7b --- /dev/null +++ b/tests/test_cli_mcp.py @@ -0,0 +1,87 @@ +"""CLI tests for the `mcp` noun (MCP spec §8).""" + +import json + +import pytest + +from helpers import covers +from mgraphctl.mcp import discover + + +@covers("mcp tools") +def test_mcp_tools_lists_the_default_capabilities(invoke): + result = invoke("mcp", "tools", "--json") + assert result.exit_code == 0, result.stderr + doc = json.loads(result.stdout) + capabilities = {item["capability"] for item in doc["items"]} + assert capabilities == set(discover.DEFAULT) + names = {item["name"] for item in doc["items"]} + assert "mail_list" in names and "me" in names + + +@covers("mcp tools") +def test_mcp_tools_is_read_only_until_allow_write(invoke): + read_only = json.loads(invoke("mcp", "tools", "--json").stdout)["items"] + assert not any(item["write"] for item in read_only) + with_writes = json.loads(invoke("mcp", "tools", "--allow-write", "--json").stdout)["items"] + assert any(item["write"] for item in with_writes) + assert len(with_writes) > len(read_only) + + +@covers("mcp tools") +def test_mcp_tools_narrows_to_the_named_capabilities(invoke): + doc = json.loads(invoke("mcp", "tools", "--capabilities", "mail", "--json").stdout) + assert {item["capability"] for item in doc["items"]} == {"mail"} + # Naming a group is how the operator keeps the client's context bounded. + everything = json.loads(invoke("mcp", "tools", "--capabilities", "all", "--json").stdout) + assert everything["count"] > doc["count"] + + +@covers("mcp tools") +def test_mcp_tools_rejects_an_unknown_capability(invoke): + result = invoke("mcp", "tools", "--capabilities", "maildrop") + assert result.exit_code == 2 + assert "maildrop" in result.stderr + + +@covers("mcp tools") +def test_mcp_tools_prints_a_table_without_json(invoke): + result = invoke("mcp", "tools", "--capabilities", "core") + assert result.exit_code == 0, result.stderr + assert "Tool" in result.stdout and "me" in result.stdout + + +@covers("mcp serve") +def test_mcp_serve_rejects_an_unknown_transport(invoke): + result = invoke("mcp", "serve", "--transport", "carrier-pigeon") + assert result.exit_code == 2 + assert "stdio" in result.stderr + + +@covers("mcp serve") +@pytest.mark.parametrize("host", ["0.0.0.0", "10.0.0.5"]) +def test_mcp_serve_refuses_to_bind_off_loopback(invoke, host): + """One identity, no way to tell callers apart: a reachable port is the user's mailbox.""" + result = invoke("mcp", "serve", "--transport", "http", "--host", host) + assert result.exit_code == 2 + assert "loopback" in result.stderr + + +@covers("mcp serve") +def test_mcp_serve_says_how_to_install_the_extra_when_it_is_missing(invoke, monkeypatch): + """The SDK is an optional dependency; without it the verb must say what to install.""" + import sys + + import mgraphctl.mcp + + for name in list(sys.modules): + if name == "mcp" or name.startswith(("mcp.", "mgraphctl.mcp.server")): + monkeypatch.delitem(sys.modules, name) + # `from mgraphctl.mcp import server` would find the attribute a previous import left on the + # package and never re-import, so the attribute has to go as well. + monkeypatch.delattr(mgraphctl.mcp, "server", raising=False) + # A None entry makes `import mcp...` raise ImportError, as an uninstalled package would. + monkeypatch.setitem(sys.modules, "mcp", None) + result = invoke("mcp", "serve") + assert result.exit_code == 2 + assert "mgraphctl[mcp]" in result.stderr diff --git a/tests/test_mcp_discover.py b/tests/test_mcp_discover.py index b29f670..38a8b3f 100644 --- a/tests/test_mcp_discover.py +++ b/tests/test_mcp_discover.py @@ -40,7 +40,7 @@ def test_capability_is_the_command_group_and_top_level_verbs_are_core(specs): def test_interactive_and_host_local_verbs_are_never_exposed(app): - """`login` opens a browser; `config` edits the operator's file. Neither is the model's job.""" + """`login` opens a browser, `config` edits the operator's file, `mcp` runs this server.""" assert { "login", "logout", @@ -49,10 +49,19 @@ def test_interactive_and_host_local_verbs_are_never_exposed(app): "config init", "config set", "config unset", + "mcp serve", + "mcp tools", } == discover.EXCLUDED assert set(discover.walk(app)) >= discover.EXCLUDED +def test_the_server_cannot_restart_itself_with_more_privilege(app): + """`mcp serve --allow-write` as a tool would make the read-only default meaningless.""" + exposed = {s.path for s in discover.discover(app, capabilities=discover.ALL, allow_write=True)} + assert not any(path.startswith("mcp ") for path in exposed) + assert "mcp" not in discover.ALL + + def test_description_comes_from_the_verbs_own_help(specs): assert specs["mail list"].description.startswith("List messages") diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py new file mode 100644 index 0000000..5fa3abb --- /dev/null +++ b/tests/test_mcp_http.py @@ -0,0 +1,117 @@ +"""The HTTP transport's guards (MCP spec §6.2, §7).""" + +import httpx +import pytest + +from mgraphctl.errors import UsageError +from mgraphctl.mcp import http_app, server + +pytestmark = pytest.mark.anyio + +TOKEN = "test-token" +POST = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "1"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + }, +} +HEADERS = { + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "mcp-protocol-version": "2026-07-28", + "mcp-method": "tools/list", +} + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +def asgi(app, tmp_path): + settings = server.Settings(capabilities=["core"], output_dir=tmp_path / "out") + return http_app.build(server.build(settings, app), token=TOKEN, allow_origin=["http://ok"]) + + +@pytest.fixture +async def http(asgi): + """The app with its lifespan run, so the SDK's session manager is started.""" + inner = asgi.app.app # RequireOrigin -> RequireBearer -> Starlette + async with inner.router.lifespan_context(inner): + transport = httpx.ASGITransport(app=asgi) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + yield client + + +@pytest.mark.parametrize("host", ["0.0.0.0", "192.168.1.10", "example.com"]) +def test_a_non_loopback_bind_is_refused(host): + """One identity, no way to tell callers apart: a reachable port is the user's mailbox.""" + with pytest.raises(UsageError) as excinfo: + http_app.check_host(host) + assert "loopback" in str(excinfo.value) + + +@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost"]) +def test_loopback_is_allowed(host): + assert http_app.check_host(host) == host + + +def test_a_generated_token_is_long_enough_to_be_a_secret(): + assert len(http_app.new_token()) >= 32 + assert http_app.new_token() != http_app.new_token() + + +async def test_a_request_without_a_token_is_401(http): + response = await http.post(http_app.MCP_PATH, json=POST, headers=HEADERS) + assert response.status_code == 401 + assert response.headers["www-authenticate"].startswith("Bearer") + + +async def test_a_request_with_the_wrong_token_is_401(http): + response = await http.post( + http_app.MCP_PATH, json=POST, headers={**HEADERS, "authorization": "Bearer nope"} + ) + assert response.status_code == 401 + + +async def test_a_request_with_the_token_gets_through(http): + response = await http.post( + http_app.MCP_PATH, json=POST, headers={**HEADERS, "authorization": f"Bearer {TOKEN}"} + ) + assert response.status_code == 200 + assert "me" in response.text + + +async def test_an_unlisted_origin_is_403_before_the_token_is_even_checked(http): + """The DNS-rebinding defence: a page the user visits must not reach this server.""" + response = await http.post( + http_app.MCP_PATH, + json=POST, + headers={**HEADERS, "origin": "http://evil.example", "authorization": f"Bearer {TOKEN}"}, + ) + assert response.status_code == 403 + + +async def test_an_allowed_origin_passes(http): + response = await http.post( + http_app.MCP_PATH, + json=POST, + headers={**HEADERS, "origin": "http://ok", "authorization": f"Bearer {TOKEN}"}, + ) + assert response.status_code == 200 + + +@pytest.mark.parametrize("method", ["get", "delete"]) +async def test_the_removed_session_endpoints_are_405(http, method): + """2026-07-28 dropped the GET stream and the DELETE session teardown.""" + response = await getattr(http, method)( + http_app.MCP_PATH, headers={**HEADERS, "authorization": f"Bearer {TOKEN}"} + ) + assert response.status_code == 405 diff --git a/tests/test_surface.py b/tests/test_surface.py index f2a5b2e..32c5b07 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -17,6 +17,19 @@ def test_every_verb_is_covered(app): assert not missing, f"verbs without a @covers test: {missing}" +# `mcp serve` prints nothing and returns nothing: it hands stdout to the MCP protocol and blocks. +# It is the only verb that cannot carry --json. `mcp tools`, which does print, carries it. +NO_JSON = {"mcp serve"} + + def test_every_verb_has_json_flag(app): for verb, cmd in walk(app).items(): + if verb in NO_JSON: + continue assert any("--json" in p.opts for p in cmd.params), verb + + +def test_the_json_exemption_list_stays_minimal(app): + """A new verb must not join it quietly.""" + assert set(walk(app)) >= NO_JSON + assert len(NO_JSON) == 1 From 00332f42adc5ed33db3cad4d115713fc4eff33ae Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 22:47:33 +0300 Subject: [PATCH 07/16] docs: README and CHANGELOG for the MCP server --- CHANGELOG.md | 23 +++++++++++++++++++++ README.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed64f00..9bdc914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [Unreleased] + +- An MCP server, `mgraphctl mcp serve`, exposing the CLI's verbs as tools to any MCP client. The + tool set is generated from the command tree, so a new verb is a new tool; `mcp tools` prints + what would be exposed. Needs the optional dependency `mgraphctl[mcp]`. + - `--capabilities mail,calendar,teams` picks the command groups to expose, because all 123 verbs + at once would cost the client a great deal of context. Default: + `core,mail,calendar,people,chats`. `all` covers everything but `api`. + - Read-only unless `--allow-write`. Tools carry the MCP behaviour annotations too, but those are + hints to the host, not the gate. + - `--json` has no tool equivalent: `output_format` chooses the compact table or the full payload + as structured content, and `output_file` writes the result under `--output-dir` and links it. + Results past `--max-inline-bytes` are written and linked regardless, so one wide fetch cannot + flood the client. Written files are readable back as MCP resources. + - Protocol revision `2026-07-28`, plus the earlier revisions the SDK negotiates. No deprecated + feature is implemented: no HTTP+SSE transport, no protocol sessions, no GET stream, no + resumable streams. + - `--transport http` binds loopback only and requires a bearer token and an allowed `Origin`. + The server acts as one signed-in user and cannot authenticate callers, so it does not pretend + to be an OAuth resource server; `stdio` is the recommended transport. README explains why. +- `render.emit` splits into `to_text`, `notes` and `to_json`, so a caller that does not own stdout + can render a result. The CLI's output is unchanged. + ## [0.3.0] — 2026-09-07 - Add a Codex plugin manifest and repository marketplace. Claude Code and Codex share the diff --git a/README.md b/README.md index 4d7ad64..f374be7 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,64 @@ ${CLAUDE_PLUGIN_ROOT}/mgraphctl mail --help ${CLAUDE_PLUGIN_ROOT}/mgraphctl mail list --help ``` +## MCP server + +The same verbs are available to any MCP client, including hosts that cannot run a Claude Code +skill. It needs the optional dependency: + +```bash +uv tool install "mgraphctl[mcp]" # or: pip install "mgraphctl[mcp]" +mgraphctl mcp tools --capabilities mail,calendar # what would be exposed, before wiring it up +mgraphctl mcp serve --capabilities mail,calendar # stdio, the recommended transport +``` + +```jsonc +// claude_desktop_config.json, or any other host's MCP config +{ "mcpServers": { "mgraphctl": { + "command": "mgraphctl", + "args": ["mcp", "serve", "--capabilities", "mail,calendar,teams"] +} } } +``` + +Three things are worth knowing before using it. + +**Only what you ask for.** All 123 verbs as tools would cost the client a great deal of context, +so `--capabilities` names the command groups to expose. Omitted, it is +`core,mail,calendar,people,chats`; `all` is accepted and covers everything except `api`, the raw +Graph escape hatch, which is only ever exposed by naming it. `mcp tools` prints the resolved list. + +**Read-only until told otherwise.** Verbs that change something appear only with `--allow-write`. +Tools also carry the MCP `readOnlyHint` / `destructiveHint` annotations, so a host can prompt +accordingly — but the annotations are hints, and `--allow-write` is the gate. + +**Results stay small.** There is no `--json` tool argument. Every tool takes `output_format` +(`text`, the default compact table, or `json` for the full payload as structured content) and +`output_file`, which writes the result under `--output-dir` and returns a link instead of the +payload. Anything past `--max-inline-bytes` (25 KB) is written and linked whichever was asked, so +one wide fetch cannot flood the client. Written files are readable back as MCP resources, and every +path a tool argument names is confined to `--output-dir`. + +The protocol revision is `2026-07-28`, plus the earlier ones the SDK negotiates. Deprecated +features — the HTTP+SSE transport, protocol sessions, the standalone GET stream, resumable +streams — are not implemented. + +### A word on HTTP + +`--transport http` exists, and it binds loopback only; anything else is a usage error rather than a +warning. The reason is worth stating plainly: **the server acts as exactly one user** — whoever's +sign-in is cached — and has no way to tell callers apart, so any reachable port is that person's +mailbox, calendar and files. It therefore also requires a bearer token (`MGRAPHCTL_MCP_TOKEN`, else +one is generated and printed at startup) and refuses any request carrying an `Origin` header that +`--allow-origin` does not list, which is the DNS-rebinding defence the transport spec requires of +local servers. + +That token is a local shared secret, **not** OAuth, and the server deliberately advertises no +protected-resource metadata. Conforming to the MCP authorization spec means validating tokens +issued for this server as their audience; accepting the caller's Entra token instead is exactly the +token passthrough the spec prohibits, and doing it properly needs a second Entra app registered as +an API plus an on-behalf-of exchange — which requires a client secret that a locally installed CLI +cannot keep. Prefer `stdio`, where credentials come from the environment as the spec prescribes. + ## Where things live - Virtual environment: under `${CLAUDE_PLUGIN_DATA}` when Claude Code sets it, otherwise From dbf22c3c89ec3476e32f66a245498ee2496908f8 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 22:48:23 +0300 Subject: [PATCH 08/16] test(mcp): auto-spill, path confinement and dry-run end to end --- tests/test_mcp_server.py | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 730d348..7147eb8 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -186,3 +186,48 @@ async def test_the_server_speaks_the_current_protocol_revision(app, settings): assert version.LATEST_PROTOCOL_VERSION == "2026-07-28" async with Client(server.build(settings, app)) as client: assert (await client.list_tools()).tools + + +async def test_a_large_result_spills_to_a_file_instead_of_flooding_the_client(client, graph): + """The point of --max-inline-bytes: one wide fetch must not fill the conversation.""" + many = [{"id": f"id-{i}", "subject": f"Subject {i} " + "x" * 80} for i in range(200)] + graph.get(f"{GRAPH}/v1.0/me/mailFolders/inbox/messages").mock( + return_value=httpx.Response(200, json={"value": many}) + ) + result = await client.call_tool("mail_list", {"all": True}) + assert result.is_error is False + link = links_of(result)[0] + assert "too large to inline" in text_of(result) + assert len(text_of(result)) < 2000 + read = await client.read_resource(link.uri) + assert "Subject 199" in read.contents[0].text + + +async def test_a_verbs_own_file_lands_in_the_output_directory(client, graph, settings): + """`me --photo` names a path; it is confined like every other path argument.""" + mock_graph(graph, "top/me_photo") + result = await client.call_tool("me", {"photo": "avatar.jpg"}) + assert result.is_error is False + link = links_of(result)[0] + assert link.uri.endswith("/avatar.jpg") + assert (settings.store().root / "avatar.jpg").exists() + + +async def test_a_verbs_own_file_cannot_escape_the_output_directory(client, graph): + mock_graph(graph, "top/me_photo") + result = await client.call_tool("me", {"photo": "/tmp/escape.jpg"}) + assert result.is_error is True + assert "absolute path" in text_of(result) + + +async def test_dry_run_reaches_a_write_verb(app, tmp_path, graph): + """A mutating tool can be asked what it would send, exactly as the CLI can.""" + settings = server.Settings(capabilities=["mail"], allow_write=True, output_dir=tmp_path / "out") + async with Client(server.build(settings, app)) as client: + result = await client.call_tool( + "mail_send", + {"to": ["ada@example.com"], "subject": "Hi", "body": "text", "dry_run": True}, + ) + assert result.is_error is False + assert "POST" in text_of(result) and "sendMail" in text_of(result) + assert not graph.calls From df7a1253db2e160bcdde45b0ef637df1cdda6e25 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 23:03:33 +0300 Subject: [PATCH 09/16] fix(mcp): close the path, concurrency and CORS defects found in review Path confinement had two holes. --body-file is typed str, so classifying by Click type alone missed it on seven verbs, leaving an arbitrary local file read; it is now named explicitly and a test scans every exposed verb for path-shaped parameters that are neither confined nor declared a Graph path. And confining only the paths a caller supplies left the defaults: a verb whose destination is optional builds a relative one from the item name, which resolved against the launch directory, so read-only tools such as mail attachments wrote outside the store and then crashed on as_uri(). The server now runs from inside its output directory. redirect_stdout and the msal lazy build are process-global while tool calls run on worker threads. Both are locked: interleaved redirects crossed output or handed a thread the real stdout, which on stdio transport carries JSON-RPC. --allow-origin was dead - the unauthenticated CORS preflight hit the bearer check and the response carried no Access-Control-Allow-Origin. Preflight is now answered ahead of the token and allowed responses carry the header. Also: compare_digest on bytes, so a non-ASCII Authorization header is a 401 and not a 500; json.dumps for error bodies, so a crafted Origin cannot inject structure; IntRange bounds reach the schema; and json_ travels to the verbs that shape their result by it. --- CHANGELOG.md | 6 ++ README.md | 6 +- skills/mgraphctl/reference/commands/mcp.md | 11 ++- src/mgraphctl/auth.py | 29 ++++++-- src/mgraphctl/commands/mcp_cmd.py | 3 + src/mgraphctl/mcp/http_app.py | 61 +++++++++++++++-- src/mgraphctl/mcp/output.py | 11 ++- src/mgraphctl/mcp/schema.py | 33 +++++++-- src/mgraphctl/mcp/server.py | 32 ++++++++- tests/test_mcp_http.py | 65 ++++++++++++++++++ tests/test_mcp_schema.py | 38 +++++++++++ tests/test_mcp_server.py | 79 ++++++++++++++++++++++ 12 files changed, 352 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bdc914..3299ee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,14 @@ - `--transport http` binds loopback only and requires a bearer token and an allowed `Origin`. The server acts as one signed-in user and cannot authenticate callers, so it does not pretend to be an OAuth resource server; `stdio` is the recommended transport. README explains why. + - Every path a tool argument names is confined to `--output-dir`, including files a verb reads + (`--attach`, `--body-file`) rather than writes, and the server runs from inside that directory + so a verb's own default destination cannot land elsewhere. - `render.emit` splits into `to_text`, `notes` and `to_json`, so a caller that does not own stdout can render a result. The CLI's output is unchanged. +- `auth.app()` and `auth.save_cache()` take a lock. The CLI is single-threaded, but the MCP server + runs tool calls on worker threads, where a racing lazy build could bind an msal app to a cache + that is never written back — silently dropping a refreshed token. ## [0.3.0] — 2026-09-07 diff --git a/README.md b/README.md index f374be7..ed0a300 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,10 @@ accordingly — but the annotations are hints, and `--allow-write` is the gate. (`text`, the default compact table, or `json` for the full payload as structured content) and `output_file`, which writes the result under `--output-dir` and returns a link instead of the payload. Anything past `--max-inline-bytes` (25 KB) is written and linked whichever was asked, so -one wide fetch cannot flood the client. Written files are readable back as MCP resources, and every -path a tool argument names is confined to `--output-dir`. +one wide fetch cannot flood the client. Written files are readable back as MCP resources. Every +path a tool argument names is confined to `--output-dir` — destinations like `--output`, and +equally files a verb *reads*, such as `--attach` and `--body-file` — and the server runs from +inside that directory, so a verb's own default destination lands there too. The protocol revision is `2026-07-28`, plus the earlier ones the SDK negotiates. Deprecated features — the HTTP+SSE transport, protocol sessions, the standalone GET stream, resumable diff --git a/skills/mgraphctl/reference/commands/mcp.md b/skills/mgraphctl/reference/commands/mcp.md index 3036216..82a1179 100644 --- a/skills/mgraphctl/reference/commands/mcp.md +++ b/skills/mgraphctl/reference/commands/mcp.md @@ -29,7 +29,7 @@ needs the optional dependency: install `mgraphctl[mcp]`. | `--transport stdio\|http` | `stdio` | `stdio` is the recommended transport. | | `--host HOST` | `127.0.0.1` | Loopback only; anything else is a usage error. | | `--port PORT` | `8765` | Port for `--transport http`. | -| `--allow-origin ORIGIN` | none | Browser origin allowed to call the HTTP endpoint (repeatable). | +| `--allow-origin ORIGIN` | none | Browser origin allowed to call the HTTP endpoint (repeatable); it also gets the CORS headers a browser needs. | | `--output-dir DIR` | `~/.cache/mgraphctl/mcp` | Where results are written, and the only place tool paths may point. | | `--max-inline-bytes N` | `25000` | Larger results are written to a file and linked instead of inlined. | @@ -45,8 +45,13 @@ needs the optional dependency: install `mgraphctl[mcp]`. returns the compact table, `json` returns the full payload as structured content, and `output_file` writes the result under `--output-dir` and returns a link to it instead of the payload. A result past `--max-inline-bytes` is written and linked whatever the caller asked, so a -single wide fetch cannot flood the client. Every path a tool argument names resolves inside -`--output-dir`; one that escapes it is a tool error. +single wide fetch cannot flood the client. + +Every path a tool argument names — a destination like `--output`, and equally a file the verb +*reads*, such as `--attach` or `--body-file` — resolves inside `--output-dir`; one that escapes it +is a tool error. The server also runs from inside that directory, so a verb whose destination is +optional (`onedrive download`, `mail attachments`) writes its default there rather than wherever +the server was launched. **Protocol.** MCP revision `2026-07-28`, plus the earlier revisions the SDK negotiates. The deprecated HTTP+SSE transport, protocol sessions, the standalone GET stream and resumable streams diff --git a/src/mgraphctl/auth.py b/src/mgraphctl/auth.py index a69a680..de69a7d 100644 --- a/src/mgraphctl/auth.py +++ b/src/mgraphctl/auth.py @@ -7,6 +7,7 @@ import json import logging import sys +import threading import time from collections.abc import Iterable from pathlib import Path @@ -38,6 +39,9 @@ # The plaintext file an earlier version left behind, deleted once the keychain holds its content. _migrate_from: Path | None = None _warned = False +# Guards the lazy build of `_app`/`_cache` and every write of the cache. The CLI is +# single-threaded; the MCP server runs its tool calls on worker threads. +_LOCK = threading.Lock() # --------------------------------------------------------------------------- app and cache @@ -50,9 +54,16 @@ def _build_app( def app() -> msal.PublicClientApplication: - """The process-wide msal application, built on first use.""" + """The process-wide msal application, built on first use. + + The lock matters to the MCP server, whose tool calls run on worker threads and can reach this + concurrently: two racing builds would leave one msal app bound to a cache that `save_cache` + never serialises, silently dropping a token it had just refreshed. + """ global _app, _cache - if _app is None: + with _LOCK: + if _app is not None: + return _app s = config.settings() if s.client_id == config.CLIENT_ID_DEFAULT: # Entra would answer AADSTS700016 for the placeholder; say what is missing instead. @@ -65,7 +76,7 @@ def app() -> msal.PublicClientApplication: ) _cache = load_cache(s) _app = _build_app(s, _cache) - return _app + return _app def store_info() -> token_store.Store: @@ -122,7 +133,17 @@ def load_cache(s: config.Settings) -> msal.SerializableTokenCache: def save_cache() -> None: - """Write the cache back when msal changed it. Never raises: it runs in a `finally`.""" + """Write the cache back when msal changed it. Never raises: it runs in a `finally`. + + Serialised against `app()` and against itself: concurrent callers must not interleave a + serialize with the flag reset, or write the same store from two threads at once. + """ + global _migrate_from + with _LOCK: + _save_cache_locked() + + +def _save_cache_locked() -> None: global _migrate_from cache = _cache if cache is None or not cache.has_state_changed: diff --git a/src/mgraphctl/commands/mcp_cmd.py b/src/mgraphctl/commands/mcp_cmd.py index 8b8e726..4d6a3c4 100644 --- a/src/mgraphctl/commands/mcp_cmd.py +++ b/src/mgraphctl/commands/mcp_cmd.py @@ -146,6 +146,9 @@ def serve( raise UsageError("USAGE", f"--transport must be stdio or http (got {transport!r})") settings = _settings(capabilities, allow_write, output_dir, max_inline_bytes) built = server.build(settings) + # Before serving, so a verb's own relative default lands in the store rather than wherever + # the operator launched from. + server.enter_output_dir(settings.store()) _warn_if_signed_out() if transport == "stdio": diff --git a/src/mgraphctl/mcp/http_app.py b/src/mgraphctl/mcp/http_app.py index 6426ce0..f9d1be3 100644 --- a/src/mgraphctl/mcp/http_app.py +++ b/src/mgraphctl/mcp/http_app.py @@ -11,6 +11,7 @@ from __future__ import annotations +import json import secrets from typing import Any @@ -57,7 +58,11 @@ async def __call__(self, scope: dict, receive: Any, send: Any) -> None: return header = _header(scope, b"authorization") or "" scheme, _, value = header.partition(" ") - if scheme.lower() != "bearer" or not secrets.compare_digest(value, self.token): + # Compared as bytes: a header carrying any byte above 0x7f makes the str form of + # compare_digest raise, which would surface as a 500 rather than a clean 401. + if scheme.lower() != "bearer" or not secrets.compare_digest( + value.encode("latin-1"), self.token.encode() + ): await _reject( send, 401, @@ -72,6 +77,11 @@ class RequireOrigin: """The DNS-rebinding defence the transport spec mandates for local servers. Without it, any page the user visits can drive this server through localhost. + + An allowed origin also needs CORS, or the guard would let the request through only for the + browser to discard the answer: the preflight is unauthenticated, so it has to be answered + ahead of the bearer check, and the real response needs `Access-Control-Allow-Origin` back. + With no `--allow-origin` there is no preflight to answer and every browser is refused. """ def __init__(self, app: Any, allowed: list[str]) -> None: @@ -85,9 +95,50 @@ async def __call__(self, scope: dict, receive: Any, send: Any) -> None: origin = _header(scope, b"origin") # A browser always sends Origin; a CLI client never does. An unlisted one is refused. if origin is not None and origin not in self.allowed: - await _reject(send, 403, f"origin {origin!r} is not allowed") + await _reject(send, 403, f"origin {origin} is not allowed") return - await self.app(scope, receive, send) + if origin is None: + await self.app(scope, receive, send) + return + if scope["method"] == "OPTIONS": + await self._preflight(scope, send, origin) + return + await self.app(scope, receive, _with_cors(send, origin)) + + async def _preflight(self, scope: dict, send: Any, origin: str) -> None: + # The requested headers are echoed rather than listed: a client may mirror tool arguments + # into `Mcp-Param-*` headers, whose names are not known ahead of the request. + requested = _header(scope, b"access-control-request-headers") or "authorization" + await send( + { + "type": "http.response.start", + "status": 204, + "headers": [ + (b"access-control-allow-origin", origin.encode("latin-1")), + (b"access-control-allow-methods", b"POST, OPTIONS"), + (b"access-control-allow-headers", requested.encode("latin-1")), + (b"access-control-max-age", b"600"), + (b"vary", b"origin"), + ], + } + ) + await send({"type": "http.response.body", "body": b""}) + + +def _with_cors(send: Any, origin: str) -> Any: + """Add the allow-origin header to whatever the inner app answers.""" + + async def wrapped(message: dict) -> None: + if message["type"] == "http.response.start": + message = dict(message) + message["headers"] = [ + *message.get("headers", []), + (b"access-control-allow-origin", origin.encode("latin-1")), + (b"vary", b"origin"), + ] + await send(message) + + return wrapped def _header(scope: dict, name: bytes) -> str | None: @@ -98,7 +149,9 @@ def _header(scope: dict, name: bytes) -> str | None: async def _reject(send: Any, status: int, message: str, headers: list | None = None) -> None: - body = f'{{"error":{message!r}}}'.replace("'", '"').encode() + # json.dumps, not string formatting: `message` can quote a caller-supplied Origin, and a + # crafted one would otherwise inject structure into the response body. + body = json.dumps({"error": message}).encode() await send( { "type": "http.response.start", diff --git a/src/mgraphctl/mcp/output.py b/src/mgraphctl/mcp/output.py index d8165ca..f74b7ad 100644 --- a/src/mgraphctl/mcp/output.py +++ b/src/mgraphctl/mcp/output.py @@ -168,9 +168,16 @@ def build( document = json.dumps(payload, indent=2, ensure_ascii=False) + "\n" if as_json else body summary = _summary(result, payload) - # A verb that wrote a file of its own (a download, a photo) is already a link. + # A verb that wrote a file of its own (a download, a photo) is already a link. Its path is + # relative when the caller named no destination and the verb built a default from the item's + # own name; the server runs from inside the store, so that is where it landed. if isinstance(result, render.FileResult) and output_file is None: - return ToolOutput(text=body, link=link_to(store, Path(result.path))) + path = Path(result.path) + if not path.is_absolute(): + path = store.root / path + if not store.contains(path) or not path.is_file(): + return ToolOutput(text=body) + return ToolOutput(text=body, link=link_to(store, path)) if output_file is not None: path = store.write(output_file, document) diff --git a/src/mgraphctl/mcp/schema.py b/src/mgraphctl/mcp/schema.py index 3d577c4..c978f28 100644 --- a/src/mgraphctl/mcp/schema.py +++ b/src/mgraphctl/mcp/schema.py @@ -43,14 +43,32 @@ } -# Click types whose value is a filesystem path. The server resolves every one of them inside its -# own output directory, so a tool argument cannot reach the rest of the filesystem. +# Click types whose value is a local filesystem path. The server resolves every one of them +# inside its own output directory, so a tool argument cannot reach the rest of the filesystem. PATH_TYPES = {"path", "file", "filename"} +# Parameters that name a local file but are typed `str`, so the type alone does not betray them. +# `--body-file` reads the message body off disk; unconfined, it is an arbitrary file read. +# `test_mcp_schema.py` scans every exposed verb for path-shaped names, so a new one cannot be +# added without either landing here or being named in NOT_LOCAL_PATHS. +LOCAL_PATH_NAMES = {"body_file"} + +# Parameters whose name looks like a path but which address Graph, not this machine. +NOT_LOCAL_PATHS = { + "api": {"path"}, + "onedrive ls": {"path"}, + "onedrive mkdir": {"path"}, + "sharepoint ls": {"path"}, +} + def path_params(command: Any) -> frozenset[str]: - """The schema property names whose value is a path.""" - return frozenset(property_name(p) for p in command.params if p.type.name in PATH_TYPES) + """The schema property names whose value is a path on this machine.""" + return frozenset( + property_name(p) + for p in command.params + if p.type.name in PATH_TYPES or p.name in LOCAL_PATH_NAMES + ) def property_name(param: Any) -> str: @@ -64,6 +82,13 @@ def _type_of(param: Any) -> dict[str, Any]: choices = getattr(param.type, "choices", None) if choices: node["enum"] = list(choices) + # An IntRange's bounds are part of the contract: without them `--limit 0` validates here and + # fails at Graph instead of producing the CLI's usage error. + low, high = getattr(param.type, "min", None), getattr(param.type, "max", None) + if low is not None: + node["minimum"] = low + if high is not None: + node["maximum"] = high if param.multiple: return {"type": "array", "items": node} return node diff --git a/src/mgraphctl/mcp/server.py b/src/mgraphctl/mcp/server.py index ab51a6f..252bca9 100644 --- a/src/mgraphctl/mcp/server.py +++ b/src/mgraphctl/mcp/server.py @@ -20,6 +20,8 @@ import io import json import logging +import os +import threading from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -94,6 +96,26 @@ def _kwargs(spec: discover.ToolSpec, arguments: dict[str, Any], store: output.Ou return out +def enter_output_dir(store: output.OutputStore) -> Path: + """Run the server from inside its output directory. + + Confining the paths a caller supplies is not enough on its own. A verb whose destination is + optional builds a bare relative default from the item's own name (`onedrive download`, + `mail attachments`, `people photo`), and a relative path resolves against the process's working + directory — so an omitted argument would write wherever the operator happened to launch the + server. Moving the process into the store once, at startup, puts those defaults inside it too. + """ + root = store.ensure() + os.chdir(root) + return root + + +# `redirect_stdout` swaps a process-global. Two tool calls run on different worker threads, and +# interleaved redirects would cross their output — or hand one thread the real stdout, which on +# stdio transport is the JSON-RPC channel. +_LOCAL_LOCK = threading.Lock() + + def _run_local(spec: discover.ToolSpec, kwargs: dict[str, Any], *, as_json: bool) -> render.Result: """A verb that answers from the local cache, run through its Click callback.""" callback = spec.command.callback @@ -104,7 +126,7 @@ def _run_local(spec: discover.ToolSpec, kwargs: dict[str, Any], *, as_json: bool root_ctx = typer.Context(group, obj=_globals()) kwargs["ctx"] = typer.Context(spec.command, parent=root_ctx, info_name=spec.path) buffer = io.StringIO() - with contextlib.redirect_stdout(buffer): + with _LOCAL_LOCK, contextlib.redirect_stdout(buffer): callback(json_=as_json, **kwargs) text = buffer.getvalue() return render.TextResult(text=text, json_obj=json.loads(text) if as_json else None) @@ -134,12 +156,16 @@ def invoke( output_file = arguments.pop("output_file", None) kwargs = _kwargs(spec, arguments, store) + as_json = output_format == "json" try: if spec.fn is None: - result = _run_local(spec, kwargs, as_json=output_format == "json") + result = _run_local(spec, kwargs, as_json=as_json) else: with open_client(_globals(), spec.scopes) as client: - result = spec.fn(client, **kwargs) + # A few verbs shape the result by it — `meetings transcript` fills a field only + # for JSON, `teams`/`chats messages` keep Graph's order — so the flag has to + # travel even though the rendering happens here rather than on stdout. + result = spec.fn(client, json_=as_json, **kwargs) finally: auth.save_cache() diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 5fa3abb..7db50a3 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -115,3 +115,68 @@ async def test_the_removed_session_endpoints_are_405(http, method): http_app.MCP_PATH, headers={**HEADERS, "authorization": f"Bearer {TOKEN}"} ) assert response.status_code == 405 + + +async def test_a_malformed_authorization_header_is_401_not_a_crash(http): + """A byte above 0x7f makes the str form of compare_digest raise.""" + response = await http.post( + http_app.MCP_PATH, + json=POST, + headers={**HEADERS, "authorization": "Bearer tok\xe9n".encode("latin-1")}, + ) + assert response.status_code == 401 + + +async def test_a_crafted_origin_cannot_inject_into_the_error_body(http): + response = await http.post( + http_app.MCP_PATH, + json=POST, + headers={**HEADERS, "origin": '"},"injected":"'}, + ) + assert response.status_code == 403 + assert set(response.json()) == {"error"} + + +async def test_an_allowed_origin_can_preflight_without_a_token(http): + """The browser sends OPTIONS with no Authorization; refusing it kills the real request.""" + response = await http.request( + "OPTIONS", + http_app.MCP_PATH, + headers={ + "origin": "http://ok", + "access-control-request-method": "POST", + "access-control-request-headers": "authorization, content-type, mcp-method", + }, + ) + assert response.status_code == 204 + assert response.headers["access-control-allow-origin"] == "http://ok" + assert "authorization" in response.headers["access-control-allow-headers"] + assert "mcp-method" in response.headers["access-control-allow-headers"] + + +async def test_an_unlisted_origin_cannot_preflight(http): + response = await http.request( + "OPTIONS", + http_app.MCP_PATH, + headers={"origin": "http://evil.example", "access-control-request-method": "POST"}, + ) + assert response.status_code == 403 + + +async def test_the_real_response_carries_the_allow_origin_header(http): + """Without it the browser discards an answer the guard already allowed.""" + response = await http.post( + http_app.MCP_PATH, + json=POST, + headers={**HEADERS, "origin": "http://ok", "authorization": f"Bearer {TOKEN}"}, + ) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "http://ok" + + +async def test_a_client_without_an_origin_gets_no_cors_headers(http): + response = await http.post( + http_app.MCP_PATH, json=POST, headers={**HEADERS, "authorization": f"Bearer {TOKEN}"} + ) + assert response.status_code == 200 + assert "access-control-allow-origin" not in response.headers diff --git a/tests/test_mcp_schema.py b/tests/test_mcp_schema.py index 36ce5c1..456f203 100644 --- a/tests/test_mcp_schema.py +++ b/tests/test_mcp_schema.py @@ -78,3 +78,41 @@ def test_every_generated_schema_is_valid_json_schema(app): jsonschema = pytest.importorskip("jsonschema") for spec in discover.discover(app, capabilities=discover.ALL, allow_write=True): jsonschema.Draft202012Validator.check_schema(spec.input_schema) + + +def test_int_range_bounds_survive_into_the_schema(commands): + """Without them `--limit 0` validates here and fails at Graph instead of as a usage error.""" + doc, _ = built(commands, "calendar availability") + assert doc["properties"]["interval"]["minimum"] == 5 + assert doc["properties"]["interval"]["maximum"] == 1440 + assert built(commands, "mail list")[0]["properties"]["limit"]["minimum"] == 1 + + +def test_a_parameter_naming_a_local_file_is_confined(commands): + """`--body-file` is typed `str`, so the Click type alone does not reveal it is a path.""" + doc = discover.spec_for("mail send", commands["mail send"]) + assert "body_file" in doc.path_params + assert "attach" in doc.path_params + + +PATH_SHAPED = ("file", "path", "dir", "output", "photo", "attach") + + +def test_every_path_shaped_parameter_is_classified(app): + """A new parameter naming a local file must be confined, or be declared a Graph path.""" + unclassified = [] + for spec in discover.discover(app, capabilities=discover.ALL, allow_write=True): + exempt = schema.NOT_LOCAL_PATHS.get(spec.path, set()) + for name, node in spec.input_schema["properties"].items(): + if node.get("type") == "boolean": # a flag names nothing + continue + if name in spec.path_params or name in exempt: + continue + if name in ("output_format", "output_file"): # the server's own, not the verb's + continue + if any(token in name for token in PATH_SHAPED): + unclassified.append(f"{spec.path}:{name}") + assert not unclassified, ( + "path-shaped parameters that are neither confined nor declared a Graph path: " + f"{sorted(unclassified)}" + ) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 7147eb8..d88d625 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -231,3 +231,82 @@ async def test_dry_run_reaches_a_write_verb(app, tmp_path, graph): assert result.is_error is False assert "POST" in text_of(result) and "sendMail" in text_of(result) assert not graph.calls + + +async def test_body_file_cannot_read_outside_the_output_directory(app, tmp_path, graph): + """`--body-file` is typed `str`; unconfined it would be an arbitrary local file read.""" + secret = tmp_path / "id_rsa" + secret.write_text("PRIVATE KEY MATERIAL") + settings = server.Settings(capabilities=["mail"], allow_write=True, output_dir=tmp_path / "out") + async with Client(server.build(settings, app)) as client: + result = await client.call_tool( + "mail_send", + {"to": ["ada@example.com"], "body_file": str(secret), "dry_run": True}, + ) + assert result.is_error is True + assert "PRIVATE KEY MATERIAL" not in text_of(result) + assert not graph.calls + + +async def test_body_file_still_works_inside_the_output_directory(app, tmp_path): + settings = server.Settings(capabilities=["mail"], allow_write=True, output_dir=tmp_path / "out") + settings.store().write("body.txt", "the message body") + async with Client(server.build(settings, app)) as client: + result = await client.call_tool( + "mail_send", + {"to": ["ada@example.com"], "body_file": "body.txt", "dry_run": True}, + ) + assert result.is_error is False + assert "the message body" in text_of(result) + + +async def test_an_omitted_destination_lands_in_the_output_directory( + app, tmp_path, graph, monkeypatch +): + """`onedrive download` with no --output names the file itself, relative to the working + directory. The server moves into the store at startup so that default cannot escape it.""" + settings = server.Settings(capabilities=["onedrive"], output_dir=tmp_path / "out") + monkeypatch.chdir(tmp_path) # a hostile default would land here + mock_graph(graph, "onedrive/download") + graph.get("https://files.contoso.example/blob", params__contains={"tempauth": "abc"}).mock( + return_value=httpx.Response(200, content=b"Hello Graph!") + ) + monkeypatch.chdir(server.enter_output_dir(settings.store())) + async with Client(server.build(settings, app)) as client: + result = await client.call_tool("onedrive_download", {"ref": "01ABCDEFGHIJKLMNOPQRSTUV"}) + assert result.is_error is False, text_of(result) + assert (settings.store().root / "report.pdf").read_bytes() == b"Hello Graph!" + assert not (tmp_path / "report.pdf").exists() + assert links_of(result)[0].uri.endswith("/report.pdf") + + +async def test_output_format_reaches_a_verb_that_shapes_its_result_by_it(app, tmp_path, graph): + """`chats messages` keeps Graph's order for JSON and reverses it for the table.""" + settings = server.Settings(capabilities=["chats"], output_dir=tmp_path / "out") + page = { + "value": [ + {"id": "2", "body": {"content": "newer"}, "createdDateTime": "2026-09-02T10:00:00Z"}, + {"id": "1", "body": {"content": "older"}, "createdDateTime": "2026-09-01T10:00:00Z"}, + ] + } + async with Client(server.build(settings, app)) as client: + graph.get(f"{GRAPH}/v1.0/chats/19:abc/messages").mock( + return_value=httpx.Response(200, json=page) + ) + as_json = await client.call_tool( + "chats_messages", {"chat": "id:19:abc", "output_format": "json"} + ) + assert [item["id"] for item in as_json.structured_content["items"]] == ["2", "1"] + + +async def test_concurrent_local_verbs_do_not_cross_their_output(client): + """`redirect_stdout` swaps a process-global; interleaved calls would cross or lose output — + and on stdio transport the stdout one of them could free is the JSON-RPC channel.""" + import asyncio + + results = await asyncio.gather( + *(client.call_tool("version", {"output_format": "json"}) for _ in range(8)) + ) + for result in results: + assert result.is_error is False, text_of(result) + assert result.structured_content["version"] From 1e38d52e404d37e2fc71620c4fd71f9f6f8553ca Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 23:06:47 +0300 Subject: [PATCH 10/16] fix(mcp): binary resources, symlinked output dirs, file:// hosts resources/read on a downloaded PNG decoded it as text and raised, which a low-level handler turns into an opaque protocol error; non-text media now come back as a blob. A store reached through a symlink (--output-dir /tmp/... on macOS) made list_resources fail outright, because the files were listed from the unresolved root and named relative to the resolved one. A file:// URI naming another host is refused, and people photo basenames the UPN it builds a filename from. --- CHANGELOG.md | 3 +- src/mgraphctl/commands/people.py | 2 +- src/mgraphctl/mcp/output.py | 47 ++++++++++++++++++++++---------- src/mgraphctl/mcp/server.py | 16 ++++++++--- tests/test_mcp_output.py | 32 ++++++++++++++++++++++ tests/test_mcp_server.py | 19 +++++++++++++ 6 files changed, 99 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3299ee4..cb2d116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ to be an OAuth resource server; `stdio` is the recommended transport. README explains why. - Every path a tool argument names is confined to `--output-dir`, including files a verb reads (`--attach`, `--body-file`) rather than writes, and the server runs from inside that directory - so a verb's own default destination cannot land elsewhere. + so a verb's own default destination cannot land elsewhere. A downloaded file that is not text + reads back through `resources/read` as a blob. - `render.emit` splits into `to_text`, `notes` and `to_json`, so a caller that does not own stdout can render a result. The CLI's output is unchanged. - `auth.app()` and `auth.save_cache()` take a lock. The CLI is single-threaded, but the MCP server diff --git a/src/mgraphctl/commands/people.py b/src/mgraphctl/commands/people.py index aec7624..bc441c2 100644 --- a/src/mgraphctl/commands/people.py +++ b/src/mgraphctl/commands/people.py @@ -191,7 +191,7 @@ def photo( else: claims = auth.decode_jwt(auth.get_access_token(False)) stem = claims.get("upn") or "me" - dest = output or Path(f"{stem}.jpg") + dest = output or Path(f"{Path(stem).name}.jpg") got = people.download_photo(client, upn, size, dest) return FileResult( path=got.path, diff --git a/src/mgraphctl/mcp/output.py b/src/mgraphctl/mcp/output.py index f74b7ad..0fb7ba0 100644 --- a/src/mgraphctl/mcp/output.py +++ b/src/mgraphctl/mcp/output.py @@ -101,23 +101,29 @@ def write(self, name: str | Path, text: str) -> Path: return path def files(self) -> list[Path]: - if not self.root.exists(): + # The resolved root, so a store reached through a symlink (`--output-dir /tmp/...` on + # macOS) still yields paths that `relative_to(root.resolve())` accepts. + root = self.root.resolve() + if not root.exists(): return [] - return sorted(p for p in self.root.rglob("*") if p.is_file()) + return sorted(p for p in root.rglob("*") if p.is_file()) - def read(self, uri: str) -> tuple[str, str]: - """The text of a `file://` URI this store produced, with its media type.""" - prefix = "file://" - if not uri.startswith(prefix): - raise UsageError("USAGE", f"{uri!r} is not a file:// URI") + def read(self, uri: str) -> tuple[str | bytes, str]: + """One file this store produced, with its media type. Bytes when it is not text.""" from urllib.parse import unquote, urlparse - path = Path(unquote(urlparse(uri).path)) + parsed = urlparse(uri) + if parsed.scheme != "file": + raise UsageError("USAGE", f"{uri!r} is not a file:// URI") + if parsed.netloc not in ("", "localhost"): + raise UsageError("USAGE", f"{uri!r} names another host") + path = Path(unquote(parsed.path)) if not self.contains(path): raise UsageError("USAGE", f"{uri!r} is outside the server's output directory") if not path.is_file(): raise UsageError("USAGE", f"{uri!r} does not exist") - return path.read_text(), mime_for(path) + mime = mime_for(path) + return (path.read_text() if is_text(mime) else path.read_bytes()), mime def mime_for(path: Path) -> str: @@ -125,13 +131,26 @@ def mime_for(path: Path) -> str: return guessed or MIME_TEXT +def is_text(mime: str) -> bool: + """Whether a media type can be served as text; anything else goes back as a blob.""" + return ( + mime.startswith("text/") + or mime in {MIME_JSON, "application/xml"} + or (mime.startswith("application/") and mime.endswith(("+json", "+xml"))) + ) + + def link_to(store: OutputStore, path: Path) -> FileLink: + # Resolved on both sides: the store may be reached through a symlink, and a path built from + # the unresolved root would not be relative_to the resolved one. + resolved = Path(path).resolve() + root = store.root.resolve() return FileLink( - path=path, - uri=path.as_uri(), - name=str(path.relative_to(store.root.resolve())) if store.contains(path) else path.name, - mime=mime_for(path), - bytes=path.stat().st_size, + path=resolved, + uri=resolved.as_uri(), + name=str(resolved.relative_to(root)) if store.contains(resolved) else resolved.name, + mime=mime_for(resolved), + bytes=resolved.stat().st_size, ) diff --git a/src/mgraphctl/mcp/server.py b/src/mgraphctl/mcp/server.py index 252bca9..6d476ca 100644 --- a/src/mgraphctl/mcp/server.py +++ b/src/mgraphctl/mcp/server.py @@ -14,6 +14,7 @@ from __future__ import annotations +import base64 import contextlib import functools import inspect @@ -246,10 +247,17 @@ async def list_resources(ctx: Any, params: Any) -> types.ListResourcesResult: ) async def read_resource(ctx: Any, params: types.ReadResourceRequestParams) -> Any: - text, mime = store.read(str(params.uri)) - return types.ReadResourceResult( - contents=[types.TextResourceContents(uri=str(params.uri), mime_type=mime, text=text)] - ) + uri = str(params.uri) + data, mime = store.read(uri) + # A downloaded file need not be text: reading one as text would raise, and an exception + # out of a handler is an opaque protocol error rather than something a model can act on. + if isinstance(data, bytes): + contents = types.BlobResourceContents( + uri=uri, mime_type=mime, blob=base64.b64encode(data).decode() + ) + else: + contents = types.TextResourceContents(uri=uri, mime_type=mime, text=data) + return types.ReadResourceResult(contents=[contents]) return Server( "mgraphctl", diff --git a/tests/test_mcp_output.py b/tests/test_mcp_output.py index 8ebaed4..f7246aa 100644 --- a/tests/test_mcp_output.py +++ b/tests/test_mcp_output.py @@ -126,6 +126,38 @@ def test_the_store_lists_and_reads_back_what_it_holds(store): assert json.loads(text)["count"] == 3 and mime == "application/json" +def test_a_binary_file_reads_back_as_bytes(store): + """A downloaded image is not text; reading it as text would raise.""" + store.ensure() + (store.root / "shot.png").write_bytes(b"\x89PNG\r\n\x1a\n") + data, mime = store.read((store.root / "shot.png").as_uri()) + assert data == b"\x89PNG\r\n\x1a\n" and mime == "image/png" + assert output.is_text("application/json") and output.is_text("text/csv") + assert not output.is_text("image/png") and not output.is_text("application/pdf") + + +def test_a_store_reached_through_a_symlink_still_lists_its_files(tmp_path): + """`--output-dir /tmp/...` on macOS: /tmp is a symlink, so root != root.resolve().""" + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + link.symlink_to(real) + store = output.OutputStore(link) + store.write("run.json", "{}") + files = store.files() + assert files and all(f.is_relative_to(store.root.resolve()) for f in files) + assert output.link_to(store, files[0]).name == "run.json" + # A verb's own destination is joined to the unresolved root; linking it must still work. + assert output.link_to(store, store.root / "run.json").name == "run.json" + + +def test_a_file_uri_naming_another_host_is_refused(store): + store.ensure() + (store.root / "a.txt").write_text("x") + with pytest.raises(UsageError): + store.read("file://evil.example/" + str(store.root.resolve() / "a.txt").lstrip("/")) + + def test_reading_outside_the_store_is_refused(store, tmp_path): outsider = tmp_path / "secret.txt" outsider.write_text("nope") diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index d88d625..1f440bb 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1,5 +1,6 @@ """The server end to end, over the SDK's in-memory transport (MCP spec §4, §5).""" +import base64 import json import httpx @@ -280,6 +281,24 @@ async def test_an_omitted_destination_lands_in_the_output_directory( assert links_of(result)[0].uri.endswith("/report.pdf") +async def test_a_downloaded_binary_reads_back_as_a_blob(app, tmp_path, graph, monkeypatch): + """`resources/read` on a PNG must not try to decode it as text.""" + settings = server.Settings(capabilities=["onedrive"], output_dir=tmp_path / "out") + monkeypatch.chdir(server.enter_output_dir(settings.store())) + mock_graph(graph, "onedrive/download") + graph.get("https://files.contoso.example/blob", params__contains={"tempauth": "abc"}).mock( + return_value=httpx.Response(200, content=b"\x89PNG\r\n\x1a\n") + ) + async with Client(server.build(settings, app)) as client: + result = await client.call_tool( + "onedrive_download", {"ref": "01ABCDEFGHIJKLMNOPQRSTUV", "output": "shot.png"} + ) + read = await client.read_resource(links_of(result)[0].uri) + contents = read.contents[0] + assert contents.mime_type == "image/png" + assert base64.b64decode(contents.blob) == b"\x89PNG\r\n\x1a\n" + + async def test_output_format_reaches_a_verb_that_shapes_its_result_by_it(app, tmp_path, graph): """`chats messages` keeps Graph's order for JSON and reverses it for the table.""" settings = server.Settings(capabilities=["chats"], output_dir=tmp_path / "out") From 4220d8c1dd7ed4f5e292df8838bddf44e8810c6b Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 23:31:26 +0300 Subject: [PATCH 11/16] fix(mcp): confine the api --body @FILE form to the output directory api --body is typed str because most values are the JSON body itself; only a leading @ makes the rest a path, so classifying by Click type missed it and an enabled api tool could read any local file and return it in its dry-run plan. The @ form now resolves through the output store. A test greps the command modules for the convention and fails if another verb adopts it without being declared, which is how this one escaped. --- skills/mgraphctl/reference/commands/mcp.md | 3 ++- src/mgraphctl/mcp/discover.py | 3 +++ src/mgraphctl/mcp/schema.py | 6 +++++ src/mgraphctl/mcp/server.py | 4 +++ tests/test_mcp_schema.py | 26 +++++++++++++++++++ tests/test_mcp_server.py | 30 ++++++++++++++++++++++ 6 files changed, 71 insertions(+), 1 deletion(-) diff --git a/skills/mgraphctl/reference/commands/mcp.md b/skills/mgraphctl/reference/commands/mcp.md index 82a1179..bfc598d 100644 --- a/skills/mgraphctl/reference/commands/mcp.md +++ b/skills/mgraphctl/reference/commands/mcp.md @@ -49,7 +49,8 @@ single wide fetch cannot flood the client. Every path a tool argument names — a destination like `--output`, and equally a file the verb *reads*, such as `--attach` or `--body-file` — resolves inside `--output-dir`; one that escapes it -is a tool error. The server also runs from inside that directory, so a verb whose destination is +is a tool error - including `api --body @FILE`, whose leading `@` is what makes the rest of the +value a path. The server also runs from inside that directory, so a verb whose destination is optional (`onedrive download`, `mail attachments`) writes its default there rather than wherever the server was launched. diff --git a/src/mgraphctl/mcp/discover.py b/src/mgraphctl/mcp/discover.py index bd2f63d..9f480d3 100644 --- a/src/mgraphctl/mcp/discover.py +++ b/src/mgraphctl/mcp/discover.py @@ -118,6 +118,8 @@ class ToolSpec: params: dict[str, str] # Property names holding a filesystem path; the server confines each to its output directory. path_params: frozenset[str] + # Property names holding a path only behind a leading `@` (`api --body @FILE`). + at_file_params: frozenset[str] # The undecorated `fn(client, **kwargs)` of a `@graph_command`; None for a verb that answers # locally (`status`, `claims`, `version`) and is invoked through its Click callback instead. fn: Callable[..., Any] | None @@ -227,6 +229,7 @@ def spec_for(path: str, command: Any) -> ToolSpec: annotations=_annotations(path, mutating), params=params, path_params=schema.path_params(command), + at_file_params=frozenset(schema.AT_FILE_PARAMS.get(path, ())), # `@graph_command` stashes the undecorated `fn(client, **kwargs)`; a verb that answers # locally has none and is invoked through its Click callback instead. fn=getattr(callback, "__graph_fn__", None), diff --git a/src/mgraphctl/mcp/schema.py b/src/mgraphctl/mcp/schema.py index c978f28..83fd8b5 100644 --- a/src/mgraphctl/mcp/schema.py +++ b/src/mgraphctl/mcp/schema.py @@ -53,6 +53,12 @@ # added without either landing here or being named in NOT_LOCAL_PATHS. LOCAL_PATH_NAMES = {"body_file"} +# Parameters whose value names a local file only when it starts with `@`: `api --body @FILE` +# reads the request body off disk, while any other value is the body itself. The server resolves +# what follows the `@` inside its output directory. Keyed by verb, since the same parameter name +# on another verb need not carry the convention. +AT_FILE_PARAMS = {"api": {"body"}} + # Parameters whose name looks like a path but which address Graph, not this machine. NOT_LOCAL_PATHS = { "api": {"path"}, diff --git a/src/mgraphctl/mcp/server.py b/src/mgraphctl/mcp/server.py index 6d476ca..a2886d7 100644 --- a/src/mgraphctl/mcp/server.py +++ b/src/mgraphctl/mcp/server.py @@ -93,6 +93,10 @@ def _kwargs(spec: discover.ToolSpec, arguments: dict[str, Any], store: output.Ou if isinstance(value, list) else store.resolve(value) ) + elif name in spec.at_file_params and isinstance(value, str) and value.startswith("@"): + # `api --body @FILE`: the value is the body itself unless it opens with `@`, and only + # then does it name a file the verb will read. + value = f"@{store.resolve(value[1:])}" out[spec.params[name]] = value return out diff --git a/tests/test_mcp_schema.py b/tests/test_mcp_schema.py index 456f203..54dfa57 100644 --- a/tests/test_mcp_schema.py +++ b/tests/test_mcp_schema.py @@ -95,6 +95,32 @@ def test_a_parameter_naming_a_local_file_is_confined(commands): assert "attach" in doc.path_params +def test_every_at_file_convention_in_the_cli_is_declared(): + """A parameter that reads a file behind a leading `@` names a path the type does not reveal. + + `api --body @FILE` was such a parameter and escaped confinement; this fails if another + appears without being declared in AT_FILE_PARAMS. + """ + import re + from pathlib import Path + + commands_dir = Path(__file__).resolve().parents[1] / "src" / "mgraphctl" / "commands" + declared = {verb.split(" ")[-1] for verb in schema.AT_FILE_PARAMS} + found = { + path.stem.replace("_cmd", "") + for path in commands_dir.glob("*.py") + if re.search(r'startswith\(\s*[\'"]@[\'"]\s*\)', path.read_text()) + } + assert found <= declared, f"modules using the @FILE convention but not declared: {found}" + + +def test_the_api_body_is_declared_as_an_at_file_parameter(app): + spec = next( + s for s in discover.discover(app, capabilities=["api"], allow_write=True) if s.path == "api" + ) + assert spec.at_file_params == {"body"} + + PATH_SHAPED = ("file", "path", "dir", "output", "photo", "attach") diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 1f440bb..0b48201 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -281,6 +281,36 @@ async def test_an_omitted_destination_lands_in_the_output_directory( assert links_of(result)[0].uri.endswith("/report.pdf") +async def test_the_api_body_at_file_form_cannot_read_outside_the_output_directory( + app, tmp_path, graph +): + """`api --body @FILE` reads a local file, but `body` is typed `str` and so escapes the + Path-based confinement unless the `@` form is resolved through the store.""" + secret = tmp_path / "secret.json" + secret.write_text('{"token": "SECRET-VALUE"}') + settings = server.Settings(capabilities=["api"], allow_write=True, output_dir=tmp_path / "out") + async with Client(server.build(settings, app)) as client: + result = await client.call_tool( + "api", + {"method": "POST", "path": "/me/sendMail", "body": f"@{secret}", "dry_run": True}, + ) + assert result.is_error is True + assert "SECRET-VALUE" not in text_of(result) + assert not graph.calls + + +async def test_the_api_body_at_file_form_still_works_inside_the_output_directory(app, tmp_path): + settings = server.Settings(capabilities=["api"], allow_write=True, output_dir=tmp_path / "out") + settings.store().write("payload.json", '{"comment": "hello"}') + async with Client(server.build(settings, app)) as client: + result = await client.call_tool( + "api", + {"method": "POST", "path": "/me/events", "body": "@payload.json", "dry_run": True}, + ) + assert result.is_error is False, text_of(result) + assert "hello" in text_of(result) + + async def test_a_downloaded_binary_reads_back_as_a_blob(app, tmp_path, graph, monkeypatch): """`resources/read` on a PNG must not try to decode it as text.""" settings = server.Settings(capabilities=["onedrive"], output_dir=tmp_path / "out") From 2a6cbbe2d942cc60fe9c21c67e89bfaab1a39261 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 23:49:38 +0300 Subject: [PATCH 12/16] feat(mcp): carry the root flags into the server `mcp serve` built its own `Globals` from config, so `-d`/`-dd` set the log level but never reached the client a tool call opens: the flag turned on tracebacks and nothing else, while `MGRAPHCTL_DEBUG` turned on request logging. The root callback already folds config into the `Globals` it puts on the context, so the server takes that instead. `--tz` and `--beta` now apply to tool calls for the same reason. --- CHANGELOG.md | 2 ++ skills/mgraphctl/reference/commands/mcp.md | 4 +++ src/mgraphctl/commands/mcp_cmd.py | 7 +++- src/mgraphctl/mcp/server.py | 32 +++++++++++------ tests/test_cli_mcp.py | 42 ++++++++++++++++++++++ tests/test_mcp_server.py | 18 ++++++++++ 6 files changed, 93 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2d116..19a9a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ (`--attach`, `--body-file`) rather than writes, and the server runs from inside that directory so a verb's own default destination cannot land elsewhere. A downloaded file that is not text reads back through `resources/read` as a blob. + - The root flags carry into the server: `mgraphctl -dd mcp serve` logs every tool call's Graph + request to stderr, and `--tz` / `--beta` apply the same way they do on the command line. - `render.emit` splits into `to_text`, `notes` and `to_json`, so a caller that does not own stdout can render a result. The CLI's output is unchanged. - `auth.app()` and `auth.save_cache()` take a lock. The CLI is single-threaded, but the MCP server diff --git a/skills/mgraphctl/reference/commands/mcp.md b/skills/mgraphctl/reference/commands/mcp.md index bfc598d..5a01381 100644 --- a/skills/mgraphctl/reference/commands/mcp.md +++ b/skills/mgraphctl/reference/commands/mcp.md @@ -36,6 +36,10 @@ needs the optional dependency: install `mgraphctl[mcp]`. - **Graph:** none directly; each tool call runs the verb it names. - **Scopes:** whatever the called verb declares, checked per call as on the command line. - **Notes:** the only verb with no `--json` — it hands stdout to the MCP protocol and blocks. + It has no verbose flag of its own: the root `-d` / `-dd` applies to every tool call, logging + each Graph request (and at `-dd` its redacted body) to stderr, which is the channel MCP leaves + free. `MGRAPHCTL_DEBUG` or `debug` in the config file does the same for a server the host + launches without arguments of yours. Capabilities are command groups plus `core` (`me`, `status`, `claims`, `version`) and `api`, the raw Graph escape hatch, which `all` deliberately omits. `login` and this noun are never exposed as tools. diff --git a/src/mgraphctl/commands/mcp_cmd.py b/src/mgraphctl/commands/mcp_cmd.py index 4d6a3c4..aa1e984 100644 --- a/src/mgraphctl/commands/mcp_cmd.py +++ b/src/mgraphctl/commands/mcp_cmd.py @@ -57,6 +57,7 @@ def _settings( + ctx: typer.Context, capabilities: str | None, allow_write: bool, output_dir: Path | None, @@ -69,6 +70,9 @@ def _settings( allow_write=allow_write, output_dir=output_dir, max_inline_bytes=max_inline_bytes, + # The root flags apply to every tool call: `-dd` logs each request to stderr, which on + # stdio is the only channel free to carry it. + env=ctx.find_root().obj, ) @@ -118,6 +122,7 @@ def tools( @app.command("serve") def serve( + ctx: typer.Context, capabilities: CapabilitiesOpt = None, allow_write: AllowWriteFlag = False, transport: Annotated[ @@ -144,7 +149,7 @@ def serve( server = _require_sdk() if transport not in ("stdio", "http"): raise UsageError("USAGE", f"--transport must be stdio or http (got {transport!r})") - settings = _settings(capabilities, allow_write, output_dir, max_inline_bytes) + settings = _settings(ctx, capabilities, allow_write, output_dir, max_inline_bytes) built = server.build(settings) # Before serving, so a verb's own relative default lands in the store rather than wherever # the operator launched from. diff --git a/src/mgraphctl/mcp/server.py b/src/mgraphctl/mcp/server.py index a2886d7..b357f41 100644 --- a/src/mgraphctl/mcp/server.py +++ b/src/mgraphctl/mcp/server.py @@ -46,6 +46,13 @@ LOCAL_VERBS = ("status", "claims", "version") +def _globals() -> Globals: + """The root callback's `Globals` rebuilt from config alone, for a server started without it.""" + settings = config.settings() + zone = settings.tz or render.local_tz() + return Globals(debug=settings.debug, tz=zone, beta=False) + + @dataclass class Settings: """Everything `mcp serve` decided, as the server needs it.""" @@ -54,6 +61,10 @@ class Settings: allow_write: bool = False output_dir: Path | None = None max_inline_bytes: int = output.DEFAULT_MAX_INLINE_BYTES + # The root callback's own decisions, so `mgraphctl -dd mcp serve` logs requests as it does on + # the command line. Rebuilt from config only when the server is started without a Click + # context; the root callback already folds the config values into what it hands over. + env: Globals = field(default_factory=_globals) def store(self) -> output.OutputStore: root = self.output_dir if self.output_dir is not None else output.default_root() @@ -75,12 +86,6 @@ def _tool(spec: discover.ToolSpec) -> types.Tool: ) -def _globals() -> Globals: - settings = config.settings() - zone = settings.tz or render.local_tz() - return Globals(debug=settings.debug, tz=zone, beta=False) - - def _kwargs(spec: discover.ToolSpec, arguments: dict[str, Any], store: output.OutputStore) -> dict: """Schema names to callback names, with every path argument confined to the output dir.""" out: dict[str, Any] = {} @@ -121,14 +126,16 @@ def enter_output_dir(store: output.OutputStore) -> Path: _LOCAL_LOCK = threading.Lock() -def _run_local(spec: discover.ToolSpec, kwargs: dict[str, Any], *, as_json: bool) -> render.Result: +def _run_local( + spec: discover.ToolSpec, kwargs: dict[str, Any], *, as_json: bool, env: Globals +) -> render.Result: """A verb that answers from the local cache, run through its Click callback.""" callback = spec.command.callback # `status` and `claims` read the root context for the timezone and debug level; `version` # takes no context at all. if "ctx" in inspect.signature(callback).parameters: group = typer.main.get_group(_app()) - root_ctx = typer.Context(group, obj=_globals()) + root_ctx = typer.Context(group, obj=env) kwargs["ctx"] = typer.Context(spec.command, parent=root_ctx, info_name=spec.path) buffer = io.StringIO() with _LOCAL_LOCK, contextlib.redirect_stdout(buffer): @@ -149,6 +156,7 @@ def invoke( arguments: dict[str, Any], *, store: output.OutputStore, + env: Globals, ) -> output.ToolOutput: """One tool call, start to finish. Runs on a worker thread: every Graph call is synchronous.""" try: @@ -164,9 +172,9 @@ def invoke( as_json = output_format == "json" try: if spec.fn is None: - result = _run_local(spec, kwargs, as_json=as_json) + result = _run_local(spec, kwargs, as_json=as_json, env=env) else: - with open_client(_globals(), spec.scopes) as client: + with open_client(env, spec.scopes) as client: # A few verbs shape the result by it — `meetings transcript` fills a field only # for JSON, `teams`/`chats messages` keep Graph's order — so the flag has to # travel even though the rendering happens here rather than on stdout. @@ -221,7 +229,9 @@ async def call_tool(ctx: Any, params: types.CallToolRequestParams) -> types.Call if spec is None: # A name that was never listed is a protocol error, not something to self-correct. raise ValueError(f"unknown tool: {params.name}") - call = functools.partial(invoke, spec, params.arguments or {}, store=store) + call = functools.partial( + invoke, spec, params.arguments or {}, store=store, env=settings.env + ) try: result = await anyio.to_thread.run_sync(call) except MsgraphError as exc: diff --git a/tests/test_cli_mcp.py b/tests/test_cli_mcp.py index e2f5d7b..b670ba7 100644 --- a/tests/test_cli_mcp.py +++ b/tests/test_cli_mcp.py @@ -1,6 +1,7 @@ """CLI tests for the `mcp` noun (MCP spec §8).""" import json +from pathlib import Path import pytest @@ -67,6 +68,47 @@ def test_mcp_serve_refuses_to_bind_off_loopback(invoke, host): assert "loopback" in result.stderr +def _capture_settings(monkeypatch): + """Start `mcp serve` far enough to build its settings, then stop before it blocks.""" + from mgraphctl.commands import mcp_cmd + from mgraphctl.mcp import server + + # `serve` moves the process into its output directory; register the undo. + monkeypatch.chdir(Path.cwd()) + captured = {} + real_build = server.build + + def build(settings, app=None): + captured["settings"] = settings + return real_build(settings, app) + + async def no_serve(built): + return None + + monkeypatch.setattr(server, "build", build) + monkeypatch.setattr(mcp_cmd, "_serve_stdio", no_serve) + return captured + + +@covers("mcp serve") +def test_mcp_serve_carries_the_root_flags_into_every_tool_call(invoke, monkeypatch, tmp_path): + """`-dd` has to reach the client the server opens, or request logging is silently dead.""" + captured = _capture_settings(monkeypatch) + result = invoke("-dd", "--tz", "Europe/Warsaw", "mcp", "serve", "--output-dir", str(tmp_path)) + assert result.exit_code == 0, result.stderr + assert captured["settings"].env.debug == 2 + assert captured["settings"].env.tz == "Europe/Warsaw" + + +@covers("mcp serve") +def test_mcp_serve_falls_back_to_the_config_when_no_flag_is_given(invoke, monkeypatch, tmp_path): + """The root callback folds config into `Globals`, so the env var still reaches the server.""" + monkeypatch.setenv("MGRAPHCTL_DEBUG", "1") + captured = _capture_settings(monkeypatch) + assert invoke("mcp", "serve", "--output-dir", str(tmp_path)).exit_code == 0 + assert captured["settings"].env.debug == 1 + + @covers("mcp serve") def test_mcp_serve_says_how_to_install_the_extra_when_it_is_missing(invoke, monkeypatch): """The SDK is an optional dependency; without it the verb must say what to install.""" diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 0b48201..275a441 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -74,6 +74,24 @@ async def test_a_call_reaches_graph_and_comes_back_as_a_text_table(client, graph assert result.structured_content is None +async def test_the_root_debug_level_reaches_the_client_a_call_opens(app, tmp_path, graph, caplog): + """`-dd` is the server's verbose mode; without it plumbed through, nothing logs the request.""" + from mgraphctl.cli import Globals + + settings = server.Settings( + capabilities=["mail"], + output_dir=tmp_path / "out", + env=Globals(debug=2, tz="UTC", beta=False), + ) + mock_graph(graph, "mail/list") + with caplog.at_level("DEBUG", logger="mgraphctl.http"): + async with Client(server.build(settings, app)) as connected: + await connected.call_tool("mail_list", {}) + logged = "\n".join(record.getMessage() for record in caplog.records) + assert "me/mailFolders/inbox/messages" in logged + assert "body:" in logged # -dd, not -d + + async def test_json_returns_the_cli_document_as_structured_content(client, graph): mock_graph(graph, "mail/list") result = await client.call_tool("mail_list", {"output_format": "json"}) From 7a3b729167a8c34ebea237d6ac85d98460f83a3b Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 7 Sep 2026 23:57:47 +0300 Subject: [PATCH 13/16] fix(mcp): have the shim request the mcp extra for its own noun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shim runs `uv run --frozen --no-dev`, which installs neither the optional extra nor the dev group, so `./mgraphctl mcp serve` ended at the install hint — advice that cannot help someone running the plugin from a clone rather than a released package. The shim now scans past the root flags for the noun and adds `--extra mcp` when it is `mcp`. Every other verb's environment is untouched, so the CLI's normal startup stays as light as it was. --- CHANGELOG.md | 3 ++ README.md | 3 +- mgraphctl | 19 ++++++++++- skills/mgraphctl/reference/commands/mcp.md | 3 +- tests/test_cli_mcp.py | 39 ++++++++++++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19a9a04..2237b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ (`--attach`, `--body-file`) rather than writes, and the server runs from inside that directory so a verb's own default destination cannot land elsewhere. A downloaded file that is not text reads back through `resources/read` as a blob. + - The `mgraphctl` shim adds `--extra mcp` when the noun being run is `mcp`, so the plugin can + start the server without the operator installing anything; every other verb's environment is + unchanged. - The root flags carry into the server: `mgraphctl -dd mcp serve` logs every tool call's Graph request to stderr, and `--tz` / `--beta` apply the same way they do on the command line. - `render.emit` splits into `to_text`, `notes` and `to_json`, so a caller that does not own stdout diff --git a/README.md b/README.md index ed0a300..2385dab 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,8 @@ ${CLAUDE_PLUGIN_ROOT}/mgraphctl mail list --help ## MCP server The same verbs are available to any MCP client, including hosts that cannot run a Claude Code -skill. It needs the optional dependency: +skill. Installed as a package it needs the optional dependency; run through the plugin's own +`mgraphctl` shim it does not, since the shim requests that extra for the `mcp` noun itself: ```bash uv tool install "mgraphctl[mcp]" # or: pip install "mgraphctl[mcp]" diff --git a/mgraphctl b/mgraphctl index 348067c..8dcbff1 100755 --- a/mgraphctl +++ b/mgraphctl @@ -9,4 +9,21 @@ export UV_PROJECT_ENVIRONMENT="${UV_PROJECT_ENVIRONMENT:-${CLAUDE_PLUGIN_DATA:-$ export UV_NO_PROGRESS=1 export PYTHONDONTWRITEBYTECODE=1 [ -d "$UV_PROJECT_ENVIRONMENT" ] || echo "mgraphctl: first run, installing Python environment (10-40 s)..." >&2 -exec uv run --project "$here" --frozen --no-dev mgraphctl "$@" + +# The MCP SDK is an optional extra: it brings starlette, uvicorn and a second HTTP client, which +# every other verb would pay for. Ask for it only when the `mcp` noun is the one being run. +extra=() +skip=0 +for arg in "$@"; do + if [ "$skip" = 1 ]; then skip=0; continue; fi + case "$arg" in + --tz) skip=1 ;; # the one root option that takes a separate value + -*) ;; # any other root flag: keep looking for the noun + mcp) extra=(--extra mcp); break ;; + *) break ;; # a different noun; the extra is not needed + esac +done + +# `${extra[@]+...}`: under `set -u`, bash 3.2 (the macOS system bash) treats an empty array as +# unbound. +exec uv run --project "$here" --frozen --no-dev ${extra[@]+"${extra[@]}"} mgraphctl "$@" diff --git a/skills/mgraphctl/reference/commands/mcp.md b/skills/mgraphctl/reference/commands/mcp.md index 5a01381..5942bee 100644 --- a/skills/mgraphctl/reference/commands/mcp.md +++ b/skills/mgraphctl/reference/commands/mcp.md @@ -5,7 +5,8 @@ are in [`../commands.md`](../commands.md). These two verbs are for the person configuring an MCP host, not for a task. They serve the other verbs in this reference to an MCP client; they read no Microsoft 365 data themselves. The server -needs the optional dependency: install `mgraphctl[mcp]`. +needs the optional dependency `mgraphctl[mcp]`, which the shim requests for this noun on its own; +only a separately installed package has to be given it (`pip install 'mgraphctl[mcp]'`). ### `mcp tools` diff --git a/tests/test_cli_mcp.py b/tests/test_cli_mcp.py index b670ba7..fbd7e21 100644 --- a/tests/test_cli_mcp.py +++ b/tests/test_cli_mcp.py @@ -68,6 +68,45 @@ def test_mcp_serve_refuses_to_bind_off_loopback(invoke, host): assert "loopback" in result.stderr +SHIM = Path(__file__).resolve().parents[1] / "mgraphctl" + + +def shim_argv(tmp_path, *args: str) -> list[str]: + """The `uv run` line the shim would exec, with a stub `uv` standing in for the real one.""" + import os + import subprocess + + bin_dir = tmp_path / "bin" + bin_dir.mkdir(parents=True) + stub = bin_dir / "uv" + stub.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n') + stub.chmod(0o755) + env = { + **os.environ, + "PATH": f"{bin_dir}:{os.environ['PATH']}", + "UV_PROJECT_ENVIRONMENT": str(tmp_path / "venv"), + } + done = subprocess.run( + ["bash", str(SHIM), *args], capture_output=True, text=True, env=env, check=True + ) + return done.stdout.split() + + +@covers("mcp serve") +def test_the_shim_asks_for_the_mcp_extra_only_for_the_mcp_noun(tmp_path): + """`--no-dev` hides the SDK, so without this the plugin's own `mcp serve` cannot start.""" + assert "--extra" not in shim_argv(tmp_path / "a", "mail", "list") + argv = shim_argv(tmp_path / "b", "mcp", "serve") + assert argv[argv.index("--extra") + 1] == "mcp" + # The extra is an option to `uv run`, so it has to precede the command being run. + assert argv.index("--extra") < argv.index("mgraphctl") + # Root flags come before the noun, and `--tz` takes a value that must not be read as one. + assert "--extra" in shim_argv(tmp_path / "c", "-dd", "mcp", "serve") + assert "--extra" in shim_argv(tmp_path / "d", "--tz", "UTC", "mcp", "tools") + assert "--extra" not in shim_argv(tmp_path / "e", "--tz", "mcp", "mail", "list") + assert "--extra" not in shim_argv(tmp_path / "f", "--version") + + def _capture_settings(monkeypatch): """Start `mcp serve` far enough to build its settings, then stop before it blocks.""" from mgraphctl.commands import mcp_cmd From 3579fea3712ae9b942a1a9a74f5b2a792fb41f51 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Tue, 8 Sep 2026 00:08:31 +0300 Subject: [PATCH 14/16] fix(mcp): serve the handshake revisions statelessly too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request at `2026-07-28` already took the revision's sessionless path, but a client negotiating one of the handshake revisions got a session and an `Mcp-Session-Id` — state the server has no use for, since the sign-in, the output directory and the tool set all belong to the process. Two tests pin it: neither era's response carries a session id. --- CHANGELOG.md | 5 +-- docs/specs/2026-09-07-mcp-server-design.md | 4 ++- skills/mgraphctl/reference/commands/mcp.md | 8 +++-- src/mgraphctl/mcp/http_app.py | 7 ++++ tests/test_mcp_http.py | 42 ++++++++++++++++++++++ 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2237b5f..40c9642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,9 @@ Results past `--max-inline-bytes` are written and linked regardless, so one wide fetch cannot flood the client. Written files are readable back as MCP resources. - Protocol revision `2026-07-28`, plus the earlier revisions the SDK negotiates. No deprecated - feature is implemented: no HTTP+SSE transport, no protocol sessions, no GET stream, no - resumable streams. + feature is implemented: no HTTP+SSE transport, no GET stream, no resumable streams. No + `Mcp-Session-Id` is minted in either era — the revision has no protocol sessions, and the + handshake revisions the SDK negotiates for older clients are served statelessly as well. - `--transport http` binds loopback only and requires a bearer token and an allowed `Origin`. The server acts as one signed-in user and cannot authenticate callers, so it does not pretend to be an OAuth resource server; `stdio` is the recommended transport. README explains why. diff --git a/docs/specs/2026-09-07-mcp-server-design.md b/docs/specs/2026-09-07-mcp-server-design.md index fbff057..907c0cd 100644 --- a/docs/specs/2026-09-07-mcp-server-design.md +++ b/docs/specs/2026-09-07-mcp-server-design.md @@ -182,7 +182,9 @@ environment. ### 6.2 Streamable HTTP `--transport http [--host 127.0.0.1] [--port N]`. One POST endpoint at `/mcp`. GET and DELETE -answer `405`. No session id is minted, no `Last-Event-ID` honoured. +answer `405`. No session id is minted, no `Last-Event-ID` honoured. The SDK routes a request +carrying `MCP-Protocol-Version: 2026-07-28` to the revision's own sessionless path, and +`stateless_http=True` puts the handshake revisions it still negotiates on the same footing. Three guards, in this order: diff --git a/skills/mgraphctl/reference/commands/mcp.md b/skills/mgraphctl/reference/commands/mcp.md index 5942bee..35e4fdc 100644 --- a/skills/mgraphctl/reference/commands/mcp.md +++ b/skills/mgraphctl/reference/commands/mcp.md @@ -60,8 +60,12 @@ optional (`onedrive download`, `mail attachments`) writes its default there rath the server was launched. **Protocol.** MCP revision `2026-07-28`, plus the earlier revisions the SDK negotiates. The -deprecated HTTP+SSE transport, protocol sessions, the standalone GET stream and resumable streams -are not implemented; `GET` and `DELETE` on the endpoint answer `405`. +deprecated HTTP+SSE transport, the standalone GET stream and resumable streams are not +implemented; `GET` and `DELETE` on the endpoint answer `405`. No request is answered with an +`Mcp-Session-Id`, in either era: `2026-07-28` has no protocol sessions, and the handshake +revisions are served statelessly too, one fresh transport per request. Nothing is kept between +calls that a session could carry — the sign-in, the output directory and the tool set all belong +to the process. **HTTP and its limits.** The server acts as exactly one user — whoever's sign-in is cached — and cannot tell callers apart, so any reachable port is that person's mailbox. Hence: loopback only; a diff --git a/src/mgraphctl/mcp/http_app.py b/src/mgraphctl/mcp/http_app.py index f9d1be3..728ef78 100644 --- a/src/mgraphctl/mcp/http_app.py +++ b/src/mgraphctl/mcp/http_app.py @@ -173,6 +173,13 @@ def build(server: Any, *, token: str, allow_origin: list[str] | None = None) -> app = server.streamable_http_app( streamable_http_path=MCP_PATH, host="127.0.0.1", + # A request carrying `MCP-Protocol-Version: 2026-07-28` is routed to the revision's own + # sessionless path whatever this says; the flag settles the handshake revisions the SDK + # still negotiates for older clients, and they get the same treatment: a fresh transport + # per request, no `Mcp-Session-Id`, no session to expire. The server holds nothing between + # calls that a session could carry — one sign-in, one output directory, a tool set fixed + # at startup. + stateless_http=True, # `RequireOrigin` is this server's DNS-rebinding defence, and it is the one under test. # The SDK's own check also validates Host against a port-bearing allowlist, which rejects # legitimate loopback requests; running both would mean two allowlists to keep in step. diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 7db50a3..3c672a1 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -180,3 +180,45 @@ async def test_a_client_without_an_origin_gets_no_cors_headers(http): ) assert response.status_code == 200 assert "access-control-allow-origin" not in response.headers + + +LEGACY_INITIALIZE = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}, + }, +} + + +async def test_a_call_needs_no_session_of_any_kind(http): + """`2026-07-28` has no protocol sessions: one POST carries its own envelope and is answered.""" + response = await http.post( + http_app.MCP_PATH, json=POST, headers={**HEADERS, "authorization": f"Bearer {TOKEN}"} + ) + assert response.status_code == 200 + assert "mcp-session-id" not in response.headers + + +async def test_an_older_client_gets_no_session_either(asgi): + """The handshake revisions the SDK still negotiates are served statelessly too. + + Its own app, with the lifespan opened and closed inside the test: a legacy `initialize` + leaves the manager's task group busy, which the shared fixture's teardown cannot unwind. + """ + headers = { + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "mcp-protocol-version": "2025-11-25", + "authorization": f"Bearer {TOKEN}", + } + inner = asgi.app.app + async with inner.router.lifespan_context(inner): + transport = httpx.ASGITransport(app=asgi) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + response = await client.post(http_app.MCP_PATH, json=LEGACY_INITIALIZE, headers=headers) + assert response.status_code == 200 + assert "mcp-session-id" not in response.headers From 5180621de7895d5898b4754595e10105eb957d11 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Tue, 8 Sep 2026 00:18:48 +0300 Subject: [PATCH 15/16] test: let anyio own the async tests, not pytest-asyncio Both plugins claimed the MCP modules' async tests and fixtures, and which one won the fixture decided whether teardown ran in the task that entered the cancel scope. It held on macOS and broke on the Linux runner: "Attempted to exit cancel scope in a different task than it was entered in", at teardown of every async test. The two modules that have async tests are anyio-marked already, so pytest-asyncio and its `auto` mode had nothing left to do. --- pyproject.toml | 4 +--- uv.lock | 25 ------------------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8718515..d4ee29e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ Issues = "https://github.com/svd/mgraphctl/issues" mgraphctl = "mgraphctl.cli:main" [dependency-groups] -dev = ["pytest>=8", "pytest-asyncio>=1", "respx>=0.23", "ruff>=0.13", "mcp>=2.1,<3"] +dev = ["pytest>=8", "respx>=0.23", "ruff>=0.13", "mcp>=2.1,<3"] [build-system] requires = ["uv_build>=0.8,<1"] @@ -75,8 +75,6 @@ known-first-party = ["mgraphctl", "helpers"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" -# The MCP SDK's client is async-only; `auto` spares every test the explicit marker. -asyncio_mode = "auto" markers = [ "real_auth: module tests auth itself; do not install the fake token provider", "scopes(list): scopes carried by the synthetic token for this test", diff --git a/uv.lock b/uv.lock index 1cb0693..0c69998 100644 --- a/uv.lock +++ b/uv.lock @@ -51,15 +51,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - [[package]] name = "backports-tarfile" version = "1.2.0" @@ -613,7 +604,6 @@ mcp = [ dev = [ { name = "mcp" }, { name = "pytest" }, - { name = "pytest-asyncio" }, { name = "respx" }, { name = "ruff" }, ] @@ -635,7 +625,6 @@ provides-extras = ["mcp"] dev = [ { name = "mcp", specifier = ">=2.1,<3" }, { name = "pytest", specifier = ">=8" }, - { name = "pytest-asyncio", specifier = ">=1" }, { name = "respx", specifier = ">=0.23" }, { name = "ruff", specifier = ">=0.13" }, ] @@ -847,20 +836,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] -[[package]] -name = "pytest-asyncio" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, -] - [[package]] name = "python-multipart" version = "0.0.32" From ee96f6627c66d67bef285366c71bf78eecc19f88 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Tue, 8 Sep 2026 00:29:40 +0300 Subject: [PATCH 16/16] chore(release): v0.4.0 - MCP server: `mcp serve` exposes the CLI's verbs as tools generated from the command tree, `mcp tools` prints what would be exposed; needs `mgraphctl[mcp]` - capability groups pick the exposed command groups, read-only unless --allow-write, results spill to files under --output-dir past an inline cap - every tool path is confined to --output-dir; the HTTP transport binds loopback and requires a bearer token and an allowed Origin - render.emit splits into to_text/notes/to_json; auth app and cache builds take a lock for the server's worker threads --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 2 +- VERSIONING.md | 1 + pyproject.toml | 2 +- skills/mgraphctl/SKILL.md | 2 +- src/mgraphctl/__init__.py | 2 +- uv.lock | 2 +- 9 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e192b9f..45ea368 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mgraphctl", "displayName": "mgraphctl", "source": "./", - "version": "0.3.0", + "version": "0.4.0", "description": "Work with Microsoft 365 — Outlook mail and calendar, Teams chats and channels, presence, meetings and transcripts, SharePoint, OneDrive, OneNote, Planner, To Do, people and org chart — through the Microsoft Graph API (Python CLI run with uv).", "author": { "name": "Sviatoslav Sviridov", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 72be06a..56f3065 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin.json", "name": "mgraphctl", - "version": "0.3.0", + "version": "0.4.0", "description": "Work with Microsoft 365 — Outlook mail and calendar, Teams chats and channels, presence, meetings and transcripts, SharePoint, OneDrive, OneNote, Planner, To Do, people and org chart — through the Microsoft Graph API (Python CLI run with uv).", "author": { "name": "Sviatoslav Sviridov", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 19dbc2a..30fa43f 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mgraphctl", - "version": "0.3.0", + "version": "0.4.0", "description": "Work with Microsoft 365 through the Microsoft Graph API using a bundled Python CLI run with uv.", "author": { "name": "Sviatoslav Sviridov", diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c9642..8b537d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.4.0] — 2026-09-08 - An MCP server, `mgraphctl mcp serve`, exposing the CLI's verbs as tools to any MCP client. The tool set is generated from the command tree, so a new verb is a new tool; `mcp tools` prints diff --git a/VERSIONING.md b/VERSIONING.md index 7259485..e50f752 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -79,3 +79,4 @@ summarises it. | `mgraphctl--v0.2.0` | 2026-09-06 | Python floor drops to 3.10, so the plugin runs under the Claude desktop app. | | `mgraphctl--v0.2.1` | 2026-09-06 | Command reference split into one file per noun. | | `mgraphctl--v0.3.0` | 2026-09-07 | Codex plugin manifest and repository marketplace added. | +| `mgraphctl--v0.4.0` | 2026-09-08 | MCP server: `mcp serve` and `mcp tools`, with capability groups, path confinement and the loopback HTTP transport. | diff --git a/pyproject.toml b/pyproject.toml index d4ee29e..8cf063a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mgraphctl" -version = "0.3.0" +version = "0.4.0" description = "Microsoft Graph CLI behind the mgraphctl Claude Code skill" readme = "README.md" license = "MIT" diff --git a/skills/mgraphctl/SKILL.md b/skills/mgraphctl/SKILL.md index e341e05..eb3bf60 100644 --- a/skills/mgraphctl/SKILL.md +++ b/skills/mgraphctl/SKILL.md @@ -14,7 +14,7 @@ description: > allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/mgraphctl *) metadata: author: Sviatoslav Sviridov - version: "0.3.0" + version: "0.4.0" --- # Microsoft Graph (Python CLI) diff --git a/src/mgraphctl/__init__.py b/src/mgraphctl/__init__.py index 3a1a3cc..46ba147 100644 --- a/src/mgraphctl/__init__.py +++ b/src/mgraphctl/__init__.py @@ -1,3 +1,3 @@ """mgraphctl: Microsoft Graph CLI for the mgraphctl skill.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/uv.lock b/uv.lock index 0c69998..0487b89 100644 --- a/uv.lock +++ b/uv.lock @@ -583,7 +583,7 @@ wheels = [ [[package]] name = "mgraphctl" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "httpx" },