Skip to content

Update PowerMCP for powerio 0.9.0 and MCP 2 - #57

Merged
qian-harvard merged 27 commits into
Power-Agent:mainfrom
samtalki:agent/v090-powermcp-closure
Aug 22, 2026
Merged

Update PowerMCP for powerio 0.9.0 and MCP 2#57
qian-harvard merged 27 commits into
Power-Agent:mainfrom
samtalki:agent/v090-powermcp-closure

Conversation

@samtalki

@samtalki samtalki commented Aug 21, 2026

Copy link
Copy Markdown
Member

Summary

  • Updates the bundled servers for MCP 2 and powerio 0.9.0.
  • Uses powerio's JSON transports and filesystem helpers across the bridge servers.
  • Checks model-supplied inputs and generated output paths before backend access.
  • Sets the package version to 0.3.0 and requires an existing published tag for PyPI retries.
  • Keeps pandapower on pandas 2.
  • Imports and starts every advertised server in CI.

Tests

  • pytest tests/ -q: 268 passed, 4 skipped
  • pytest PSCAD/tests HOPE/tests -q: 43 passed
  • Installed wheel: all 14 advertised servers imported, registered tools, and reached MCP run
  • Wheel and source distribution passed twine check; the wheel rebuilt from the source distribution

samtalki and others added 22 commits August 18, 2026 04:23
powerio's mcp extra now requires mcp>=2,<3, and mcp 2.0 removed
mcp.server.fastmcp.FastMCP in favor of mcp.server.mcpserver.MCPServer (the
class powerio's own mcp.server module already targets). Every server module
the test suite touches (pandapower, PyPSA, PSSE, PSLF, the OpenDSS
configuration tools) imports MCPServer under the FastMCP alias so the rest of
each file is unchanged; the two test-side monkeypatches of
mcp.server.fastmcp.FastMCP.run move to the new module path.

Bumping the floor from >=0.4.0 to >=0.9.0 also required updating tests to
match 0.9.0's actual MCP tool responses: the tool envelope carries
powerio_version (the installed version) instead of a static schema_version,
and the mcp 2.0 Tool model exposes input_schema instead of inputSchema.

Adds test coverage for POWERIO_MCP_ALLOWED_ROOTS: it was unset everywhere in
the suite, so the containment check in powerio's mcp server was never
exercised by any test. New tests set it explicitly and assert both the reject
and admit sides for a read path and a write path.

Files outside the test suite's reach (Egret, ANDES, PowerWorld, LTSpice,
HOPE, surge, PSCAD, the rest of OpenDSS) still import the removed
mcp.server.fastmcp.FastMCP and are broken under mcp 2.0; out of scope here,
tracked separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The powerio server checks each path argument against `POWERIO_MCP_ALLOWED_ROOTS` before opening it. The bridge servers did not: twenty-two tool arguments across six servers went from the model straight to a reader or a writer, so a model persuaded to name `../../.ssh/id_rsa` had it read, and one persuaded to name an output path outside the roots had it written.

`powermcp.sandbox` applies the same policy for the same environment variable, so an operator configures containment once for the installation. Unset, nothing is constrained and the servers behave as before, which is the opt-in shape the powerio server already established.

Paths resolve before they are compared, so neither a `..` segment nor a symlink out of a root gets through — it is the real target that is checked, not the spelling. A write through a dangling symlink resolves its final component too, which is the case where checking the link's own location would pass while the write followed it out. A non-local URI scheme is refused rather than handed to an opener.

The tests hold the policy directly and hold every listed tool to using it, reading the server sources rather than importing them: a bridge server pulls in the simulator it wraps, so importing to introspect would skip the check in exactly the environments that lack that simulator.
powerio 0.9.0 renamed the balanced JSON transport token from powerio-json to
model-json in every MCP response. The reader still accepts the old spelling as
an input alias, so this pins both halves: responses must state model-json, and
powerio-json must keep working as a json_format argument.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iuj7EXLRVU9s2BUNadUeN
The policy existed twice, and the copies had already drifted: powerio reads POWERIO_MCP_ALLOWED_ROOTS, then POWERIO_MCP_ROOT, then POWERIO_MCP_ALLOWED_ROOT, while this repo's copy read only the first and third. An operator who set POWERIO_MCP_ROOT alone got containment on the powerio server and none on the ANDES, Egret, PyPSA, surge, PowerWorld, and pandapower bridges, which silently accepted any path on the filesystem.

powerio.mcp.sandbox is the policy on its own and imports nothing but the standard library, so powermcp.sandbox is now a re-export. powerio raises a plain ValueError for a refusal, so PathNotAllowed is an alias; the bridge servers are unchanged. The tests stay here as the consumer suite and gain a check that the two modules are one object plus a case per root spelling, and every refusal assertion now matches the message rather than only the type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iuj7EXLRVU9s2BUNadUeN
This repo carried powerio/powerio_mcp.py, a file whose only content was a list of powerio's tool names re-exported from powerio.mcp.server. Four powerio releases in a row required editing it, each time to restate a surface powerio already publishes, and the file could only ever be a release behind.

