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 ed64f00..8b537d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [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 + 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 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. + - 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. 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 + 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 - Add a Codex plugin manifest and repository marketplace. Claude Code and Codex share the diff --git a/README.md b/README.md index 4d7ad64..2385dab 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,67 @@ ${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. 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]" +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. 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 +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 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/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..907c0cd --- /dev/null +++ b/docs/specs/2026-09-07-mcp-server-design.md @@ -0,0 +1,263 @@ +# 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. 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: + +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. 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/pyproject.toml b/pyproject.toml index cb555e1..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" @@ -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", "respx>=0.23", "ruff>=0.13", "mcp>=2.1,<3"] [build-system] requires = ["uv_build>=0.8,<1"] 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/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..35e4fdc --- /dev/null +++ b/skills/mgraphctl/reference/commands/mcp.md @@ -0,0 +1,82 @@ +# `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 `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` + +| 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); 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. | + +- **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. + +**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 — 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 - 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. + +**Protocol.** MCP revision `2026-07-28`, plus the earlier revisions the SDK negotiates. The +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 +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/__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/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/cli.py b/src/mgraphctl/cli.py index b813b24..5858edc 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 @@ -246,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..aa1e984 --- /dev/null +++ b/src/mgraphctl/commands/mcp_cmd.py @@ -0,0 +1,212 @@ +"""`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( + ctx: typer.Context, + 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, + # 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, + ) + + +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( + ctx: typer.Context, + 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(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. + server.enter_output_dir(settings.store()) + _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/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/__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..9f480d3 --- /dev/null +++ b/src/mgraphctl/mcp/discover.py @@ -0,0 +1,255 @@ +"""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; +# `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", + "config path", + "config show", + "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 +# 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] + # 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 + + @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", "mcp"} | {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, + 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), + ) + + +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/http_app.py b/src/mgraphctl/mcp/http_app.py new file mode 100644 index 0000000..728ef78 --- /dev/null +++ b/src/mgraphctl/mcp/http_app.py @@ -0,0 +1,188 @@ +"""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 json +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(" ") + # 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, + "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. + + 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: + 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} is not allowed") + return + 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: + 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: + # 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", + "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", + # 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. + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), + ) + return RequireOrigin(RequireBearer(app, token), allow_origin or []) diff --git a/src/mgraphctl/mcp/output.py b/src/mgraphctl/mcp/output.py new file mode 100644 index 0000000..0fb7ba0 --- /dev/null +++ b/src/mgraphctl/mcp/output.py @@ -0,0 +1,219 @@ +"""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]: + # 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 root.rglob("*") if p.is_file()) + + 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 + + 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") + mime = mime_for(path) + return (path.read_text() if is_text(mime) else path.read_bytes()), mime + + +def mime_for(path: Path) -> str: + guessed, _ = mimetypes.guess_type(path.name) + 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=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, + ) + + +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. 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: + 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) + 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/src/mgraphctl/mcp/schema.py b/src/mgraphctl/mcp/schema.py new file mode 100644 index 0000000..83fd8b5 --- /dev/null +++ b/src/mgraphctl/mcp/schema.py @@ -0,0 +1,137 @@ +"""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", +} + + +# 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 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"}, + "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 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: + """`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) + # 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 + + +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/src/mgraphctl/mcp/server.py b/src/mgraphctl/mcp/server.py new file mode 100644 index 0000000..b357f41 --- /dev/null +++ b/src/mgraphctl/mcp/server.py @@ -0,0 +1,289 @@ +"""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 base64 +import contextlib +import functools +import inspect +import io +import json +import logging +import os +import threading +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") + + +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.""" + + 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 + # 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() + 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 _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) + ) + 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 + + +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, 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=env) + kwargs["ctx"] = typer.Context(spec.command, parent=root_ctx, info_name=spec.path) + buffer = io.StringIO() + 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) + + +@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, + env: Globals, +) -> 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) + + as_json = output_format == "json" + try: + if spec.fn is None: + result = _run_local(spec, kwargs, as_json=as_json, env=env) + else: + 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. + result = spec.fn(client, json_=as_json, **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, env=settings.env + ) + 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: + 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", + 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/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_cli_mcp.py b/tests/test_cli_mcp.py new file mode 100644 index 0000000..fbd7e21 --- /dev/null +++ b/tests/test_cli_mcp.py @@ -0,0 +1,168 @@ +"""CLI tests for the `mcp` noun (MCP spec §8).""" + +import json +from pathlib import Path + +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 + + +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 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.""" + 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 new file mode 100644 index 0000000..38a8b3f --- /dev/null +++ b/tests/test_mcp_discover.py @@ -0,0 +1,138 @@ +"""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, `mcp` runs this server.""" + assert { + "login", + "logout", + "config path", + "config show", + "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") + + +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_http.py b/tests/test_mcp_http.py new file mode 100644 index 0000000..3c672a1 --- /dev/null +++ b/tests/test_mcp_http.py @@ -0,0 +1,224 @@ +"""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 + + +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 + + +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 diff --git a/tests/test_mcp_output.py b/tests/test_mcp_output.py new file mode 100644 index 0000000..f7246aa --- /dev/null +++ b/tests/test_mcp_output.py @@ -0,0 +1,172 @@ +"""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_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") + 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/tests/test_mcp_schema.py b/tests/test_mcp_schema.py new file mode 100644 index 0000000..54dfa57 --- /dev/null +++ b/tests/test_mcp_schema.py @@ -0,0 +1,144 @@ +"""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) + + +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 + + +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") + + +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 new file mode 100644 index 0000000..275a441 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,379 @@ +"""The server end to end, over the SDK's in-memory transport (MCP spec §4, §5).""" + +import base64 +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_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"}) + 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 + + +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 + + +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_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") + 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") + 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"] 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)) == [] diff --git a/tests/test_surface.py b/tests/test_surface.py index 58caedf..32c5b07 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,27 +12,24 @@ 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}" +# `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 diff --git a/uv.lock b/uv.lock index 394503b..0487b89 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,15 @@ 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-tarfile" version = "1.2.0" @@ -215,6 +238,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 +334,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 +362,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 +463,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 +534,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" @@ -446,7 +583,7 @@ wheels = [ [[package]] name = "mgraphctl" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -458,8 +595,14 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] +[package.optional-dependencies] +mcp = [ + { name = "mcp" }, +] + [package.dev-dependencies] dev = [ + { name = "mcp" }, { name = "pytest" }, { name = "respx" }, { name = "ruff" }, @@ -470,14 +613,17 @@ 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 = "respx", specifier = ">=0.23" }, { name = "ruff", specifier = ">=0.13" }, @@ -506,6 +652,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 +691,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 +836,34 @@ 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 = "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 +873,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 +928,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 +1158,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 +1220,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 +1253,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 +1283,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"