Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -189,6 +191,20 @@ 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. 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:

Expand Down
44 changes: 29 additions & 15 deletions src/gete/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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
90 changes: 67 additions & 23 deletions src/gete/runtime/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -81,16 +89,23 @@ 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],
*,
fixed: Mapping[str, Any],
only: Iterable[str] | None,
warn: Callable[[str], None] = logger.warning,
) -> "PinnedMcpTool | None":
"""The listed tool with its fixed arguments held, or None and a
warning when the server's schema does not declare them all.
) -> "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,
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
Expand All @@ -100,20 +115,42 @@ 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:
warn(
f"mcp tool {listed.name} is not offered: its input schema "
f"declares no {', '.join(missing)}, which the declaration fixes"
)
return None
return cls(listed, values)
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.
warn(
f"mcp tool {listed.name}: its input schema declares no "
f"{', '.join(unknown)}, which only lists"
)
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
)


Expand All @@ -126,7 +163,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__(
Expand All @@ -143,6 +180,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)
Expand All @@ -153,10 +191,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()}
self._warned: set[str] = set()
provider: Callable[[Any], dict[str, str]] | None = (
self._authorization_headers if connection is not None else None
Expand Down Expand Up @@ -188,9 +227,13 @@ 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, warn=self._warn_once)
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),
warn=self._warn_once,
)
if tool is None:
continue
if self.does_not:
Expand Down Expand Up @@ -283,4 +326,5 @@ def mcp_toolset(
confirm_names=confirm_names,
denied=denied,
params=spec.get("params"),
only=spec.get("only"),
)
13 changes: 13 additions & 0 deletions src/gete/schema/agent.json
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1
}
}
},
"effect": {
"$ref": "#/$defs/effect"
},
Expand Down
50 changes: 50 additions & 0 deletions tests/e2e/test_mcp_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
28 changes: 28 additions & 0 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Loading
Loading