Add M7.2 MCP client support with McpStep, CLI commands, and stdio transport

This commit is contained in:
rob
2026-07-20 00:20:40 -03:00
parent 6908d8ee6f
commit dee48d8cea
8 changed files with 1279 additions and 3 deletions
+77 -2
View File
@@ -9,11 +9,12 @@ from typing import Optional
import yaml
from .tool import Tool, PromptStep, CodeStep, ToolStep
from .tool import Tool, PromptStep, CodeStep, ToolStep, McpStep
from .providers import call_provider, mock_provider
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec, install_from_registry
from .manifest import load_manifest
from .profiles import load_profile
from .mcp_client import McpClientManager
# Maximum recursion depth for nested tool calls
MAX_TOOL_DEPTH = 10
@@ -352,7 +353,9 @@ def substitute_variables(template: str, variables: dict, warn_non_scalar: bool =
if base_name == "settings":
# Settings dict - leave unchanged (handled specially)
return match.group(0)
# Replace with empty string for None, str() for other values
if isinstance(base_value, (dict, list)):
return json.dumps(base_value)
# Replace with empty string for None, str() for other scalar values
return "" if base_value is None else str(base_value)
# Handle nested access
@@ -901,6 +904,9 @@ def run_tool(
output = substitute_variables(tool.output, variables, warn_non_scalar=verbose)
return output, 0
# Reuse MCP configuration and schema caches across all MCP steps in this run.
mcp_manager = None
# Execute each step
for i, step in enumerate(tool.steps):
if verbose:
@@ -910,6 +916,8 @@ def run_tool(
step_type = "CODE"
elif isinstance(step, ToolStep):
step_type = f"TOOL({step.tool})"
elif isinstance(step, McpStep):
step_type = f"MCP({step.server}/{step.tool})"
else:
step_type = "UNKNOWN"
print(f"[verbose] Step {i+1}: {step_type} -> {{{step.output_var}}}", file=sys.stderr)
@@ -981,12 +989,79 @@ def run_tool(
return "", 3
variables[step.output_var] = output
elif isinstance(step, McpStep):
if verbose or dry_run:
print(f"=== MCP (Step {i+1}) -> {{{step.output_var}}} ===", file=sys.stderr)
print(f" Server: {step.server} Tool: {step.tool}", file=sys.stderr)
print(f" Args: {step.arguments}", file=sys.stderr)
print("=== END MCP ===", file=sys.stderr)
if dry_run:
variables[step.output_var] = f"[DRY RUN - would call mcp:{step.server}/{step.tool}]"
else:
args = _substitute_mcp_args(step.arguments, variables)
try:
if mcp_manager is None:
mcp_manager = McpClientManager()
result = mcp_manager.call_tool(
step.server, step.tool, args,
result_mode=step.result_mode,
)
variables[step.output_var] = result
except ImportError as e:
print(f"{e}", file=sys.stderr)
return "", 4
except Exception as e:
print(f"MCP step failed (server={step.server}, tool={step.tool}): {e}", file=sys.stderr)
return "", 4
# Generate final output
output = substitute_variables(tool.output, variables, warn_non_scalar=verbose)
return output, 0
def _substitute_mcp_args(arguments: dict, variables: dict) -> dict:
"""Substitute variables in MCP arguments, preserving types."""
result = {}
for key, value in arguments.items():
result[key] = _deep_substitute(value, variables)
return result
def _deep_substitute(value, variables: dict):
if isinstance(value, str):
import re
exact_reference = re.fullmatch(
r"\s*\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}\s*",
value,
)
if exact_reference:
path = exact_reference.group(1).split(".")
if path[0] in variables:
resolved = variables[path[0]]
found = True
for key in path[1:]:
if isinstance(resolved, dict) and key in resolved:
resolved = resolved[key]
elif hasattr(resolved, key):
resolved = getattr(resolved, key)
else:
found = False
break
if found:
return resolved
substituted = substitute_variables(value, variables)
return substituted if substituted != value else value
elif isinstance(value, dict):
return {k: _deep_substitute(v, variables) for k, v in value.items()}
elif isinstance(value, list):
return [_deep_substitute(v, variables) for v in value]
return value
def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
"""
Create an argument parser for a tool.