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
7 changes: 4 additions & 3 deletions src/entrypoints/mcp_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@
* **No output schemas:** this port's tools declare none, so none are
emitted (pre-empts TS's object-rooted-only branch, mcp.ts:106-120).
* **SDK input validation disabled** (``validate_input=False``): the
registry's ``validate_json_schema`` runs inside ``dispatch`` and produces
this port's error text — the analog of TS shaping its own ZodError
message (mcp.ts:231-241) rather than letting the transport layer do it.
registry's ``validate_tool_input`` runs inside ``dispatch`` and produces
``formatZodValidationError``-parity error text — the analog of TS shaping
its own ZodError message (mcp.ts:231-241) rather than letting the
transport layer do it.
* **No provider:** the registry is built with ``provider=None`` — the
serve surface exposes the tool set, not the model stack; agent-spawning
tools report "no provider configured" honestly at call time (divergence
Expand Down
31 changes: 18 additions & 13 deletions src/services/tool_execution/tool_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,19 @@ async def _check_permissions_and_call_tool(
# Mirrors typescript/src/services/tools/toolExecution.ts schema check at
# the head of checkPermissionsAndCallTool. Skipped when the tool has no
# input_schema (defensive — every Tool should declare one).
# ``validate_tool_input`` returns the semantically-coerced input (string
# "true"/"30" → bool/number, the zod preprocess wrappers) and the
# coerced object is what flows onward — TS carries ``parsedInput.data``
# forward (toolExecution.ts:669 → 821), not the raw model input.
if getattr(tool, "input_schema", None):
try:
from src.tool_system.schema_validation import (
build_schema_not_sent_hint,
validate_json_schema,
validate_tool_input,
)

validate_json_schema(
processed_input, tool.input_schema, root_name=tool.name,
processed_input = validate_tool_input(
tool.name, processed_input, tool.input_schema,
)
except Exception as schema_err: # ToolInputError or any subclass
msg = str(schema_err)
Expand Down Expand Up @@ -228,11 +232,12 @@ async def _check_permissions_and_call_tool(
logger.debug("Validation error for %s: %s", tool.name, e)

# ----- Step 6 — Input Backfill (clone, not mutate).
# call() receives the MODEL-ORIGINAL input: tool results embed input
# call() receives the PRE-BACKFILL input (post schema-coercion — TS's
# callInput is likewise parsedInput.data): tool results embed input
# fields verbatim (e.g. "File created successfully at: {path}"), and
# changing them alters the serialized transcript. The cloned,
# backfilled input is the hooks/permissions audience only.
# Mirrors typescript/src/services/tools/toolExecution.ts:838-853.
# backfill mutations (path expansion) would alter the serialized
# transcript. The cloned, backfilled input is the hooks/permissions
# audience only. Mirrors typescript/src/services/tools/toolExecution.ts:838-853.
call_input = processed_input
backfilled_clone: dict[str, Any] | None = None
if tool.backfill_observable_input is not None:
Expand Down Expand Up @@ -337,12 +342,12 @@ async def _check_permissions_and_call_tool(

# If processed_input still points at the backfill clone, no
# hook/permission replaced it — pass the pre-backfill call_input so
# call() sees the model's original field values. Hook/permission
# flows may return a fresh object derived from the backfilled clone
# (e.g. via schema re-parse): if its file_path matches the
# backfill-expanded value, restore the model's original so the tool
# result string embeds the path the model emitted. Other
# modifications flow through unchanged. Mirrors
# call() sees the pre-expansion field values (post schema-coercion).
# Hook/permission flows may return a fresh object derived from the
# backfilled clone (e.g. via schema re-parse): if its file_path
# matches the backfill-expanded value, restore the pre-backfill one
# so the tool result string embeds the path the model emitted.
# Other modifications flow through unchanged. Mirrors
# typescript/src/services/tools/toolExecution.ts:1212-1237.
if (
backfilled_clone is not None
Expand Down
11 changes: 9 additions & 2 deletions src/tool_system/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from .build_tool import Tool, Tools, tool_matches_name
from .context import ToolContext
from .protocol import ToolCall, ToolResult
from .schema_validation import validate_json_schema
from .schema_validation import validate_tool_input
from src.permissions.check import has_permissions_to_use_tool
from src.permissions.handler import handle_permission_ask
from src.permissions.types import (
Expand Down Expand Up @@ -184,7 +184,14 @@ def dispatch(self, call: ToolCall, context: ToolContext) -> ToolResult:
)

context.ensure_tool_allowed(tool.name)
validate_json_schema(call.input, tool.input_schema, root_name=tool.name)
# Semantic-coerce + validate; the coerced input (string "true"/"30" →
# bool/number) replaces the raw model input for permissions and call,
# mirroring TS carrying ``parsedInput.data`` forward.
coerced_input = validate_tool_input(tool.name, call.input, tool.input_schema)
if coerced_input is not call.input:
call = ToolCall(
name=call.name, input=coerced_input, tool_use_id=call.tool_use_id,
)

if tool.validate_input is not None:
validation = tool.validate_input(call.input, context)
Expand Down
Loading
Loading