powerio ships the server in its own wheel, so the registry now declares it as one: a new run_kind, "package", meaning the server is an installed distribution rather than a directory bundled here. `powermcp run powerio` is `python -m powerio.mcp`, nothing goes on sys.path, and the wheel no longer force-includes a powerio/ directory. A powerio release that adds or renames a tool needs no change in this repo.

The tests keep their full coverage of the surface and now import powerio.mcp.server directly, which is what they were reaching through the re-export anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iuj7EXLRVU9s2BUNadUeN
Every existing powerio test calls the tool functions in process, so the SDK's argument handling was never exercised and a defect hid behind a green suite: the SDK rewrites any string argument whose text parses as JSON into the parsed object before validation, so an argument annotated Optional[str] is rejected as "Input should be a valid string" for a string the caller did send. That makes `json`, `content` carrying JSON, and `package_json` unusable over a real transport on every powerio tool except `diagnostics`, whose `package_json` is a required bare `str` and therefore skipped by the rewrite.

Three passing cases pin the launch chain, a path argument, and non-JSON content; two strict xfails record the transport arguments that do not work, so the markers fail the moment the defect is fixed and can be removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iuj7EXLRVU9s2BUNadUeN
…hecked

Three problems, all of which showed a user something wrong or nothing at all.

The install hint told every user to run a command that does not exist. Rich reads a bracketed lowercase word as a style tag, so "pip install powermcp[andes]" rendered as "pip install powermcp" for every tool with an extra; for a core tool such as powerio, whose extra is None, the template produced the literal "pip install powermcp[None]". Messages are escaped before they go into markup, and the hint is one function in the registry that both the doctor and the launch error use.

Nothing checked the MCP SDK, which every server imports at module scope. A missing or broken SDK made `powermcp run <tool>` die during import with a traceback on a stderr its MCP client does not read, so the client saw an empty transport and the doctor said "ok". Preflight now refuses with an actionable message and the doctor reports the SDK.

Nothing checked that an installed dependency is new enough. find_spec answers "importable", so an old powerio reported "ok" and then failed at call time; the doctor now compares the installed version against the floor this project declares for it. Nothing reported the containment configuration either, and a root that does not exist refuses every path, which reads as a broken server rather than a misconfiguration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iuj7EXLRVU9s2BUNadUeN
Both were invisible because the suite exercises only the servers whose engine is installed in the test environment, which is three of fourteen.

Eight servers still import mcp.server.fastmcp, a module mcp 2.0 removed, while this project requires mcp>=2,<3. `powermcp run hope` on a machine with its dependencies present dies with ModuleNotFoundError: No module named 'mcp.server.fastmcp' before the transport opens, so the MCP client sees an empty pipe. The new test reads each server's AST and imports the SDK entry point it names, which works without the vendor engine; the unmigrated files are listed with what each needs, so a migrated file fails the test until it leaves the list. Most need only the rename; HOPE takes host, port, and transport_security constructor arguments MCPServer does not have, and reads settings.transport_security, which it does not expose.

The containment policy reaches six of the path-taking servers. LTSpice, OpenDSS, PSSE, and PSLF take a path from the model straight to the filesystem, so an operator who sets POWERIO_MCP_ALLOWED_ROOTS constrains some servers and not others. LTSpice's read_simulation_log is a bare read returned to the model verbatim, and PSLF's open_case joins the argument onto the working directory, so a relative climb leaves it. The inventory sits beside the guarded list under the same AST walk and fails when a listed tool starts checking, so the two lists cannot disagree with the source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iuj7EXLRVU9s2BUNadUeN
…eant for

Review found three defects in the checks added a commit ago.

The floor check compared a probe against a requirement by name, but a probe is an import name and a requirement names a distribution. `yaml` never equals `PyYAML` and `surge` never equals `surge-py`, so the comparison found no floor and reported every version as fine, for exactly the two tools that declare one. It resolves the distribution through packages_distributions() now and compares PEP 503 normalized names, so an ancient PyYAML reports red instead of ok.

A version string that does not parse was reported as below the floor, because a specifier answers False for anything it cannot read. There is nothing to compare in that case, so it now says nothing.

With several roots configured and only some of them missing, the containment line claimed every path was refused while paths under the surviving roots were admitted. That sends a reader after the wrong problem; the missing roots are now named as admitting nothing while the rest still hold.

The transport tests also gained a timeout. The SDK waits forever by default, so a server that starts and then blocks would hang the suite with nothing to fail it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iuj7EXLRVU9s2BUNadUeN
…y the rename

