From b4a8228774d5274d30524ffd809e456e37c7aafd Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:15:19 +0900 Subject: [PATCH 1/2] Let an mcp block name the arguments the model may write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit params fixes one argument, but a tool keeps every other argument it takes, and some of them can widen what the fixed one bounds. A server's schemas are listed while the agent runs, so an argument it adds later would reach the model without anyone declaring it. only names the arguments the model may write, by tool out of allow, as it does for an openapi block. The rest are taken out of the schema the model is shown and dropped from each call even when the model writes them; a params value still rides. validate refuses an argument that is both listed and fixed, and only, like params, needs allow to hold its tool names against. A listed argument the server does not declare is logged and the tool is still offered, since the miss opens nothing up. A fixed one it does not declare still keeps the tool away. 🤖 Generated with Claude Code --- README.md | 14 +++++++ src/gete/mcp.py | 44 +++++++++++++-------- src/gete/runtime/mcp.py | 79 +++++++++++++++++++++++++++----------- src/gete/schema/agent.json | 13 +++++++ tests/e2e/test_mcp_e2e.py | 50 ++++++++++++++++++++++++ tests/test_mcp.py | 28 ++++++++++++++ tests/test_runtime_mcp.py | 49 +++++++++++++++++++++++ tests/test_schema.py | 18 +++++++++ 8 files changed, 257 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index bcb3ebe..1a21ae3 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,8 @@ tools: params: search: scope: {value: team-handbook} # searched within this scope only + only: + search: [query] # and nothing else the server takes ``` The argument is taken out of the schema the model is shown, and every call @@ -189,6 +191,18 @@ schema no longer declares it — renamed on the server, say — is not offered, and the log says which tool and argument, rather than the value riding under a name the server ignores. +Fixing one argument leaves every other argument the tool takes, and some +may widen what the fixed one bounds, including one the server adds +later. `only` names the arguments the model may write, as it +does for an `openapi:` block. Everything else is taken out of the +schema the model is shown and dropped from each call, even when the model +writes it; a `params` value still rides, and `validate` refuses an argument +that is both listed and fixed. Because the schemas are listed live, naming +what goes out is what keeps a new argument from reaching the model before +anyone has declared it. An argument `only` names that the server does not +declare is logged, and the tool is still offered: nothing is opened up by +the miss. + With more than one engine, name them once in `gete.yaml` and let each agent refer to one by name: diff --git a/src/gete/mcp.py b/src/gete/mcp.py index 5d0d13b..74dbcd6 100644 --- a/src/gete/mcp.py +++ b/src/gete/mcp.py @@ -2,8 +2,9 @@ A server's tool schemas are listed at run time and never packed, so the only thing a declaration can be held against before then is itself: the tool -names it fixes arguments for must be among the ones it offers. What each -tool's schema says is checked where the tools are listed. +names it fixes or limits arguments for must be among the ones it offers, +and no argument may be both fixed and the model's. What each tool's schema +says is checked where the tools are listed. Free of ADK, so validate runs it on machines that never deploy. """ @@ -19,17 +20,30 @@ def declaration_problems(block: Mapping[str, Any]) -> list[str]: Returned messages carry no prefix; the caller adds where they were found. """ - params: Mapping[str, Any] = block.get("params", {}) - if not params: - return [] allow = block.get("allow") - if not allow: - return [ - "params: names tools out of allow, and this block has no allow; " - "a misspelt name would leave the tool it meant as the server lists it" - ] - return [ - f"params: {name!r} is not one of this block's allow" - for name in params - if name not in allow - ] + found: list[str] = [] + for key in ("params", "only"): + named: Mapping[str, Any] = block.get(key, {}) + if not named: + continue + if not allow: + found.append( + f"{key}: names tools out of allow, and this block has no allow; " + "a misspelt name would leave the tool it meant as the server " + "lists it" + ) + continue + found.extend( + f"{key}: {name!r} is not one of this block's allow" + for name in named + if name not in allow + ) + params: Mapping[str, Mapping[str, Any]] = block.get("params", {}) + for tool, names in block.get("only", {}).items(): + found.extend( + f"only.{tool}: {name!r} is fixed by params; a fixed argument is " + "not the model's to write" + for name in names + if name in params.get(tool, {}) + ) + return found diff --git a/src/gete/runtime/mcp.py b/src/gete/runtime/mcp.py index 0ee67de..78377e9 100644 --- a/src/gete/runtime/mcp.py +++ b/src/gete/runtime/mcp.py @@ -51,28 +51,36 @@ def _schema_field(listed: Any) -> str: return "inputSchema" if hasattr(listed, "inputSchema") else "input_schema" -class PinnedMcpTool(McpTool): - """An MCP tool some of whose arguments the declaration fixes. +class NarrowedMcpTool(McpTool): + """An MCP tool whose arguments the declaration narrows. - The fixed arguments are taken out of the schema the model is shown, and - every call carries the declared values over anything the model wrote. + A fixed argument, and with only any argument it does not list, is taken + out of the schema the model is shown. Every call drops what the model + wrote outside only and carries the fixed values over anything else. """ - def __init__(self, listed: McpTool, values: Mapping[str, Any]) -> None: + def __init__( + self, + listed: McpTool, + *, + fixed: Mapping[str, Any], + only: Iterable[str] | None, + ) -> None: # Not McpTool.__init__: its parameters move between ADK releases, and # the listed tool already carries everything it was built with - the # session, the headers, the confirmation setting. self.__dict__.update(listed.__dict__) - self._pinned = dict(values) + self._fixed = dict(fixed) + self._only = frozenset(only) if only is not None else None raw = listed.raw_mcp_tool field = _schema_field(raw) schema = dict(getattr(raw, field)) schema["properties"] = { name: value - for name, value in schema["properties"].items() - if name not in values + for name, value in schema.get("properties", {}).items() + if self._writes(name) } - required = [name for name in schema.get("required", ()) if name not in values] + required = [name for name in schema.get("required", ()) if self._writes(name)] if required: schema["required"] = required else: @@ -81,12 +89,21 @@ def __init__(self, listed: McpTool, values: Mapping[str, Any]) -> None: # check below must find the fixed names there next time too. self._mcp_tool = raw.model_copy(update={field: schema}) + def _writes(self, name: str) -> bool: + """Whether the model writes this argument.""" + return name not in self._fixed and (self._only is None or name in self._only) + @classmethod def holding( - cls, listed: McpTool, values: Mapping[str, Any] - ) -> "PinnedMcpTool | None": - """The listed tool with its fixed arguments held, or None and a log - line when the server's schema does not declare them all. + cls, + listed: McpTool, + *, + fixed: Mapping[str, Any], + only: Iterable[str] | None, + ) -> "NarrowedMcpTool | None": + """The listed tool with its arguments narrowed, or None and a log + line when the server's schema does not declare every fixed one. A + listed one it does not declare is logged, and the tool still offered. validate cannot see a server's schemas, so a renamed argument shows up only here. Offering the tool anyway would send the declared value @@ -96,7 +113,7 @@ def holding( schema = getattr(raw, _schema_field(raw), None) properties = schema.get("properties") if isinstance(schema, Mapping) else None declared = properties if isinstance(properties, Mapping) else {} - missing = [name for name in values if name not in declared] + missing = [name for name in fixed if name not in declared] if missing: logger.warning( "mcp tool %s is not offered: its input schema declares no %s, " @@ -105,13 +122,23 @@ def holding( ", ".join(missing), ) return None - return cls(listed, values) + unknown = [name for name in only or () if name not in declared] + if unknown: + # Nothing the server takes is opened up by the miss; the model + # just cannot write what the declaration meant it to. + logger.warning( + "mcp tool %s: its input schema declares no %s, which only lists", + listed.name, + ", ".join(unknown), + ) + return cls(listed, fixed=fixed, only=only) async def run_async(self, *, args: dict[str, Any], tool_context: Any) -> Any: - # The declared values come last, so a value the model smuggled into - # the arguments under a fixed name is overwritten, never sent. + written = {name: value for name, value in args.items() if self._writes(name)} + # The fixed values come last, so a value the model smuggled into the + # arguments under a fixed name is overwritten, never sent. return await super().run_async( - args={**args, **self._pinned}, tool_context=tool_context + args={**written, **self._fixed}, tool_context=tool_context ) @@ -124,7 +151,7 @@ class GeteMcpToolset(McpToolset): not contacted at all and nothing is offered; asking the user to authorize again belongs to the connection, which may back more than one toolset. ``does_not`` is appended to every tool description, and the tools - ``params`` names come with their fixed arguments held. + ``params`` and ``only`` name come with their arguments narrowed. """ def __init__( @@ -141,6 +168,7 @@ def __init__( confirm_names: Iterable[str] = (), denied: Iterable[str] = (), params: Mapping[str, Mapping[str, Mapping[str, Any]]] | None = None, + only: Mapping[str, Iterable[str]] | None = None, ) -> None: self.url = url self.fixed_headers = dict(fixed_headers) @@ -151,10 +179,11 @@ def __init__( self._authorization_key = authorization_key self._confirm_names = frozenset(confirm_names) self._denied = frozenset(denied) - self._pinned = { + self._fixed = { tool: {name: fix["value"] for name, fix in arguments.items()} for tool, arguments in (params or {}).items() } + self._only = {tool: tuple(names) for tool, names in (only or {}).items()} provider: Callable[[Any], dict[str, str]] | None = ( self._authorization_headers if connection is not None else None ) @@ -185,9 +214,12 @@ async def get_tools(self, readonly_context: Any = None) -> list[Any]: for tool in tools: if tool.name in self._denied: continue - pinned = self._pinned.get(tool.name) - if pinned: - tool = PinnedMcpTool.holding(tool, pinned) + if tool.name in self._fixed or tool.name in self._only: + tool = NarrowedMcpTool.holding( + tool, + fixed=self._fixed.get(tool.name, {}), + only=self._only.get(tool.name), + ) if tool is None: continue if self.does_not: @@ -273,4 +305,5 @@ def mcp_toolset( confirm_names=confirm_names, denied=denied, params=spec.get("params"), + only=spec.get("only"), ) diff --git a/src/gete/schema/agent.json b/src/gete/schema/agent.json index 0b5284b..dba262b 100644 --- a/src/gete/schema/agent.json +++ b/src/gete/schema/agent.json @@ -272,6 +272,19 @@ } } }, + "only": { + "description": "The arguments the model may write, by tool name out of allow. Every other argument is taken out of the schema the model is shown, and dropped from each call even when the model writes it; a params value still rides. A server's schemas are listed while the agent runs, so an argument it adds later stays unexposed until someone declares it.", + "type": "object", + "additionalProperties": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + }, "effect": { "$ref": "#/$defs/effect" }, diff --git a/tests/e2e/test_mcp_e2e.py b/tests/e2e/test_mcp_e2e.py index b1e0768..d11bc15 100644 --- a/tests/e2e/test_mcp_e2e.py +++ b/tests/e2e/test_mcp_e2e.py @@ -140,3 +140,53 @@ async def test_a_fixed_argument_is_sent_over_what_the_model_wrote( "query": "leave policy", "scope": "team-handbook", } + + +async def test_an_argument_only_leaves_out_is_never_sent(server_url: str) -> None: + toolset = mcp_toolset( + { + "url": server_url, + "connection": "freee", + "allow": ["lookup"], + "only": {"lookup": ["query"]}, + }, + authorizations={"freee": "finance-freee"}, + registry=CATALOG, + confirm=False, + ) + context = await tool_context({"finance-freee": TOKEN_A}) + [lookup] = await toolset.get_tools(context) + answer = await lookup.run_async( + args={"query": "leave policy", "scope": "other-team"}, tool_context=context + ) + # The server's own default, so the model's scope never arrived. + assert answer["structuredContent"] == { + "query": "leave policy", + "scope": "everywhere", + } + + +async def test_a_fixed_argument_rides_though_only_does_not_list_it( + server_url: str, +) -> None: + toolset = mcp_toolset( + { + "url": server_url, + "connection": "freee", + "allow": ["lookup"], + "only": {"lookup": ["query"]}, + "params": {"lookup": {"scope": {"value": "team-handbook"}}}, + }, + authorizations={"freee": "finance-freee"}, + registry=CATALOG, + confirm=False, + ) + context = await tool_context({"finance-freee": TOKEN_A}) + [lookup] = await toolset.get_tools(context) + answer = await lookup.run_async( + args={"query": "leave policy"}, tool_context=context + ) + assert answer["structuredContent"] == { + "query": "leave policy", + "scope": "team-handbook", + } diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 26b23b8..602a389 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -31,3 +31,31 @@ def test_params_needs_an_allow_list_to_hold_its_names_against() -> None: "params: names tools out of allow, and this block has no allow; " "a misspelt name would leave the tool it meant as the server lists it" ] + + +def test_only_must_name_a_tool_in_allow() -> None: + found = declaration_problems(block(only={"serach": ["query"]})) + assert found == ["only: 'serach' is not one of this block's allow"] + + +def test_only_needs_an_allow_list_to_hold_its_names_against() -> None: + found = declaration_problems({"url": URL, "only": {"search": ["query"]}}) + assert found == [ + "only: names tools out of allow, and this block has no allow; " + "a misspelt name would leave the tool it meant as the server lists it" + ] + + +def test_an_argument_listed_in_only_and_fixed_by_params_is_refused() -> None: + """Listing says the model writes it, fixing says it never does; the + declaration has to say which.""" + found = declaration_problems( + block( + only={"search": ["query", "scope"]}, + params={"search": {"scope": {"value": "team-handbook"}}}, + ) + ) + assert found == [ + "only.search: 'scope' is fixed by params; a fixed argument is not " + "the model's to write" + ] diff --git a/tests/test_runtime_mcp.py b/tests/test_runtime_mcp.py index 5c08d24..88d8cbf 100644 --- a/tests/test_runtime_mcp.py +++ b/tests/test_runtime_mcp.py @@ -540,3 +540,52 @@ def test_build_refuses_params_for_a_tool_allow_does_not_offer() -> None: misspelt = {**PINNED, "params": {"serach": {"scope": {"value": FIXED_SCOPE}}}} with pytest.raises(DeclarationError, match="'serach' is not one of"): toolset(misspelt) + + +WIDE = ListedTool( + name="search", + description="Search pages.", + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "scope": {"type": "string"}, + "within": {"type": "string"}, + }, + "required": ["query", "within"], + }, +) +LISTED: dict[str, Any] = { + "url": URL, + "allow": ["search"], + "only": {"search": ["query"]}, +} + + +async def test_only_takes_the_unlisted_arguments_out_of_the_declaration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + listing(monkeypatch, WIDE) + [tool] = await toolset(LISTED).get_tools(Context({})) + declared = shown(tool) + assert set(declared["properties"]) == {"query"} + assert declared["required"] == ["query"] + + +async def test_a_listed_argument_the_server_does_not_declare_is_logged( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A miss opens nothing up, so the tool is still offered; the log says + the declaration and the server have drifted apart.""" + listing(monkeypatch, WIDE) + drifted = {**LISTED, "only": {"search": ["query", "keyword"]}} + with caplog.at_level(logging.WARNING): + [tool] = await toolset(drifted).get_tools(Context({})) + assert set(shown(tool)["properties"]) == {"query"} + assert "search" in caplog.text + assert "keyword" in caplog.text + + +def test_build_refuses_only_for_a_tool_allow_does_not_offer() -> None: + with pytest.raises(DeclarationError, match="'serach' is not one of"): + toolset({**LISTED, "only": {"serach": ["query"]}}) diff --git a/tests/test_schema.py b/tests/test_schema.py index 70675d2..1be6b39 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -376,6 +376,17 @@ def test_an_optional_scope_needs_a_non_empty_explanation() -> None: {"scope": {"value": "x", "suffix": "y"}}, ) ), + # An empty only would offer a tool the model can write nothing to. + *( + { + "mcp": { + "url": "https://mcp.example.com/mcp", + "allow": ["search"], + "only": {"search": names}, + } + } + for names in ([], "query", [""], ["query", "query"]) + ), {"python": {"effect": "read"}}, {"python": "no_colon_here"}, # openapi needs the spec, the connection, and a choice of operations. @@ -441,6 +452,13 @@ def test_tool_must_be_exactly_one_supported_kind(tool: dict[str, Any]) -> None: "params": {"search": {"scope": {"value": "team-handbook"}}}, } }, + { + "mcp": { + "url": "https://mcp.example.com/mcp", + "allow": ["search"], + "only": {"search": ["query"]}, + } + }, {"python": "my_agent.agent:TOOLS"}, {"python": {"ref": "my_agent.agent:TOOLS", "effect": "read"}}, {**OPENAPI_TOOL}, From 75549ae5b211d7f4c8bc77900d38a7cc41cb3f88 Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:31:31 +0900 Subject: [PATCH 2/2] Keep away an mcp tool whose required argument only leaves out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When only left out an argument the server's schema requires, and params did not fix it, the tool was offered with the argument dropped from its schema. Every call then went out without it, and the server refused each one, while the model kept being shown a tool it could never use. An openapi block refuses the same declaration in validate; an mcp block can only see the schema once the tools are listed, so that is where the tool is now kept away, with a warning that names the tool and the argument. The warning that only names an argument the server does not declare now goes through the same once-per-toolset path as the others, since it would otherwise repeat on every turn as well. 🤖 Generated with Claude Code --- README.md | 4 +++- src/gete/runtime/mcp.py | 25 +++++++++++++++++------ src/gete/schema/agent.json | 2 +- tests/test_runtime_mcp.py | 42 +++++++++++++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1a21ae3..dd95cee 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,9 @@ that is both listed and fixed. Because the schemas are listed live, naming what goes out is what keeps a new argument from reaching the model before anyone has declared it. An argument `only` names that the server does not declare is logged, and the tool is still offered: nothing is opened up by -the miss. +the miss. A tool whose schema requires an argument that `only` does not +list and `params` does not fix is not offered, and the log says which: +every call would go out without it, and the server would refuse each one. With more than one engine, name them once in `gete.yaml` and let each agent refer to one by name: diff --git a/src/gete/runtime/mcp.py b/src/gete/runtime/mcp.py index f4864ce..2562f3f 100644 --- a/src/gete/runtime/mcp.py +++ b/src/gete/runtime/mcp.py @@ -103,8 +103,9 @@ def holding( warn: Callable[[str], None] = logger.warning, ) -> "NarrowedMcpTool | None": """The listed tool with its arguments narrowed, or None and a - warning when the server's schema does not declare every fixed one. A - listed one it does not declare is logged, and the tool still offered. + warning when the server's schema does not declare every fixed one, + or requires one that is neither listed nor fixed. A listed one it + does not declare is logged, and the tool still offered. validate cannot see a server's schemas, so a renamed argument shows up only here. Offering the tool anyway would send the declared value @@ -121,14 +122,26 @@ def holding( f"declares no {', '.join(missing)}, which the declaration fixes" ) return None + required = schema.get("required") if isinstance(schema, Mapping) else None + unwritable = ( + [name for name in required if name not in only and name not in fixed] + if only is not None and isinstance(required, list) + else [] + ) + if unwritable: + warn( + f"mcp tool {listed.name} is not offered: its input schema " + f"requires {', '.join(map(str, unwritable))}, which only does " + "not list and params does not fix" + ) + return None unknown = [name for name in only or () if name not in declared] if unknown: # Nothing the server takes is opened up by the miss; the model # just cannot write what the declaration meant it to. - logger.warning( - "mcp tool %s: its input schema declares no %s, which only lists", - listed.name, - ", ".join(unknown), + warn( + f"mcp tool {listed.name}: its input schema declares no " + f"{', '.join(unknown)}, which only lists" ) return cls(listed, fixed=fixed, only=only) diff --git a/src/gete/schema/agent.json b/src/gete/schema/agent.json index dba262b..e1debc2 100644 --- a/src/gete/schema/agent.json +++ b/src/gete/schema/agent.json @@ -273,7 +273,7 @@ } }, "only": { - "description": "The arguments the model may write, by tool name out of allow. Every other argument is taken out of the schema the model is shown, and dropped from each call even when the model writes it; a params value still rides. A server's schemas are listed while the agent runs, so an argument it adds later stays unexposed until someone declares it.", + "description": "The arguments the model may write, by tool name out of allow. Every other argument is taken out of the schema the model is shown, and dropped from each call even when the model writes it; a params value still rides. A server's schemas are listed while the agent runs, so an argument it adds later stays unexposed until someone declares it. A tool whose listed schema requires an argument that is neither listed nor fixed is not offered, since every call would be refused.", "type": "object", "additionalProperties": { "type": "array", diff --git a/tests/test_runtime_mcp.py b/tests/test_runtime_mcp.py index 4c4fa46..780d5a6 100644 --- a/tests/test_runtime_mcp.py +++ b/tests/test_runtime_mcp.py @@ -567,7 +567,7 @@ def test_build_refuses_params_for_a_tool_allow_does_not_offer() -> None: "scope": {"type": "string"}, "within": {"type": "string"}, }, - "required": ["query", "within"], + "required": ["query"], }, ) LISTED: dict[str, Any] = { @@ -601,6 +601,46 @@ async def test_a_listed_argument_the_server_does_not_declare_is_logged( assert "keyword" in caplog.text +async def test_a_listed_argument_the_server_does_not_declare_is_logged_once( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + listing(monkeypatch, WIDE) + built = toolset({**LISTED, "only": {"search": ["query", "keyword"]}}) + with caplog.at_level(logging.WARNING): + for _ in range(3): + await built.get_tools(Context({})) + assert len([r for r in caplog.records if "keyword" in r.getMessage()]) == 1 + + +WITHIN_REQUIRED = WIDE.model_copy( + update={"inputSchema": {**WIDE.inputSchema, "required": ["query", "within"]}} +) + + +async def test_a_tool_whose_required_argument_only_leaves_out_is_not_offered( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Every call would go out without an argument the server requires, and + the server would refuse each one; the model would be shown a tool it + can never use.""" + listing(monkeypatch, WITHIN_REQUIRED) + with caplog.at_level(logging.WARNING): + assert await toolset(LISTED).get_tools(Context({})) == [] + assert "search" in caplog.text + assert "within" in caplog.text + + +async def test_a_required_argument_params_fixes_needs_no_listing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + listing(monkeypatch, WITHIN_REQUIRED) + declaration = {**LISTED, "params": {"search": {"within": {"value": "docs"}}}} + [tool] = await toolset(declaration).get_tools(Context({})) + declared = shown(tool) + assert set(declared["properties"]) == {"query"} + assert declared["required"] == ["query"] + + def test_build_refuses_only_for_a_tool_allow_does_not_offer() -> None: with pytest.raises(DeclarationError, match="'serach' is not one of"): toolset({**LISTED, "only": {"serach": ["query"]}})