Repository navigation
feat(mcp): structured tool output, annotations, and isError across the fleet pattern - #6
Merged
Merged
Conversation
mcp 2.0.0's FuncMetadata.convert_result validates a returned CallToolResult's structured_content against the tool's output model unconditionally. mcp>=2.1.1 adds an "and not result.is_error" guard, which the upcoming Annotated[CallToolResult, Model] structured-output pattern depends on: an is_error=True result has no structured_content to validate, and without the guard convert_result raises a pydantic.ValidationError instead of returning the error result. This repo's lock was already on mcp 2.1.1 (has the guard); bump to 2.2.0 for consistency with the rest of the fleet. Also bumps the mypy pre-commit hook's hard mcp==2.0.0 pin to 2.2.0, and adds mypy_path = ["src"] to pyproject.toml -- without it, the mypy hook can only resolve servicex_mcp.* imports when a src/ file happens to be part of the same commit's changed-file list, breaking on a test-only commit. This repo's dependency graph pulls a forced anyio 4.15.1 (vs. the 4.14.2 the rest of the fleet keeps) as part of the mcp 2.2.0 solve. anyio >=4.15 deprecates the anyio.abc.BlockingPortal re-export; starlette 1.6.0's own testclient.py still uses that alias internally, so every wire-level test importing starlette.testclient now trips filterwarnings=["error"] on collection. Added a narrowly-scoped filterwarnings ignore for that exact upstream message -- nothing in servicex-mcp touches the deprecated alias directly. Assisted-by: Claude (Anthropic)
…lToolResult Both shared helpers move from returning a plain error string to CallToolResult(content=[TextContent(...)], is_error=True), keeping the existing recovery-hint prose byte-for-byte. No structured_content is set on either: an error result carries no structured payload (mcp SDK's convert_result only validates structured_content against the tool's output model when is_error is false). This is a prerequisite for the per-tool structured-output commits that follow: check_write_allowed's write-gate is servicex-mcp's own analog of rucio-mcp's check_write_allowed, and both must speak the same is_error CallToolResult shape as classify_error before any tool's return type can change to Annotated[CallToolResult, Model]. Adds a tool_text(result) fixture to tests/conftest.py so existing markdown assertions keep working once tool functions stop returning plain strings. Assisted-by: Claude (Anthropic)
…uctured content and read-only annotations Both tools are read-only queries against the external ServiceX server, so both get ToolAnnotations(read_only_hint=True, open_world_hint=True). Return type becomes Annotated[CallToolResult, ResultModel] per the mcp SDK escape hatch for combining a curated markdown text block with a validated structuredContent payload: ServicexInfoResult (app_version, capabilities) and ServicexListCodeGeneratorsResult (generators) mirror the fields each tool already assembles for its markdown rendering. Assisted-by: Claude (Anthropic)
servicex_list_datasets/servicex_get_dataset are read-only queries against the external ServiceX server (read_only_hint=True, open_world_hint=True). servicex_delete_dataset actually removes a cached dataset record, so it gets read_only_hint=False, destructive_hint=True. DatasetInfo mirrors _dataset_to_dict fields exactly and is reused as both the per-row model inside ServicexListDatasetsResult and the whole result of servicex_get_dataset, avoiding a second near-duplicate model. ServicexDeleteDatasetResult carries the dataset_id and the "stale" flag delete_dataset returns from the server. The empty-list early return in servicex_list_datasets now also builds a valid (empty) structured payload rather than skipping structured_content -- the tool output schema is unconditional, so every non-error return must satisfy it. Assisted-by: Claude (Anthropic)
…ating annotation
servicex_submit_query mutates ServiceX state (submits a real transform
request) but is not destructive -- it never removes or overwrites
anything -- so it gets read_only_hint=False with destructive_hint left
unset. ServicexSubmitQueryResult carries just the request_id, the one
field the tool returns.
Also routes the ValueError raised by _build_dataset_identifier and
ResultFormat parsing through classify_error instead of building an ad
hoc "Error: {exc}" string by hand at the call site: for both current
failure messages, classify_error falls through to the same "Error:
{exc}" text (neither matches any of its recovery-guidance categories),
so this is byte-identical output while collapsing to a single error
factory function per the fleet convention.
Assisted-by: Claude (Anthropic)
servicex_list_transforms/servicex_get_transform_status are read-only queries against the external ServiceX server (read_only_hint=True, open_world_hint=True). servicex_cancel_transform and servicex_delete_transform both act on real running/finished transforms and cannot be undone, so both get read_only_hint=False, destructive_hint=True. TransformInfo mirrors _transform_to_dict fields exactly (including log_url, which the list view omits from its markdown table via _TRANSFORM_KEYS but the structured payload still carries) and is reused as both the per-row model inside ServicexListTransformsResult and the whole result of servicex_get_transform_status. ServicexCancelTransformResult/ServicexDeleteTransformResult each carry just the transform_id acted on. As with servicex_list_datasets, the empty-list early return in servicex_list_transforms now builds a valid (empty) structured payload rather than skipping structured_content. Assisted-by: Claude (Anthropic)
…test test_every_tool_declares_annotations_and_output_schema asserts every registered tool publishes annotations (with read_only_hint set) and an outputSchema -- the one test that would catch a future tool skipping the Annotated[CallToolResult, Model] + ToolAnnotations pattern. TestStdioAppOverTheWire exercises a real JSON-RPC round trip through the built ASGI app via starlette.testclient.TestClient, since every existing tool test calls tool.fn directly and never goes through mcp SDK serialization -- none of them would notice a missing annotations or outputSchema on the wire. Built with streamable_http_app(json_response=True) and a base_url with an explicit port to avoid the DNS-rebinding protection 421 that host="127.0.0.1" alone would trigger. Assisted-by: Claude (Anthropic)
The servicex-mcp README had no tool-list section at all (unlike the other backend repos in this fleet). Adds a plain "Available tools" table with a read/write column following the other repos table style. No --8<-- transclusion markers: docs/index.md already carries its own separately hand-maintained tools table with no snippet reference back to README, unlike af-filesystem-mcp, where docs/index.md does transclude README sections -- so there is no existing transclusion pattern here to extend. Assisted-by: Claude (Anthropic)
Assisted-by: Claude (Anthropic)
This reverts commit 63af597.
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.
Summary
Rolls out the shared MCP interop pattern (SDK currency, structured
content, tool annotations,
isError) to servicex-mcp's 10 tools,following the reviewed pilot in af-filesystem-mcp#6.
chore(deps): bumpmcplock to 2.2.0 (was already 2.1.1, which hasthe
is_errorguardconvert_resultneeds); bump the mypypre-commit hook's hard
mcp==2.0.0pin to 2.2.0; addmypy_path = ["src"]topyproject.toml(without it the mypy hookonly resolves
servicex_mcp.*imports when asrc/file is part ofthe same commit).
classify_error/check_write_allowednow returnCallToolResult(is_error=True)instead of a plain string, keepingthe existing recovery-hint prose byte-for-byte.
Annotated[CallToolResult, ResultModel]with acurated markdown text block and
structured_contentvalidatedagainst a published
outputSchema, permcp/server/mcpserver/utilities/func_metadata.py's escape hatch forcombining the two.
ToolAnnotationsper the bucket table: 6 read-only(
open_world_hint=True, external ServiceX backend),servicex_submit_querymutating-non-destructive, and 3 destructive(
servicex_cancel_transform,servicex_delete_transform,servicex_delete_dataset) -- cross-checked against the existingread_onlylifespan flag that already gates these same 4 tools, sothe two signals cannot drift apart.
starlette.testclient.TestClientprovingtools/listcarriesannotations/outputSchemaandtools/callcarries both a textblock and
structuredContent.## Available toolssection inREADME.md(this repo had noexisting tool-list section) with a read/write/destructive column.
tbump(0.1.4 -> 0.1.5), chartappVersion/versionin lockstep.Deviations from the pilot / brief
forced
anyio 4.15.1(vs. the4.14.2the rest of the fleet keeps)as part of the
mcp 2.2.0solve.anyio>=4.15deprecates theanyio.abc.BlockingPortalre-export;starlette1.6.0's owntestclient.pystill uses that alias internally, so everywire-level test importing
starlette.testclienttrippedfilterwarnings=["error"]on collection. Added a narrowly-scopedfilterwarningsignore for that exact upstream message inpyproject.toml-- nothing in servicex-mcp touches the deprecatedalias directly, and no other repo in the fleet needed this.
exclude-newergotcha: this repo'spixi.tomlhasexclude-newer = "7d"(the pilot has none), which hidmcp 2.2.0from a plain
pixi update. Worked around by temporarily wideningexclude-newerto a narrow explicit window just pastmcp 2.2.0'spublish date, running the update, then reverting
pixi.toml(thelock stays pinned once resolved) --
pixi.tomlitself has no netdiff in this PR.
classify_errornaming: this repo's error-formatting helper isnamed
classify_error(notformat_errorlike the pilot); kept theexisting name per the brief, only changed its return type.
repos), so step 9 of the brief (update the add-a-tool template) had
nothing to update. Flagging for Giordon: worth deciding separately
whether this repo should get a CLAUDE.md matching the fleet
convention.
docs/index.mdhas its own hand-maintained tools table, nottranscluded from
README.mdvia--8<--snippets (unlikeaf-filesystem-mcp, where
docs/index.mddoes transclude READMEsections). Left it untouched per the brief's scope (README.md only);
it will read as slightly stale (no read/write column) until someone
updates it too.
tbump.toml'sgithub_urlstill points atkratsg/servicex-mcp(pre-org-migration); left untouched as out of scope for this PR.
Test plan
pixi run -e py313 test-all(and py311/py312) -- 303 passed, 1skipped (the
--runslowlive-backend suite)pixi run pre-commit --stage manual-- all hooks pass, includingmypy via the prek hook
pixi run pylint-- 10.00/10pixi run -e helm helm-lint/helm-template-- pass with thebumped chart version
🤖 Generated with Claude Code