ANDES/andes_mcp.py, Egret/egret_mcp.py, PowerWorld/powerworld_mcp.py and surge/surge_mcp.py each imported FastMCP from mcp.server.fastmcp, which mcp 2.0 removed, so each died at import under the pinned mcp>=2,<3. All four now use the line the already migrated servers use, `from mcp.server.mcpserver import MCPServer as FastMCP`, and nothing else in them changes: each constructs the server with a single name argument and calls mcp.run(transport="stdio"), which is the surface MCPServer already serves for pandapower, PyPSA, PSSE, PSLF and the OpenDSS configuration tools. Dropping the four from the unmigrated inventory in tests/test_sdk_imports.py turns that test from an assertion that they are broken into an assertion that they resolve the SDK. HOPE stays in that inventory because MCPServer takes neither host/port nor transport_security, which makes it a port rather than a rename, and LTSpice, PSCAD and the remaining OpenDSS files stay there too. The path containment inventories in tests/test_sandbox.py are untouched, and the four files keep the guarded path arguments a73b05a gave them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iuj7EXLRVU9s2BUNadUeN
tests/test_transport.py carried two strict xfails recording an SDK defect: a string argument whose text parses as JSON was rewritten into the parsed object before validation, so any argument not annotated exactly `str` could not carry JSON over a real transport. The registered tools in powerio 0.9.0 annotate content, json and package_json as bare `str`, which is what stops the rewriting, and it stops it for every mcp 2.x rather than for one SDK version, so both tests now assert the round trip instead of recording the break; the module docstring and the control test's comment stop pointing at xfails that no longer exist. tests/test_powerio_server.py::test_wrong_schema_json_maps_cleanly matched the prose "parse failed", which 0.9.0 replaced with coded diagnostics, so it now matches the PARSE.SOURCE.MALFORMED code that all four wrong schema inputs in its loop raise. Full suite on python 3.12 with mcp 2.0.0 and powerio 0.9.0 goes from 3 failed, 187 passed, 4 skipped to 190 passed, 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iuj7EXLRVU9s2BUNadUeN
powerio.mcp.sandbox grew the dedicated refusal type, so the bridge servers
can catch exactly the containment refusal rather than every ValueError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iuj7EXLRVU9s2BUNadUeN
powerio 0.9 reports source_format as the same lowercase token from accepts
(matpower), retiring the PascalCase variant name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iuj7EXLRVU9s2BUNadUeN
An MCP tool argument is whatever the model was persuaded to ask for, so
every backend read and write reached from one has to stay inside the
configured roots, and no model-selected file may be deserialized as code.

pandapower drops `.p` input. Containment confines which pickle a caller can
name; it does not make unpickling it safe, and `pandas.read_pickle` runs
attacker-selected callables as the server user.

PSS/E replaces the prose and parameter-name heuristics with per-command
metadata that names each file argument and its arity. The heuristics missed
documented parameters, `accfiles` and `acfiles` among them, and the
executable IPLAN, response and automation file arguments. The sibling
`reviewed_non_paths` records the parameters that read as paths and are not,
so the suite holds every bundled spec parameter to a decision instead of to
a name pattern, and regenerating the specs fails loudly.

PyPSA preflights the whole tree for every `Network(...)` read, since
`Network` accepts a NetCDF file or a CSV directory and imports the
directory's descendants. Its CSV import takes an explicit checked output
path rather than writing a NetCDF file into the process working directory.

PSCAD checks the sibling `.psout` it selects after globbing; validating the
caller's `.out` says nothing about the file actually opened. PowerFactory
defers nested output directories to the helper that checks each missing
component before creating it.

The README describes what the checks do rather than claiming the servers
are contained, and the doctor reports an unsatisfied requirement rather
than asserting the installed version is too old.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workflow checked out whatever ref the run resolved to and would publish
it, so a manual dispatch could ship a branch rather than the reviewed tag.
It now takes the tag as a required input, checks out `refs/tags/<tag>`,
and refuses to build unless that tag resolves to the checked out commit on
origin, the package version matches the tag, and the matching GitHub
release exists as a published non-prerelease.

Third-party actions are pinned to full commit SHAs, and the built
distributions travel between jobs under an artifact name keyed to the
verified commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@samtalki
samtalki marked this pull request as ready for review August 21, 2026 21:06
@samtalki
samtalki marked this pull request as draft August 21, 2026 21:13
@samtalki
samtalki marked this pull request as ready for review August 21, 2026 21:15
samtalki and others added 2 commits August 21, 2026 21:41
(REPO / server).read_text() decodes with the platform default encoding —
cp1252 on Windows — and LTSpice/ltspice_mcp.py contains UTF-8 emoji, so
test_every_path_taking_tool_checks_its_argument crashed with
UnicodeDecodeError on Windows. Ubuntu CI (utf-8 default) never sees it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants