Skip to content

chore(release): v0.4.0 - #7

Merged
svd merged 17 commits into
mainfrom
dev
Sep 7, 2026
Merged

chore(release): v0.4.0#7
svd merged 17 commits into
mainfrom
dev

Conversation

@svd

@svd svd commented Sep 7, 2026

Copy link
Copy Markdown
Owner
  • 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.

svd added 17 commits September 7, 2026 22:24
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
@svd
svd merged commit c4d6360 into main Sep 7, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant