Conversation
The MCP server renders results without owning stdout - on stdio transport stdout carries JSON-RPC - so the rendering has to be reachable as strings. emit composes the three and behaves exactly as before.
…chema walk() moves out of test_surface.py into discover.py and the test imports it, so the CLI's coverage invariants and the MCP tool set describe one command tree. Mutation is an explicit table rather than a scope check: Graph has no read-only Tasks scope, so planner/todo reads must declare Tasks.ReadWrite, while chats create writes holding nothing matching ReadWrite. Tests fail on an unclassified verb, a mutating verb without a write-capable scope, and a write-capable verb that is neither classified nor named in the excuse list.
--json becomes a choice among MCP's three channels. Results past the inline limit spill to a file whatever the caller asked, so one runaway --all cannot fill the client's context. Every path a tool argument names resolves inside the server's output directory, symlinks included.
Uses the low-level Server: the tool set is generated, and MCPServer can only infer a schema from a Python signature. That means the SDK neither validates arguments nor turns an exception into isError, so the server does both itself. graph_command gains __graph_fn__ because __wrapped__ cannot serve the purpose: typer wraps our wrapper, so the chain's first link is the wrapper itself.
Streamable HTTP on the 2026-07-28 shape: one POST endpoint, no session id, no GET stream, no resumable streams. Three guards stand in for the authorization spec, which cannot be met honestly here: loopback-only bind, a required bearer token, and Origin validation against DNS rebinding. The mcp noun excludes itself from its own tool surface - a tool that could restart the server with --allow-write would defeat the read-only default.
Path confinement had two holes. --body-file is typed str, so classifying by Click type alone missed it on seven verbs, leaving an arbitrary local file read; it is now named explicitly and a test scans every exposed verb for path-shaped parameters that are neither confined nor declared a Graph path. And confining only the paths a caller supplies left the defaults: a verb whose destination is optional builds a relative one from the item name, which resolved against the launch directory, so read-only tools such as mail attachments wrote outside the store and then crashed on as_uri(). The server now runs from inside its output directory. redirect_stdout and the msal lazy build are process-global while tool calls run on worker threads. Both are locked: interleaved redirects crossed output or handed a thread the real stdout, which on stdio transport carries JSON-RPC. --allow-origin was dead - the unauthenticated CORS preflight hit the bearer check and the response carried no Access-Control-Allow-Origin. Preflight is now answered ahead of the token and allowed responses carry the header. Also: compare_digest on bytes, so a non-ASCII Authorization header is a 401 and not a 500; json.dumps for error bodies, so a crafted Origin cannot inject structure; IntRange bounds reach the schema; and json_ travels to the verbs that shape their result by it.
resources/read on a downloaded PNG decoded it as text and raised, which a low-level handler turns into an opaque protocol error; non-text media now come back as a blob. A store reached through a symlink (--output-dir /tmp/... on macOS) made list_resources fail outright, because the files were listed from the unresolved root and named relative to the resolved one. A file:// URI naming another host is refused, and people photo basenames the UPN it builds a filename from.
api --body is typed str because most values are the JSON body itself; only a leading @ makes the rest a path, so classifying by Click type missed it and an enabled api tool could read any local file and return it in its dry-run plan. The @ form now resolves through the output store. A test greps the command modules for the convention and fails if another verb adopts it without being declared, which is how this one escaped.
`mcp serve` built its own `Globals` from config, so `-d`/`-dd` set the log level but never reached the client a tool call opens: the flag turned on tracebacks and nothing else, while `MGRAPHCTL_DEBUG` turned on request logging. The root callback already folds config into the `Globals` it puts on the context, so the server takes that instead. `--tz` and `--beta` now apply to tool calls for the same reason.
The shim runs `uv run --frozen --no-dev`, which installs neither the optional extra nor the dev group, so `./mgraphctl mcp serve` ended at the install hint — advice that cannot help someone running the plugin from a clone rather than a released package. The shim now scans past the root flags for the noun and adds `--extra mcp` when it is `mcp`. Every other verb's environment is untouched, so the CLI's normal startup stays as light as it was.
A request at `2026-07-28` already took the revision's sessionless path, but a client negotiating one of the handshake revisions got a session and an `Mcp-Session-Id` — state the server has no use for, since the sign-in, the output directory and the tool set all belong to the process. Two tests pin it: neither era's response carries a session id.
An MCP server exposing the CLI's verbs as tools, generated from the Click tree. Capability gating keeps the client's context bounded, writes are opt-in, and every path a tool argument names is confined to the output directory.
Both plugins claimed the MCP modules' async tests and fixtures, and which one won the fixture decided whether teardown ran in the task that entered the cancel scope. It held on macOS and broke on the Linux runner: "Attempted to exit cancel scope in a different task than it was entered in", at teardown of every async test. The two modules that have async tests are anyio-marked already, so pytest-asyncio and its `auto` mode had nothing left to do.
- MCP server: `mcp serve` exposes the CLI's verbs as tools generated from the command tree, `mcp tools` prints what would be exposed; needs `mgraphctl[mcp]` - capability groups pick the exposed command groups, read-only unless --allow-write, results spill to files under --output-dir past an inline cap - every tool path is confined to --output-dir; the HTTP transport binds loopback and requires a bearer token and an allowed Origin - render.emit splits into to_text/notes/to_json; auth app and cache builds take a lock for the server's worker threads
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
mgraphctl mcp serve, exposing the CLI's verbs as tools to any MCP client. Thetool set is generated from the command tree, so a new verb is a new tool;
mcp toolsprintswhat would be exposed. Needs the optional dependency
mgraphctl[mcp].--capabilities mail,calendar,teamspicks the command groups to expose, because all 123 verbsat once would cost the client a great deal of context. Default:
core,mail,calendar,people,chats.allcovers everything butapi.--allow-write. Tools carry the MCP behaviour annotations too, but those arehints to the host, not the gate.
--jsonhas no tool equivalent:output_formatchooses the compact table or the full payloadas structured content, and
output_filewrites the result under--output-dirand links it.Results past
--max-inline-bytesare written and linked regardless, so one wide fetch cannotflood the client. Written files are readable back as MCP resources.
2026-07-28, plus the earlier revisions the SDK negotiates. No deprecatedfeature is implemented: no HTTP+SSE transport, no GET stream, no resumable streams. No
Mcp-Session-Idis minted in either era — the revision has no protocol sessions, and thehandshake revisions the SDK negotiates for older clients are served statelessly as well.
--transport httpbinds loopback only and requires a bearer token and an allowedOrigin.The server acts as one signed-in user and cannot authenticate callers, so it does not pretend
to be an OAuth resource server;
stdiois the recommended transport. README explains why.--output-dir, including files a verb reads(
--attach,--body-file) rather than writes, and the server runs from inside that directoryso a verb's own default destination cannot land elsewhere. A downloaded file that is not text
reads back through
resources/readas a blob.mgraphctlshim adds--extra mcpwhen the noun being run ismcp, so the plugin canstart the server without the operator installing anything; every other verb's environment is
unchanged.
mgraphctl -dd mcp servelogs every tool call's Graphrequest to stderr, and
--tz/--betaapply the same way they do on the command line.render.emitsplits intoto_text,notesandto_json, so a caller that does not own stdoutcan render a result. The CLI's output is unchanged.
auth.app()andauth.save_cache()take a lock. The CLI is single-threaded, but the MCP serverruns 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.