From ba45eff5fc61e6c7ec26d55fd4ecacfa0c498823 Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Tue, 16 Sep 2025 14:51:59 +0100 Subject: [PATCH 01/15] cloud elicitation example --- examples/mcp/mcp_elicitation/cloud/README.md | 19 +++++++ examples/mcp/mcp_elicitation/cloud/main.py | 55 +++++++++++++++++++ .../cloud/mcp_agent.config.yaml | 15 +++++ .../cloud/mcp_agent.secrets.yaml.example | 7 +++ .../mcp_elicitation/cloud/requirements.txt | 6 ++ 5 files changed, 102 insertions(+) create mode 100644 examples/mcp/mcp_elicitation/cloud/README.md create mode 100644 examples/mcp/mcp_elicitation/cloud/main.py create mode 100644 examples/mcp/mcp_elicitation/cloud/mcp_agent.config.yaml create mode 100644 examples/mcp/mcp_elicitation/cloud/mcp_agent.secrets.yaml.example create mode 100644 examples/mcp/mcp_elicitation/cloud/requirements.txt diff --git a/examples/mcp/mcp_elicitation/cloud/README.md b/examples/mcp/mcp_elicitation/cloud/README.md new file mode 100644 index 000000000..d51a383d8 --- /dev/null +++ b/examples/mcp/mcp_elicitation/cloud/README.md @@ -0,0 +1,19 @@ +# Deploying the elicitation example to the cloud + +In `mcp_agent.secrets.yaml`, set your OpenAI `api_key`. + +Then, in the current directory (`cloud`), run: + +```bash +uv run mcp-agent deploy elicitation --config-dir . +``` + +Once deployed, you should see an app ID, and a URL in the output. +You can use the URL to access the MCP via e.g. the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). +Add `/sse` to the end of the url, as the MCP is exposed as a server-sent events endpoint. +Do not forget to add an authorization header with your MCP-agent API key as the bearer token. + +The app ID can be used to delete the example again: + +```bash +uv run mcp-agent cloud app delete --id= \ No newline at end of file diff --git a/examples/mcp/mcp_elicitation/cloud/main.py b/examples/mcp/mcp_elicitation/cloud/main.py new file mode 100644 index 000000000..53a54a2cc --- /dev/null +++ b/examples/mcp/mcp_elicitation/cloud/main.py @@ -0,0 +1,55 @@ +import asyncio +import logging +import sys + +from mcp.server.fastmcp import FastMCP, Context +from mcp.server.elicitation import ( + AcceptedElicitation, + DeclinedElicitation, + CancelledElicitation, +) +from pydantic import BaseModel, Field +from mcp_agent.app import MCPApp +from mcp_agent.server.app_server import create_mcp_server_for_app +from mcp_agent.executor.workflow import Workflow, WorkflowResult +from temporalio import workflow + +# Initialize logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = MCPApp( + name="elicitation_demo", + description="Demo of workflow with elicitation" +) + + +# mcp_context for fastmcp context +@app.tool() +async def book_table(date: str, party_size: int, app_ctx: Context) -> str: + """Book a table with confirmation""" + + # Schema must only contain primitive types (str, int, float, bool) + class ConfirmBooking(BaseModel): + confirm: bool = Field(description="Confirm booking?") + notes: str = Field(default="", description="Special requests") + + app.logger.info(f"Confirming the use wants to book a table for {party_size} on {date} via elicitation") + + result = await app.context.upstream_session.elicit( + message=f"Confirm booking for {party_size} on {date}?", + requestedSchema=ConfirmBooking.model_json_schema(), + ) + + app.logger.info(f"Result from confirmation: {result}") + + if result.action == "accept": + data = ConfirmBooking.model_validate(result.content) + if data.confirm: + return f"Booked! Notes: {data.notes or 'None'}" + return "Booking cancelled" + elif result.action == "decline": + return "Booking declined" + elif result.action == "cancel": + return "Booking cancelled" + diff --git a/examples/mcp/mcp_elicitation/cloud/mcp_agent.config.yaml b/examples/mcp/mcp_elicitation/cloud/mcp_agent.config.yaml new file mode 100644 index 000000000..96160a333 --- /dev/null +++ b/examples/mcp/mcp_elicitation/cloud/mcp_agent.config.yaml @@ -0,0 +1,15 @@ +$schema: ../../schema/mcp-agent.config.schema.json + +execution_engine: asyncio +logger: + transports: [file] + level: debug + path_settings: + path_pattern: "logs/mcp-agent-{unique_id}.jsonl" + unique_id: "timestamp" # Options: "timestamp" or "session_id" + timestamp_format: "%Y%m%d_%H%M%S" + +openai: + # Secrets (API keys, etc.) are stored in an mcp_agent.secrets.yaml file which can be gitignored + # default_model: "o3-mini" + default_model: "gpt-4o-mini" diff --git a/examples/mcp/mcp_elicitation/cloud/mcp_agent.secrets.yaml.example b/examples/mcp/mcp_elicitation/cloud/mcp_agent.secrets.yaml.example new file mode 100644 index 000000000..d009bdbd0 --- /dev/null +++ b/examples/mcp/mcp_elicitation/cloud/mcp_agent.secrets.yaml.example @@ -0,0 +1,7 @@ +$schema: ../../schema/mcp-agent.config.schema.json + +openai: + api_key: openai_api_key + +anthropic: + api_key: anthropic_api_key diff --git a/examples/mcp/mcp_elicitation/cloud/requirements.txt b/examples/mcp/mcp_elicitation/cloud/requirements.txt new file mode 100644 index 000000000..7c184f770 --- /dev/null +++ b/examples/mcp/mcp_elicitation/cloud/requirements.txt @@ -0,0 +1,6 @@ +# Core framework dependency +mcp-agent + +# Additional dependencies specific to this example +anthropic +openai From 82d46c9bb9cb9e95b93b9f4cfe435291b771dfe9 Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Tue, 16 Sep 2025 15:17:05 +0100 Subject: [PATCH 02/15] update README --- examples/mcp/mcp_elicitation/cloud/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/mcp/mcp_elicitation/cloud/README.md b/examples/mcp/mcp_elicitation/cloud/README.md index d51a383d8..d1eaf0f94 100644 --- a/examples/mcp/mcp_elicitation/cloud/README.md +++ b/examples/mcp/mcp_elicitation/cloud/README.md @@ -13,7 +13,8 @@ You can use the URL to access the MCP via e.g. the [MCP Inspector](https://githu Add `/sse` to the end of the url, as the MCP is exposed as a server-sent events endpoint. Do not forget to add an authorization header with your MCP-agent API key as the bearer token. -The app ID can be used to delete the example again: +The app ID can be used to delete the example again afterward: ```bash -uv run mcp-agent cloud app delete --id= \ No newline at end of file +uv run mcp-agent cloud app delete --id= +``` \ No newline at end of file From 07677572eab6734101f628d9fb538f0d4856a5da Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Wed, 17 Sep 2025 20:44:50 +0100 Subject: [PATCH 03/15] elicitation/sampling via signals --- examples/mcp/mcp_elicitation/main.py | 1 + .../mcp/mcp_elicitation/temporal/client.py | 289 +++++++++ examples/mcp/mcp_elicitation/temporal/main.py | 126 ++++ .../temporal/mcp_agent.config.yaml | 22 + .../temporal/mcp_agent.secrets.yaml.example | 7 + .../mcp_elicitation/temporal/requirements.txt | 6 + .../mcp/mcp_elicitation/temporal/worker.py | 31 + src/mcp_agent/app.py | 41 +- .../executor/temporal/session_proxy.py | 19 +- .../executor/temporal/system_activities.py | 3 +- src/mcp_agent/executor/workflow.py | 21 + src/mcp_agent/mcp/client_proxy.py | 187 ++++-- src/mcp_agent/mcp/sampling_handler.py | 74 +++ src/mcp_agent/server/app_server.py | 564 +++++++++--------- 14 files changed, 1037 insertions(+), 354 deletions(-) create mode 100644 examples/mcp/mcp_elicitation/temporal/client.py create mode 100644 examples/mcp/mcp_elicitation/temporal/main.py create mode 100644 examples/mcp/mcp_elicitation/temporal/mcp_agent.config.yaml create mode 100644 examples/mcp/mcp_elicitation/temporal/mcp_agent.secrets.yaml.example create mode 100644 examples/mcp/mcp_elicitation/temporal/requirements.txt create mode 100644 examples/mcp/mcp_elicitation/temporal/worker.py diff --git a/examples/mcp/mcp_elicitation/main.py b/examples/mcp/mcp_elicitation/main.py index 78728b880..1dcce620b 100644 --- a/examples/mcp/mcp_elicitation/main.py +++ b/examples/mcp/mcp_elicitation/main.py @@ -15,6 +15,7 @@ ) +@app.tool async def example_usage(): async with app.run() as agent_app: logger = agent_app.logger diff --git a/examples/mcp/mcp_elicitation/temporal/client.py b/examples/mcp/mcp_elicitation/temporal/client.py new file mode 100644 index 000000000..f2ff99c6e --- /dev/null +++ b/examples/mcp/mcp_elicitation/temporal/client.py @@ -0,0 +1,289 @@ +import asyncio +import json +import time +import argparse +from mcp_agent.app import MCPApp +from mcp_agent.config import Settings, LoggerSettings, MCPSettings +import yaml +from mcp_agent.elicitation.handler import console_elicitation_callback +from mcp_agent.config import MCPServerSettings +from mcp_agent.core.context import Context +from mcp_agent.executor.workflow import WorkflowExecution +from mcp_agent.mcp.gen_client import gen_client +from datetime import timedelta +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp import ClientSession +from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession +from mcp.types import CallToolResult, LoggingMessageNotificationParams +from mcp_agent.human_input.handler import console_input_callback +try: + from exceptiongroup import ExceptionGroup as _ExceptionGroup # Python 3.10 backport +except Exception: # pragma: no cover + _ExceptionGroup = None # type: ignore +try: + from anyio import BrokenResourceError as _BrokenResourceError +except Exception: # pragma: no cover + _BrokenResourceError = None # type: ignore + + +async def main(): + # Create MCPApp to get the server registry, with console handlers + # IMPORTANT: This client acts as the “upstream MCP client” for the server. + # When the server requests sampling (sampling/createMessage), the client-side + # MCPApp must be able to service that request locally (approval prompts + LLM call). + # Those client-local flows are not running inside a Temporal workflow, so they + # must use the asyncio executor. If this were set to "temporal", local sampling + # would crash with: "TemporalExecutor.execute must be called from within a workflow". + # + # We programmatically construct Settings here (mirroring examples/basic/mcp_basic_agent/main.py) + # so everything is self-contained in this client: + settings = Settings( + execution_engine="asyncio", + logger=LoggerSettings(level="info"), + mcp=MCPSettings( + servers={ + "basic_agent_server": MCPServerSettings( + name="basic_agent_server", + description="Local workflow server running the basic agent example", + transport="sse", + # Use a routable loopback host; 0.0.0.0 is a bind address, not a client URL + url="http://127.0.0.1:8000/sse", + ) + } + ), + ) + # Load secrets (API keys, etc.) if a secrets file is available and merge into settings. + # We intentionally deep-merge the secrets on top of our base settings so + # credentials are applied without overriding our executor or server endpoint. + try: + secrets_path = Settings.find_secrets() + if secrets_path and secrets_path.exists(): + with open(secrets_path, "r", encoding="utf-8") as f: + secrets_dict = yaml.safe_load(f) or {} + + def _deep_merge(base: dict, overlay: dict) -> dict: + out = dict(base) + for k, v in (overlay or {}).items(): + if k in out and isinstance(out[k], dict) and isinstance(v, dict): + out[k] = _deep_merge(out[k], v) + else: + out[k] = v + return out + + base_dict = settings.model_dump(mode="json") + merged = _deep_merge(base_dict, secrets_dict) + settings = Settings(**merged) + except Exception: + # Best-effort: continue without secrets if parsing fails + pass + app = MCPApp( + name="workflow_mcp_client", + # Disable sampling approval prompts entirely to keep flows non-interactive. + # Elicitation remains interactive via console_elicitation_callback. + human_input_callback=console_input_callback, + elicitation_callback=console_elicitation_callback, + settings=settings, + ) + async with app.run() as client_app: + logger = client_app.logger + context = client_app.context + + # Connect to the workflow server + try: + logger.info("Connecting to workflow server...") + + # Server connection is configured via Settings above (no runtime mutation needed) + + # Connect to the workflow server + # Define a logging callback to receive server-side log notifications + async def on_server_log(params: LoggingMessageNotificationParams) -> None: + # Pretty-print server logs locally for demonstration + level = params.level.upper() + name = params.logger or "server" + # params.data can be any JSON-serializable data + print(f"[SERVER LOG] [{level}] [{name}] {params.data}") + + # Provide a client session factory that installs our logging callback + # and prints non-logging notifications to the console + class ConsolePrintingClientSession(MCPAgentClientSession): + async def _received_notification(self, notification): # type: ignore[override] + try: + method = getattr(notification.root, "method", None) + except Exception: + method = None + + # Avoid duplicating server log prints (handled by logging_callback) + if method and method != "notifications/message": + try: + data = notification.model_dump() + except Exception: + data = str(notification) + print(f"[SERVER NOTIFY] {method}: {data}") + + return await super()._received_notification(notification) + + def make_session( + read_stream: MemoryObjectReceiveStream, + write_stream: MemoryObjectSendStream, + read_timeout_seconds: timedelta | None, + context: Context | None = None, + ) -> ClientSession: + return ConsolePrintingClientSession( + read_stream=read_stream, + write_stream=write_stream, + read_timeout_seconds=read_timeout_seconds, + logging_callback=on_server_log, + context=context, + ) + + # Connect to the workflow server + async with gen_client( + "basic_agent_server", + context.server_registry, + client_session_factory=make_session, + ) as server: + # Ask server to send logs at the requested level (default info) + level = "info" + print(f"[client] Setting server logging level to: {level}") + try: + await server.set_logging_level(level) + except Exception: + # Older servers may not support logging capability + print("[client] Server does not support logging/setLevel") + + # Call the `book_table` tool defined via `@app.tool` + run_result = await server.call_tool( + "book_table", + arguments={ + "date": "today", + "party_size": 2, + "topic": "autumn" + }, + ) + print(f"[client] Workflow run result: {run_result}") + + # Run the `TestWorkflow` workflow... + run_result = await server.call_tool( + "workflows-TestWorkflow-run", + arguments={ + "run_parameters":{ + "args":{ + "date": "today", + "party_size": 2, + "topic": "autumn" + } + } + } + ) + + execution = WorkflowExecution( + **json.loads(run_result.content[0].text) + ) + run_id = execution.run_id + workflow_id = execution.workflow_id + + # and wait for execution to complete + while True: + get_status_result = await server.call_tool( + "workflows-get_status", + arguments={ + "run_id": run_id, + "workflow_id": workflow_id + }, + ) + + workflow_status = _tool_result_to_json(get_status_result) + if workflow_status is None: + logger.error( + f"Failed to parse workflow status response: {get_status_result}" + ) + break + + logger.info( + f"Workflow run {run_id} status:", + data=workflow_status, + ) + + if not workflow_status.get("status"): + logger.error( + f"Workflow run {run_id} status is empty. get_status_result:", + data=get_status_result, + ) + break + + if workflow_status.get("status") == "completed": + logger.info( + f"Workflow run {run_id} completed successfully! Result:", + data=workflow_status.get("result"), + ) + + break + elif workflow_status.get("status") == "error": + logger.error( + f"Workflow run {run_id} failed with error:", + data=workflow_status, + ) + break + elif workflow_status.get("status") == "running": + logger.info( + f"Workflow run {run_id} is still running...", + ) + elif workflow_status.get("status") == "cancelled": + logger.error( + f"Workflow run {run_id} was cancelled.", + data=workflow_status, + ) + break + else: + logger.error( + f"Unknown workflow status: {workflow_status.get('status')}", + data=workflow_status, + ) + break + + await asyncio.sleep(5) + + except Exception as e: + # Tolerate benign shutdown races from SSE client (BrokenResourceError within ExceptionGroup) + if _ExceptionGroup is not None and isinstance(e, _ExceptionGroup): + subs = getattr(e, "exceptions", []) or [] + if ( + _BrokenResourceError is not None + and subs + and all(isinstance(se, _BrokenResourceError) for se in subs) + ): + logger.debug("Ignored BrokenResourceError from SSE shutdown") + else: + raise + elif _BrokenResourceError is not None and isinstance( + e, _BrokenResourceError + ): + logger.debug("Ignored BrokenResourceError from SSE shutdown") + elif "BrokenResourceError" in str(e): + logger.debug( + "Ignored BrokenResourceError from SSE shutdown (string match)" + ) + else: + raise + + +def _tool_result_to_json(tool_result: CallToolResult): + if tool_result.content and len(tool_result.content) > 0: + text = tool_result.content[0].text + try: + # Try to parse the response as JSON if it's a string + import json + + return json.loads(text) + except (json.JSONDecodeError, TypeError): + # If it's not valid JSON, just use the text + return None + + +if __name__ == "__main__": + start = time.time() + asyncio.run(main()) + end = time.time() + t = end - start + + print(f"Total run time: {t:.2f}s") diff --git a/examples/mcp/mcp_elicitation/temporal/main.py b/examples/mcp/mcp_elicitation/temporal/main.py new file mode 100644 index 000000000..6923f1972 --- /dev/null +++ b/examples/mcp/mcp_elicitation/temporal/main.py @@ -0,0 +1,126 @@ +import asyncio +import logging +from typing import Dict, Any + +from mcp.server.fastmcp import Context +import mcp.types as types +from pydantic import BaseModel, Field +from mcp_agent.app import MCPApp +from mcp_agent.server.app_server import create_mcp_server_for_app +from mcp_agent.executor.workflow import Workflow, WorkflowResult +from temporalio import workflow + +# Initialize logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = MCPApp( + name="elicitation_demo", + description="Demo of workflow with elicitation" +) + + +@app.tool() +async def book_table(date: str, party_size: int, topic: str, app_ctx: Context) -> str: + """Book a table with confirmation""" + + app.logger.info(f"Confirming table for {party_size} on {date}") + + class ConfirmBooking(BaseModel): + confirm: bool = Field(description="Confirm booking?") + notes: str = Field(default="", description="Special requests") + + result = await app.context.upstream_session.elicit( + message=f"Confirm booking for {party_size} on {date}?", + requestedSchema=ConfirmBooking.model_json_schema(), + ) + + app.logger.info(f"Result from confirmation: {result}") + + haiku = await app_ctx.upstream_session.create_message( + messages=[ + types.SamplingMessage( + role="user", + content=types.TextContent( + type="text", text=f"Write a haiku about {topic}." + ), + ) + ], + system_prompt="You are a poet.", + max_tokens=80, + model_preferences=types.ModelPreferences( + hints=[types.ModelHint(name="gpt-4o-mini")], + costPriority=0.1, + speedPriority=0.8, + intelligencePriority=0.1, + ), + ) + + app.logger.info(f"Haiku: {haiku.content.text}") + return "Done!" + + +@app.workflow +class TestWorkflow(Workflow[str]): + + @app.workflow_run + async def run(self, args: Dict[str, Any]) -> WorkflowResult[str]: + app_ctx = app.context + + date = args.get("date", "today") + party_size = args.get("party_size", 2) + topic = args.get("topic", "autumn") + + app.logger.info(f"Confirming table for {party_size} on {date}") + + class ConfirmBooking(BaseModel): + confirm: bool = Field(description="Confirm booking?") + notes: str = Field(default="", description="Special requests") + + result = await app.context.upstream_session.elicit( + message=f"Confirm booking for {party_size} on {date}?", + requestedSchema=ConfirmBooking.model_json_schema(), + ) + + app.logger.info(f"Result from confirmation: {result}") + + haiku = await app_ctx.upstream_session.create_message( + messages=[ + types.SamplingMessage( + role="user", + content=types.TextContent( + type="text", text=f"Write a haiku about {topic}." + ), + ) + ], + system_prompt="You are a poet.", + max_tokens=80, + model_preferences=types.ModelPreferences( + hints=[types.ModelHint(name="gpt-4o-mini")], + costPriority=0.1, + speedPriority=0.8, + intelligencePriority=0.1, + ), + ) + + app.logger.info(f"Haiku: {haiku.content.text}") + return WorkflowResult(value="Done!") + + +async def main(): + async with app.run() as agent_app: + # Log registered workflows and agent configurations + logger.info(f"Creating MCP server for {agent_app.name}") + + logger.info("Registered workflows:") + for workflow_id in agent_app.workflows: + logger.info(f" - {workflow_id}") + # Create the MCP server that exposes both workflows and agent configurations + mcp_server = create_mcp_server_for_app(agent_app) + + # Run the server + await mcp_server.run_sse_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/mcp/mcp_elicitation/temporal/mcp_agent.config.yaml b/examples/mcp/mcp_elicitation/temporal/mcp_agent.config.yaml new file mode 100644 index 000000000..3e27f7435 --- /dev/null +++ b/examples/mcp/mcp_elicitation/temporal/mcp_agent.config.yaml @@ -0,0 +1,22 @@ +$schema: ../../schema/mcp-agent.config.schema.json + +execution_engine: temporal + +temporal: + host: "localhost:7233" # Default Temporal server address + namespace: "default" # Default Temporal namespace + task_queue: "mcp-agent" # Task queue for workflows and activities + max_concurrent_activities: 10 # Maximum number of concurrent activities + +logger: + transports: [file] + level: debug + path_settings: + path_pattern: "logs/mcp-agent-{unique_id}.jsonl" + unique_id: "timestamp" # Options: "timestamp" or "session_id" + timestamp_format: "%Y%m%d_%H%M%S" + +openai: + # Secrets (API keys, etc.) are stored in an mcp_agent.secrets.yaml file which can be gitignored + # default_model: "o3-mini" + default_model: "gpt-4o-mini" diff --git a/examples/mcp/mcp_elicitation/temporal/mcp_agent.secrets.yaml.example b/examples/mcp/mcp_elicitation/temporal/mcp_agent.secrets.yaml.example new file mode 100644 index 000000000..d009bdbd0 --- /dev/null +++ b/examples/mcp/mcp_elicitation/temporal/mcp_agent.secrets.yaml.example @@ -0,0 +1,7 @@ +$schema: ../../schema/mcp-agent.config.schema.json + +openai: + api_key: openai_api_key + +anthropic: + api_key: anthropic_api_key diff --git a/examples/mcp/mcp_elicitation/temporal/requirements.txt b/examples/mcp/mcp_elicitation/temporal/requirements.txt new file mode 100644 index 000000000..7c184f770 --- /dev/null +++ b/examples/mcp/mcp_elicitation/temporal/requirements.txt @@ -0,0 +1,6 @@ +# Core framework dependency +mcp-agent + +# Additional dependencies specific to this example +anthropic +openai diff --git a/examples/mcp/mcp_elicitation/temporal/worker.py b/examples/mcp/mcp_elicitation/temporal/worker.py new file mode 100644 index 000000000..39b2a3c67 --- /dev/null +++ b/examples/mcp/mcp_elicitation/temporal/worker.py @@ -0,0 +1,31 @@ +""" +Worker script for the Temporal workflow example. +This script starts a Temporal worker that can execute workflows and activities. +Run this script in a separate terminal window before running the main.py script. + +This leverages the TemporalExecutor's start_worker method to handle the worker setup. +""" + +import asyncio +import logging + + +from mcp_agent.executor.temporal import create_temporal_worker_for_app + +from main import app + +# Initialize logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def main(): + """ + Start a Temporal worker for the example workflows using the app's executor. + """ + async with create_temporal_worker_for_app(app) as worker: + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/mcp_agent/app.py b/src/mcp_agent/app.py index f6a9f8622..41a053a05 100644 --- a/src/mcp_agent/app.py +++ b/src/mcp_agent/app.py @@ -448,6 +448,7 @@ def workflow( decorated_cls = workflow_defn_decorator( cls, sandboxed=False, *args, **kwargs ) + self._workflows[workflow_id] = decorated_cls return decorated_cls else: @@ -649,6 +650,7 @@ async def _invoke_target(workflow_self, *args, **kwargs): return res async def _run(self, *args, **kwargs): # type: ignore[no-redef] + await self.initialize() return await _invoke_target(self, *args, **kwargs) # Decorate run with engine-specific decorator @@ -661,11 +663,33 @@ async def _run(self, *args, **kwargs): # type: ignore[no-redef] else: decorated_run = self.workflow_run(_run) + # Create signal handler for elicitation response + async def _user_response(self, response: dict[str,Any]): + """Signal handler that receives elicitation responses.""" + # Import here to avoid circular dependencies + try: + from temporalio import workflow + from mcp_agent.executor.temporal.session_proxy import _workflow_states + + if workflow.in_workflow(): + workflow_info = workflow.info() + workflow_key = f"{workflow_info.run_id}" + + if workflow_key not in _workflow_states: + _workflow_states[workflow_key] = {} + + _workflow_states[workflow_key]['response_data'] = response + _workflow_states[workflow_key]['response_received'] = True + except ImportError: + # Fallback for non-temporal environments + pass + # Build the Workflow subclass dynamically cls_dict: Dict[str, Any] = { "__doc__": description or (fn.__doc__ or ""), "run": decorated_run, "__mcp_agent_param_source_fn__": fn, + "_user_response": _user_response, } if mark_sync_tool: cls_dict["__mcp_agent_sync_tool__"] = True @@ -674,6 +698,19 @@ async def _run(self, *args, **kwargs): # type: ignore[no-redef] auto_cls = type(f"AutoWorkflow_{workflow_name}", (_Workflow,), cls_dict) + # Apply the workflow signal decorator to the signal handler + try: + signal_handler = getattr(auto_cls, "_user_response") + engine_type = self.config.execution_engine + signal_decorator = self._decorator_registry.get_workflow_signal_decorator( + engine_type + ) + if signal_decorator: + decorated_signal = signal_decorator(name="_user_response")(signal_handler) + setattr(auto_cls, "_user_response", decorated_signal) + except Exception: + pass + # Workaround for Temporal: publish the dynamically created class as a # top-level (module global) so it is not considered a "local class". # Temporal requires workflow classes to be importable from a module. @@ -692,9 +729,9 @@ async def _run(self, *args, **kwargs): # type: ignore[no-redef] # decorate the run method with the engine-specific run decorator. if engine_type == "temporal": try: - run_decorator = self._decorator_registry.get_workflow_run_decorator( + run_decorator = (self._decorator_registry.get_workflow_run_decorator( engine_type - ) + )) if run_decorator: fn_run = getattr(auto_cls, "run") # Ensure method appears as top-level for Temporal diff --git a/src/mcp_agent/executor/temporal/session_proxy.py b/src/mcp_agent/executor/temporal/session_proxy.py index ea4a6e809..8bf9e471f 100644 --- a/src/mcp_agent/executor/temporal/session_proxy.py +++ b/src/mcp_agent/executor/temporal/session_proxy.py @@ -20,6 +20,9 @@ from mcp_agent.executor.temporal.temporal_context import get_execution_id +# Global state for signal handling (will be stored per workflow instance) +_workflow_states: Dict[str, Dict[str, Any]] = {} + class SessionProxy(ServerSession): """ SessionProxy acts like an MCP `ServerSession` for code running under the @@ -116,15 +119,27 @@ async def request( return {"error": "missing_execution_id"} if _in_workflow_runtime(): + from temporalio import workflow act = self._context.task_registry.get_activity("mcp_relay_request") - return await self._executor.execute( + + await self._executor.execute( act, + True, exec_id, method, params or {}, ) + + # Wait for the _elicitation_response signal to be triggered + await workflow.wait_condition( + lambda: _workflow_states.get(exec_id, {}).get('response_received', False) + ) + + return _workflow_states.get(exec_id, {}).get('response_data', {"error": "no_response"}) + + # Non-workflow (activity/asyncio): direct call and wait for result return await self._system_activities.relay_request( - exec_id, method, params or {} + False, exec_id, method, params or {} ) async def send_notification( diff --git a/src/mcp_agent/executor/temporal/system_activities.py b/src/mcp_agent/executor/temporal/system_activities.py index aff8c7f12..9b9ece8f8 100644 --- a/src/mcp_agent/executor/temporal/system_activities.py +++ b/src/mcp_agent/executor/temporal/system_activities.py @@ -90,11 +90,12 @@ async def relay_notify( @activity.defn(name="mcp_relay_request") async def relay_request( - self, execution_id: str, method: str, params: Dict[str, Any] | None = None + self, make_async_call: bool, execution_id: str, method: str, params: Dict[str, Any] | None = None ) -> Dict[str, Any]: gateway_url = getattr(self.context, "gateway_url", None) gateway_token = getattr(self.context, "gateway_token", None) return await request_via_proxy( + make_async_call=make_async_call, execution_id=execution_id, method=method, params=params or {}, diff --git a/src/mcp_agent/executor/workflow.py b/src/mcp_agent/executor/workflow.py index 7e0eed92d..5bb70f394 100644 --- a/src/mcp_agent/executor/workflow.py +++ b/src/mcp_agent/executor/workflow.py @@ -429,6 +429,27 @@ async def cancel(self) -> bool: from temporalio.common import RawValue from typing import Sequence + @workflow.signal() + async def _user_response(self, response: Dict[str, Any]): + """Signal handler that receives user responses.""" + # Import here to avoid circular dependencies + try: + from temporalio import workflow + from mcp_agent.executor.temporal.session_proxy import _workflow_states + + if workflow.in_workflow(): + workflow_info = workflow.info() + workflow_key = f"{workflow_info.run_id}" + + if workflow_key not in _workflow_states: + _workflow_states[workflow_key] = {} + + _workflow_states[workflow_key]['response_data'] = response + _workflow_states[workflow_key]['response_received'] = True + except ImportError: + # Fallback for non-temporal environments + pass + @workflow.signal(dynamic=True) async def _signal_receiver(self, name: str, args: Sequence[RawValue]): """Dynamic signal handler for Temporal workflows.""" diff --git a/src/mcp_agent/mcp/client_proxy.py b/src/mcp_agent/mcp/client_proxy.py index 5f4394e93..605761088 100644 --- a/src/mcp_agent/mcp/client_proxy.py +++ b/src/mcp_agent/mcp/client_proxy.py @@ -7,9 +7,9 @@ def _resolve_gateway_url( - *, - gateway_url: Optional[str] = None, - context_gateway_url: Optional[str] = None, + *, + gateway_url: Optional[str] = None, + context_gateway_url: Optional[str] = None, ) -> str: """Resolve the base URL for the MCP gateway. @@ -37,14 +37,14 @@ def _resolve_gateway_url( async def log_via_proxy( - execution_id: str, - level: str, - namespace: str, - message: str, - data: Dict[str, Any] | None = None, - *, - gateway_url: Optional[str] = None, - gateway_token: Optional[str] = None, + execution_id: str, + level: str, + namespace: str, + message: str, + data: Dict[str, Any] | None = None, + *, + gateway_url: Optional[str] = None, + gateway_token: Optional[str] = None, ) -> bool: base = _resolve_gateway_url(gateway_url=gateway_url, context_gateway_url=None) url = f"{base}/internal/workflows/log" @@ -79,12 +79,12 @@ async def log_via_proxy( async def ask_via_proxy( - execution_id: str, - prompt: str, - metadata: Dict[str, Any] | None = None, - *, - gateway_url: Optional[str] = None, - gateway_token: Optional[str] = None, + execution_id: str, + prompt: str, + metadata: Dict[str, Any] | None = None, + *, + gateway_url: Optional[str] = None, + gateway_token: Optional[str] = None, ) -> Dict[str, Any]: base = _resolve_gateway_url(gateway_url=gateway_url, context_gateway_url=None) url = f"{base}/internal/human/prompts" @@ -116,12 +116,12 @@ async def ask_via_proxy( async def notify_via_proxy( - execution_id: str, - method: str, - params: Dict[str, Any] | None = None, - *, - gateway_url: Optional[str] = None, - gateway_token: Optional[str] = None, + execution_id: str, + method: str, + params: Dict[str, Any] | None = None, + *, + gateway_url: Optional[str] = None, + gateway_token: Optional[str] = None, ) -> bool: base = _resolve_gateway_url(gateway_url=gateway_url, context_gateway_url=None) url = f"{base}/internal/session/by-run/{quote(execution_id, safe='')}/notify" @@ -149,46 +149,109 @@ async def notify_via_proxy( async def request_via_proxy( - execution_id: str, - method: str, - params: Dict[str, Any] | None = None, - *, - gateway_url: Optional[str] = None, - gateway_token: Optional[str] = None, + make_async_call: bool, + execution_id: str, + method: str, + params: Dict[str, Any] | None = None, + *, + gateway_url: Optional[str] = None, + gateway_token: Optional[str] = None, ) -> Dict[str, Any]: - base = _resolve_gateway_url(gateway_url=gateway_url, context_gateway_url=None) - url = f"{base}/internal/session/by-run/{quote(execution_id, safe='')}/request" - headers: Dict[str, str] = {} - tok = gateway_token or os.environ.get("MCP_GATEWAY_TOKEN") - if tok: - headers["X-MCP-Gateway-Token"] = tok - headers["Authorization"] = f"Bearer {tok}" - # Requests require a response; default to no HTTP timeout. - # Configure with MCP_GATEWAY_REQUEST_TIMEOUT (seconds). If unset or <= 0, no timeout is applied. - timeout_str = os.environ.get("MCP_GATEWAY_REQUEST_TIMEOUT") - timeout_float: float | None - if timeout_str is None: - timeout_float = None # no timeout by default; activity timeouts still apply - else: + if make_async_call: + # Make sure we're running in a Temporal workflow context try: - timeout_float = float(str(timeout_str).strip()) - except Exception: + from temporalio import workflow, activity + in_temporal = workflow.in_workflow() + if in_temporal: + workflow_id = workflow.info().workflow_id + else: + in_temporal = activity.in_activity() + if in_temporal: + workflow_id = activity.info().workflow_id + except ImportError: + in_temporal = False + + if not in_temporal: + return {"error": "not_in_workflow_or_activity"} + + from mcp_agent.executor.temporal.session_proxy import _workflow_states + + # Initialize workflow state if not present + if execution_id not in _workflow_states: + _workflow_states[execution_id] = {} + + # Reset the signal response state + _workflow_states[execution_id]['response_data'] = None + _workflow_states[execution_id]['response_received'] = False + + # Make the HTTP request (but don't return the response directly) + base = _resolve_gateway_url(gateway_url=gateway_url, context_gateway_url=None) + url = f"{base}/internal/session/by-run/{workflow_id}/{quote(execution_id, safe='')}/async-request" + headers: Dict[str, str] = {} + tok = gateway_token or os.environ.get("MCP_GATEWAY_TOKEN") + if tok: + headers["X-MCP-Gateway-Token"] = tok + headers["Authorization"] = f"Bearer {tok}" + + timeout_str = os.environ.get("MCP_GATEWAY_REQUEST_TIMEOUT") + timeout_float: float | None + if timeout_str is None: timeout_float = None - try: - # If timeout is None, pass a Timeout object with no limits - if timeout_float is None: - timeout = httpx.Timeout(None) else: - timeout = timeout_float - async with httpx.AsyncClient(timeout=timeout) as client: - r = await client.post( - url, json={"method": method, "params": params or {}}, headers=headers - ) - except httpx.RequestError: - return {"error": "request_failed"} - if r.status_code >= 400: - return {"error": r.text} - try: - return r.json() if r.content else {"error": "invalid_response"} - except ValueError: - return {"error": "invalid_response"} + try: + timeout_float = float(str(timeout_str).strip()) + except Exception: + timeout_float = None + + try: + if timeout_float is None: + timeout = httpx.Timeout(None) + else: + timeout = timeout_float + async with httpx.AsyncClient(timeout=timeout) as client: + r = await client.post( + url, json={"method": method, "params": params or {}}, headers=headers + ) + except httpx.RequestError: + return {"error": "request_failed"} + if r.status_code >= 400: + return {"error": r.text} + else: + # Use original synchronous approach for non-workflow contexts + base = _resolve_gateway_url(gateway_url=gateway_url, context_gateway_url=None) + url = f"{base}/internal/session/by-run/{quote(execution_id, safe='')}/request" + headers: Dict[str, str] = {} + tok = gateway_token or os.environ.get("MCP_GATEWAY_TOKEN") + if tok: + headers["X-MCP-Gateway-Token"] = tok + headers["Authorization"] = f"Bearer {tok}" + # Requests require a response; default to no HTTP timeout. + # Configure with MCP_GATEWAY_REQUEST_TIMEOUT (seconds). If unset or <= 0, no timeout is applied. + timeout_str = os.environ.get("MCP_GATEWAY_REQUEST_TIMEOUT") + timeout_float: float | None + if timeout_str is None: + timeout_float = None # no timeout by default; activity timeouts still apply + else: + try: + timeout_float = float(str(timeout_str).strip()) + except Exception: + timeout_float = None + try: + # If timeout is None, pass a Timeout object with no limits + if timeout_float is None: + timeout = httpx.Timeout(None) + else: + timeout = timeout_float + async with httpx.AsyncClient(timeout=timeout) as client: + r = await client.post( + url, json={"method": method, "params": params or {}}, headers=headers + ) + except httpx.RequestError: + return {"error": "request_failed"} + if r.status_code >= 400: + return {"error": r.text} + + try: + return r.json() if r.content else {"error": "invalid_response"} + except ValueError: + return {"error": "invalid_response"} diff --git a/src/mcp_agent/mcp/sampling_handler.py b/src/mcp_agent/mcp/sampling_handler.py index d3f79f43c..09bf77be6 100644 --- a/src/mcp_agent/mcp/sampling_handler.py +++ b/src/mcp_agent/mcp/sampling_handler.py @@ -30,6 +30,73 @@ from mcp_agent.core.context import Context +def _format_sampling_request_for_human( + params: CreateMessageRequestParams +) -> str: + """Format sampling request for human review""" + messages_text = "" + for i, msg in enumerate(params.messages): + content = ( + msg.content.text if hasattr(msg.content, "text") else str(msg.content) + ) + messages_text += f" Message {i + 1} ({msg.role}): {content[:200]}{'...' if len(content) > 200 else ''}\n" + + system_prompt_display = ( + "None" + if params.systemPrompt is None + else ( + f"{params.systemPrompt[:100]}{'...' if len(params.systemPrompt) > 100 else ''}" + ) + ) + + stop_sequences_display = ( + "None" if params.stopSequences is None else str(params.stopSequences) + ) + + model_preferences_display = "None" + if params.modelPreferences is not None: + prefs = [] + if params.modelPreferences.hints: + hints = [ + hint.name + for hint in params.modelPreferences.hints + if hint.name is not None + ] + prefs.append(f"hints: {hints}") + if params.modelPreferences.costPriority is not None: + prefs.append(f"cost: {params.modelPreferences.costPriority}") + if params.modelPreferences.speedPriority is not None: + prefs.append(f"speed: {params.modelPreferences.speedPriority}") + if params.modelPreferences.intelligencePriority is not None: + prefs.append( + f"intelligence: {params.modelPreferences.intelligencePriority}" + ) + model_preferences_display = ", ".join(prefs) if prefs else "None" + + return f"""REQUEST DETAILS: +- Max Tokens: {params.maxTokens} +- System Prompt: {system_prompt_display} +- Temperature: {params.temperature if params.temperature is not None else 0.7} +- Stop Sequences: {stop_sequences_display} +- Model Preferences: {model_preferences_display} +MESSAGES: +{messages_text}""" + + +def _format_sampling_response_for_human(result: CreateMessageResult) -> str: + """Format sampling response for human review""" + content = ( + result.content.text + if hasattr(result.content, "text") + else str(result.content) + ) + return f"""RESPONSE DETAILS: +- Model: {result.model} +- Role: {result.role} +CONTENT: +{content}""" + + class SamplingHandler(ContextDependent): """Handles MCP sampling requests with optional human approval and LLM generation.""" @@ -89,10 +156,13 @@ async def _human_approve_request( from mcp_agent.human_input.types import HumanInputRequest + request_summary = _format_sampling_request_for_human(params) + req = HumanInputRequest( prompt=( "MCP server requests LLM sampling. Respond 'approve' to proceed, " "anything else to reject (your input will be recorded as reason)." + f"\n\n{request_summary}" ), description="MCP Sampling Request Approval", request_id=f"sampling_request_{uuid4()}", @@ -115,10 +185,14 @@ async def _human_approve_response( from mcp_agent.human_input.types import HumanInputRequest + response_summary = _format_sampling_response_for_human(result) + req = HumanInputRequest( prompt=( "LLM has generated a response. Respond 'approve' to send, " "anything else to reject (your input will be recorded as reason)." + f"\n\n{response_summary}" + ), description="MCP Sampling Response Approval", request_id=f"sampling_response_{uuid4()}", diff --git a/src/mcp_agent/server/app_server.py b/src/mcp_agent/server/app_server.py index 1b4e8b712..52344f0a7 100644 --- a/src/mcp_agent/server/app_server.py +++ b/src/mcp_agent/server/app_server.py @@ -76,8 +76,8 @@ async def _get_session(execution_id: str) -> Any | None: try: logger.debug( ( - f"Lookup session for execution_id={execution_id}: " - + (f"found session_id={id(session)}" if session else "not found") + f"Lookup session for execution_id={execution_id}: " + + (f"found session_id={id(session)}" if session else "not found") ) ) except Exception: @@ -190,7 +190,7 @@ def _set_upstream_from_request_ctx_if_available(ctx: MCPContext) -> None: def _resolve_workflows_and_context( - ctx: MCPContext, + ctx: MCPContext, ) -> Tuple[Dict[str, Type["Workflow"]] | None, Optional["Context"]]: """Resolve the workflows mapping and underlying app context regardless of startup mode. @@ -200,9 +200,9 @@ def _resolve_workflows_and_context( # Try lifespan-provided ServerContext first lifespan_ctx = getattr(ctx.request_context, "lifespan_context", None) if ( - lifespan_ctx is not None - and hasattr(lifespan_ctx, "workflows") - and hasattr(lifespan_ctx, "context") + lifespan_ctx is not None + and hasattr(lifespan_ctx, "workflows") + and hasattr(lifespan_ctx, "context") ): # Ensure upstream session once at resolution time try: @@ -374,23 +374,10 @@ async def _relay_notify(request: Request): except Exception: pass - # Optional shared-secret auth - gw_token = os.environ.get("MCP_GATEWAY_TOKEN") - if gw_token: - bearer = request.headers.get("Authorization", "") - bearer_token = ( - bearer.split(" ", 1)[1] - if bearer.lower().startswith("bearer ") - else "" - ) - header_tok = request.headers.get("X-MCP-Gateway-Token", "") - if not ( - secrets.compare_digest(header_tok, gw_token) - or secrets.compare_digest(bearer_token, gw_token) - ): - return JSONResponse( - {"ok": False, "error": "unauthorized"}, status_code=401 - ) + # Check authentication + auth_error = _check_gateway_auth(request) + if auth_error: + return auth_error # Optional idempotency handling idempotency_key = params.get("idempotency_key") @@ -531,231 +518,259 @@ async def _relay_notify(request: Request): {"ok": False, "error": str(e_mapped)}, status_code=500 ) + # Helper function for shared authentication + def _check_gateway_auth(request: Request) -> JSONResponse | None: + """ + Check optional shared-secret authentication for internal endpoints. + Returns JSONResponse with error if auth fails, None if auth passes. + """ + gw_token = os.environ.get("MCP_GATEWAY_TOKEN") + if not gw_token: + return None # No auth required if no token is set + + bearer = request.headers.get("Authorization", "") + bearer_token = ( + bearer.split(" ", 1)[1] + if bearer.lower().startswith("bearer ") + else "" + ) + header_tok = request.headers.get("X-MCP-Gateway-Token", "") + + if not ( + secrets.compare_digest(header_tok, gw_token) + or secrets.compare_digest(bearer_token, gw_token) + ): + return JSONResponse( + {"ok": False, "error": "unauthorized"}, status_code=401 + ) + + return None # Auth passed + + # Helper functions for request handling + async def _handle_request_via_rpc(session, method: str, params: dict, execution_id: str, + log_prefix: str = "request"): + """Handle request via generic RPC if available.""" + rpc = getattr(session, "rpc", None) + if rpc and hasattr(rpc, "request"): + result = await rpc.request(method, params) + logger.debug(f"[{log_prefix}] delivered via session_id={id(session)} (generic '{method}')") + return result + return None + + async def _handle_specific_request(session, method: str, params: dict, log_prefix: str = "request"): + """Handle specific request types with structured request/response.""" + from mcp.types import ( + CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, + ElicitRequest, ElicitRequestParams, ElicitResult, + ListRootsRequest, ListRootsResult, + PingRequest, EmptyResult, ServerRequest + ) + + if method == "sampling/createMessage": + req = ServerRequest( + CreateMessageRequest(method="sampling/createMessage", params=CreateMessageRequestParams(**params))) + result = await session.send_request(request=req, + result_type=CreateMessageResult) # type: ignore[attr-defined] + return result.model_dump(by_alias=True, mode="json", exclude_none=True) + elif method == "elicitation/create": + req = ServerRequest(ElicitRequest(method="elicitation/create", params=ElicitRequestParams(**params))) + result = await session.send_request(request=req, result_type=ElicitResult) # type: ignore[attr-defined] + return result.model_dump(by_alias=True, mode="json", exclude_none=True) + elif method == "roots/list": + req = ServerRequest(ListRootsRequest(method="roots/list")) + result = await session.send_request(request=req, + result_type=ListRootsResult) # type: ignore[attr-defined] + return result.model_dump(by_alias=True, mode="json", exclude_none=True) + elif method == "ping": + req = ServerRequest(PingRequest(method="ping")) + result = await session.send_request(request=req, result_type=EmptyResult) # type: ignore[attr-defined] + return result.model_dump(by_alias=True, mode="json", exclude_none=True) + else: + raise ValueError(f"unsupported method: {method}") + + async def _try_session_request(session, method: str, params: dict, execution_id: str, + log_prefix: str = "request", register_session: bool = False): + """Try to handle a request via session, with optional registration.""" + try: + # First try generic RPC passthrough + result = await _handle_request_via_rpc(session, method, params, execution_id, log_prefix) + if result is not None: + if register_session: + try: + await _register_session(run_id=execution_id, execution_id=execution_id, session=session) + logger.info( + f"[{log_prefix}] rebound mapping to session_id={id(session)} for execution_id={execution_id}") + except Exception: + pass + return result + + # Fallback to specific structured request handling + result = await _handle_specific_request(session, method, params, log_prefix) + if register_session: + try: + await _register_session(run_id=execution_id, execution_id=execution_id, session=session) + logger.info( + f"[{log_prefix}] rebound mapping to session_id={id(session)} for execution_id={execution_id}") + except Exception: + pass + return result + except Exception as e: + if "unsupported method" in str(e): + raise # Re-raise unsupported method errors + logger.warning( + f"[{log_prefix}] session delivery failed for execution_id={execution_id} method={method}: {e}") + raise + @mcp_server.custom_route( "/internal/session/by-run/{execution_id}/request", methods=["POST"], include_in_schema=False, ) async def _relay_request(request: Request): - from mcp.types import ( - CreateMessageRequest, - CreateMessageRequestParams, - CreateMessageResult, - ElicitRequest, - ElicitRequestParams, - ElicitResult, - ListRootsRequest, - ListRootsResult, - PingRequest, - EmptyResult, - ServerRequest, - ) - body = await request.json() execution_id = request.path_params.get("execution_id") method = body.get("method") params = body.get("params") or {} try: - logger.info( - f"[request] incoming execution_id={execution_id} method={method}" - ) + logger.info(f"[request] incoming execution_id={execution_id} method={method}") except Exception: pass - # Prefer latest upstream session first + # Check authentication + auth_error = _check_gateway_auth(request) + if auth_error: + return auth_error + + # Try latest upstream session first latest_session = _get_fallback_upstream_session() if latest_session is not None: try: - rpc = getattr(latest_session, "rpc", None) - if rpc and hasattr(rpc, "request"): - result = await rpc.request(method, params) - logger.debug( - f"[request] delivered via latest session_id={id(latest_session)} (generic '{method}')" - ) - try: - await _register_session( - run_id=execution_id, - execution_id=execution_id, - session=latest_session, - ) - logger.info( - f"[request] rebound mapping to latest session_id={id(latest_session)} for execution_id={execution_id}" - ) - except Exception: - pass - return JSONResponse(result) - # If latest_session lacks rpc.request, try a limited mapping path - if method == "sampling/createMessage": - req = ServerRequest( - CreateMessageRequest( - method="sampling/createMessage", - params=CreateMessageRequestParams(**params), - ) - ) - result = await latest_session.send_request( # type: ignore[attr-defined] - request=req, - result_type=CreateMessageResult, - ) - try: - await _register_session( - run_id=execution_id, - execution_id=execution_id, - session=latest_session, - ) - except Exception: - pass - return JSONResponse( - result.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - ) - elif method == "elicitation/create": - req = ServerRequest( - ElicitRequest( - method="elicitation/create", - params=ElicitRequestParams(**params), - ) - ) - result = await latest_session.send_request( # type: ignore[attr-defined] - request=req, - result_type=ElicitResult, - ) - try: - await _register_session( - run_id=execution_id, - execution_id=execution_id, - session=latest_session, - ) - except Exception: - pass - return JSONResponse( - result.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - ) - elif method == "roots/list": - req = ServerRequest(ListRootsRequest(method="roots/list")) - result = await latest_session.send_request( # type: ignore[attr-defined] - request=req, - result_type=ListRootsResult, - ) - try: - await _register_session( - run_id=execution_id, - execution_id=execution_id, - session=latest_session, - ) - except Exception: - pass - return JSONResponse( - result.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - ) - elif method == "ping": - req = ServerRequest(PingRequest(method="ping")) - result = await latest_session.send_request( # type: ignore[attr-defined] - request=req, - result_type=EmptyResult, - ) - try: - await _register_session( - run_id=execution_id, - execution_id=execution_id, - session=latest_session, - ) - except Exception: - pass - return JSONResponse( - result.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - ) - except Exception as e_latest: - logger.warning( - f"[request] latest session delivery failed for execution_id={execution_id} method={method}: {e_latest}" + result = await _try_session_request( + latest_session, method, params, execution_id, + log_prefix="request", register_session=True ) + return JSONResponse(result) + except Exception as e_latest: + # Only log and continue to fallback if it's not an unsupported method error + if "unsupported method" not in str(e_latest): + logger.warning( + f"[request] latest session delivery failed for execution_id={execution_id} method={method}: {e_latest}") # Fallback to mapped session session = await _get_session(execution_id) if not session: - logger.warning( - f"[request] session_not_available for execution_id={execution_id}" - ) + logger.warning(f"[request] session_not_available for execution_id={execution_id}") return JSONResponse({"error": "session_not_available"}, status_code=503) try: - # Prefer generic request passthrough if available - rpc = getattr(session, "rpc", None) - if rpc and hasattr(rpc, "request"): - result = await rpc.request(method, params) - try: - logger.debug( - f"[request] forwarded generic request '{method}' to session_id={id(session)}" - ) - except Exception: - pass - return JSONResponse(result) - # Fallback: Map a small set of supported server->client requests - if method == "sampling/createMessage": - req = ServerRequest( - CreateMessageRequest( - method="sampling/createMessage", - params=CreateMessageRequestParams(**params), - ) - ) - result = await session.send_request( # type: ignore[attr-defined] - request=req, - result_type=CreateMessageResult, - ) - return JSONResponse( - result.model_dump(by_alias=True, mode="json", exclude_none=True) - ) - elif method == "elicitation/create": - req = ServerRequest( - ElicitRequest( - method="elicitation/create", - params=ElicitRequestParams(**params), - ) - ) - result = await session.send_request( # type: ignore[attr-defined] - request=req, - result_type=ElicitResult, - ) - return JSONResponse( - result.model_dump(by_alias=True, mode="json", exclude_none=True) - ) - elif method == "roots/list": - req = ServerRequest(ListRootsRequest(method="roots/list")) - result = await session.send_request( # type: ignore[attr-defined] - request=req, - result_type=ListRootsResult, - ) - return JSONResponse( - result.model_dump(by_alias=True, mode="json", exclude_none=True) - ) - elif method == "ping": - req = ServerRequest(PingRequest(method="ping")) - result = await session.send_request( # type: ignore[attr-defined] - request=req, - result_type=EmptyResult, - ) - return JSONResponse( - result.model_dump(by_alias=True, mode="json", exclude_none=True) - ) - else: - return JSONResponse( - {"error": f"unsupported method: {method}"}, status_code=400 - ) + result = await _try_session_request( + session, method, params, execution_id, + log_prefix="request", register_session=False + ) + return JSONResponse(result) except Exception as e: + if "unsupported method" in str(e): + return JSONResponse({"error": f"unsupported method: {method}"}, status_code=400) try: - logger.error( - f"[request] error forwarding for execution_id={execution_id} method={method}: {e}" - ) + logger.error(f"[request] error forwarding for execution_id={execution_id} method={method}: {e}") except Exception: pass return JSONResponse({"error": str(e)}, status_code=500) + @mcp_server.custom_route( + "/internal/session/by-run/{workflow_id}/{execution_id}/async-request", + methods=["POST"], + include_in_schema=False, + ) + async def _async_relay_request(request: Request): + body = await request.json() + execution_id = request.path_params.get("execution_id") + workflow_id = request.path_params.get("workflow_id") + method = body.get("method") + params = body.get("params") or {} + try: + logger.info(f"[async-request] incoming execution_id={execution_id} method={method}") + except Exception: + pass + + if method != "sampling/createMessage" and method != "elicitation/create": + logger.error(f"async not supported for method {method} ({type(method)})") + return JSONResponse({"error": f"async not supported for method {method}"}, + status_code=405) + + # Check authentication + auth_error = _check_gateway_auth(request) + if auth_error: + return auth_error + + # Create background task to handle the request and signal the workflow + async def _handle_async_request_task(): + try: + result = None + + # Try latest upstream session first + latest_session = _get_fallback_upstream_session() + if latest_session is not None: + try: + result = await _try_session_request( + latest_session, method, params, execution_id, + log_prefix="async-request", register_session=True + ) + except Exception as e_latest: + logger.warning(f"[async-request] latest session delivery failed for execution_id={execution_id} method={method}: {e_latest}") + + # Fallback to mapped session if latest session failed + if result is None: + session = await _get_session(execution_id) + if session: + try: + result = await _try_session_request( + session, method, params, execution_id, + log_prefix="async-request", register_session=False + ) + except Exception as e: + logger.error(f"[async-request] error forwarding for execution_id={execution_id} method={method}: {e}") + result = {"error": str(e)} + else: + logger.warning(f"[async-request] session_not_available for execution_id={execution_id}") + result = {"error": "session_not_available"} + + # Signal the workflow with the result using method-specific signal + try: + from temporalio.client import Client + from temporalio import workflow + + # Try to get Temporal client from the app context + app = _get_attached_app(mcp_server) + if app and app.context and hasattr(app.context, 'executor'): + executor = app.context.executor + if hasattr(executor, 'client'): + client = executor.client + # Find the workflow using execution_id as both workflow_id and run_id + try: + workflow_handle = client.get_workflow_handle( + workflow_id=workflow_id, + run_id=execution_id + ) + + await workflow_handle.signal("_user_response", result) + logger.info(f"[async-request] signaled workflow {execution_id} " + f"with {method} result using signal") + except Exception as signal_error: + logger.warning(f"[async-request] failed to signal workflow {execution_id}:" + f" {signal_error}") + except Exception as e: + logger.error(f"[async-request] failed to signal workflow: {e}") + + except Exception as e: + logger.error(f"[async-request] background task error: {e}") + + # Start the background task + asyncio.create_task(_handle_async_request_task()) + + # Return immediately with 200 status to indicate request was received + return JSONResponse({"status": "received", "execution_id": execution_id, "method": method}) + @mcp_server.custom_route( "/internal/workflows/log", methods=["POST"], include_in_schema=False ) @@ -773,23 +788,10 @@ async def _internal_workflows_log(request: Request): except Exception: pass - # Optional shared-secret auth - gw_token = os.environ.get("MCP_GATEWAY_TOKEN") - if gw_token: - bearer = request.headers.get("Authorization", "") - bearer_token = ( - bearer.split(" ", 1)[1] - if bearer.lower().startswith("bearer ") - else "" - ) - header_tok = request.headers.get("X-MCP-Gateway-Token", "") - if not ( - secrets.compare_digest(header_tok, gw_token) - or secrets.compare_digest(bearer_token, gw_token) - ): - return JSONResponse( - {"ok": False, "error": "unauthorized"}, status_code=401 - ) + # Check authentication + auth_error = _check_gateway_auth(request) + if auth_error: + return auth_error # Prefer latest upstream session first latest_session = _get_fallback_upstream_session() @@ -870,21 +872,10 @@ async def _internal_human_prompts(request: Request): except Exception: pass - # Optional shared-secret auth - gw_token = os.environ.get("MCP_GATEWAY_TOKEN") - if gw_token: - bearer = request.headers.get("Authorization", "") - bearer_token = ( - bearer.split(" ", 1)[1] - if bearer.lower().startswith("bearer ") - else "" - ) - header_tok = request.headers.get("X-MCP-Gateway-Token", "") - if not ( - secrets.compare_digest(header_tok, gw_token) - or secrets.compare_digest(bearer_token, gw_token) - ): - return JSONResponse({"error": "unauthorized"}, status_code=401) + # Check authentication + auth_error = _check_gateway_auth(request) + if auth_error: + return auth_error # Prefer latest upstream session first latest_session = _get_fallback_upstream_session() @@ -996,7 +987,7 @@ async def _internal_human_prompts(request: Request): @lowlevel_server.set_logging_level() async def _set_level( - level: str, + level: str, ) -> None: # mcp.types.LoggingLevel is a Literal[str] try: LoggingConfig.set_min_level(level) @@ -1084,10 +1075,10 @@ async def list_workflow_runs(ctx: MCPContext) -> List[Dict[str, Any]]: @mcp.tool(name="workflows-run") async def run_workflow( - ctx: MCPContext, - workflow_name: str, - run_parameters: Dict[str, Any] | None = None, - **kwargs: Any, + ctx: MCPContext, + workflow_name: str, + run_parameters: Dict[str, Any] | None = None, + **kwargs: Any, ) -> Dict[str, str]: """ Run a workflow with the given name. @@ -1111,9 +1102,9 @@ async def run_workflow( @mcp.tool(name="workflows-get_status") async def get_workflow_status( - ctx: MCPContext, - run_id: str | None = None, - workflow_id: str | None = None, + ctx: MCPContext, + run_id: str | None = None, + workflow_id: str | None = None, ) -> Dict[str, Any]: """ Get the status of a running workflow. @@ -1151,11 +1142,11 @@ async def get_workflow_status( @mcp.tool(name="workflows-resume") async def resume_workflow( - ctx: MCPContext, - run_id: str | None = None, - workflow_id: str | None = None, - signal_name: str | None = "resume", - payload: Dict[str, Any] | None = None, + ctx: MCPContext, + run_id: str | None = None, + workflow_id: str | None = None, + signal_name: str | None = "resume", + payload: Dict[str, Any] | None = None, ) -> bool: """ Resume a paused workflow. @@ -1225,7 +1216,7 @@ async def resume_workflow( @mcp.tool(name="workflows-cancel") async def cancel_workflow( - ctx: MCPContext, run_id: str | None = None, workflow_id: str | None = None + ctx: MCPContext, run_id: str | None = None, workflow_id: str | None = None ) -> bool: """ Cancel a running workflow. @@ -1355,14 +1346,14 @@ def create_declared_function_tools(mcp: FastMCP, server_context: ServerContext): import time async def _wait_for_completion( - ctx: MCPContext, - run_id: str, - *, - workflow_id: str | None = None, - timeout: float | None = None, - registration_grace: float = 1.0, - poll_initial: float = 0.05, - poll_max: float = 1.0, + ctx: MCPContext, + run_id: str, + *, + workflow_id: str | None = None, + timeout: float | None = None, + registration_grace: float = 1.0, + poll_initial: float = 0.05, + poll_max: float = 1.0, ): registry = _resolve_workflow_registry(ctx) if not registry: @@ -1455,8 +1446,8 @@ async def _wrapper(**kwargs): return getattr(result, "value", None) # If status payload returned a dict that looks like WorkflowResult, unwrap safely via 'kind' if ( - isinstance(result, dict) - and result.get("kind") == "workflow_result" + isinstance(result, dict) + and result.get("kind") == "workflow_result" ): return result.get("value") return result @@ -1562,9 +1553,9 @@ async def _async_wrapper(**kwargs): if p.name in ("ctx", "context"): continue if ( - _Ctx is not None - and p.annotation is not inspect._empty - and p.annotation is _Ctx + _Ctx is not None + and p.annotation is not inspect._empty + and p.annotation is _Ctx ): continue params.append(p) @@ -1617,7 +1608,7 @@ async def _adapter(**kw): def create_workflow_specific_tools( - mcp: FastMCP, workflow_name: str, workflow_cls: Type["Workflow"] + mcp: FastMCP, workflow_name: str, workflow_cls: Type["Workflow"] ): """Create specific tools for a given workflow.""" param_source = _get_param_source_function_from_workflow(workflow_cls) @@ -1666,8 +1657,8 @@ def _schema_fn_proxy(*args, **kwargs): """, ) async def run( - ctx: MCPContext, - run_parameters: Dict[str, Any] | None = None, + ctx: MCPContext, + run_parameters: Dict[str, Any] | None = None, ) -> Dict[str, str]: _set_upstream_from_request_ctx_if_available(ctx) return await _workflow_run(ctx, workflow_name, run_parameters) @@ -1677,7 +1668,7 @@ async def run( def _get_server_descriptions( - server_registry: ServerRegistry | None, server_names: List[str] + server_registry: ServerRegistry | None, server_names: List[str] ) -> List: servers: List[dict[str, str]] = [] if server_registry: @@ -1699,7 +1690,7 @@ def _get_server_descriptions( def _get_server_descriptions_as_string( - server_registry: ServerRegistry | None, server_names: List[str] + server_registry: ServerRegistry | None, server_names: List[str] ) -> str: servers = _get_server_descriptions(server_registry, server_names) @@ -1719,10 +1710,10 @@ def _get_server_descriptions_as_string( async def _workflow_run( - ctx: MCPContext, - workflow_name: str, - run_parameters: Dict[str, Any] | None = None, - **kwargs: Any, + ctx: MCPContext, + workflow_name: str, + run_parameters: Dict[str, Any] | None = None, + **kwargs: Any, ) -> Dict[str, str]: # Use Temporal run_id as the routing key for gateway callbacks. # We don't have it until after the workflow is started; we'll register mapping post-start. @@ -1892,7 +1883,7 @@ def _normalize_gateway_url(url: str | None) -> str | None: async def _workflow_status( - ctx: MCPContext, run_id: str | None = None, workflow_id: str | None = None + ctx: MCPContext, run_id: str | None = None, workflow_id: str | None = None ) -> Dict[str, Any]: # Ensure upstream session so status-related logs are forwarded try: @@ -1932,5 +1923,4 @@ async def _workflow_status( return status - # endregion From 840ba6b1a085bc080e2f017669b9c99565062219 Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Wed, 17 Sep 2025 20:51:03 +0100 Subject: [PATCH 04/15] fix lint errors --- examples/mcp/mcp_elicitation/cloud/main.py | 12 +----------- examples/mcp/mcp_elicitation/temporal/client.py | 1 - examples/mcp/mcp_elicitation/temporal/main.py | 1 - src/mcp_agent/server/app_server.py | 2 -- 4 files changed, 1 insertion(+), 15 deletions(-) diff --git a/examples/mcp/mcp_elicitation/cloud/main.py b/examples/mcp/mcp_elicitation/cloud/main.py index 53a54a2cc..296b7303a 100644 --- a/examples/mcp/mcp_elicitation/cloud/main.py +++ b/examples/mcp/mcp_elicitation/cloud/main.py @@ -1,18 +1,8 @@ -import asyncio import logging -import sys -from mcp.server.fastmcp import FastMCP, Context -from mcp.server.elicitation import ( - AcceptedElicitation, - DeclinedElicitation, - CancelledElicitation, -) +from mcp.server.fastmcp import Context from pydantic import BaseModel, Field from mcp_agent.app import MCPApp -from mcp_agent.server.app_server import create_mcp_server_for_app -from mcp_agent.executor.workflow import Workflow, WorkflowResult -from temporalio import workflow # Initialize logging logging.basicConfig(level=logging.INFO) diff --git a/examples/mcp/mcp_elicitation/temporal/client.py b/examples/mcp/mcp_elicitation/temporal/client.py index f2ff99c6e..b6c4d114c 100644 --- a/examples/mcp/mcp_elicitation/temporal/client.py +++ b/examples/mcp/mcp_elicitation/temporal/client.py @@ -1,7 +1,6 @@ import asyncio import json import time -import argparse from mcp_agent.app import MCPApp from mcp_agent.config import Settings, LoggerSettings, MCPSettings import yaml diff --git a/examples/mcp/mcp_elicitation/temporal/main.py b/examples/mcp/mcp_elicitation/temporal/main.py index 6923f1972..eff0901dd 100644 --- a/examples/mcp/mcp_elicitation/temporal/main.py +++ b/examples/mcp/mcp_elicitation/temporal/main.py @@ -8,7 +8,6 @@ from mcp_agent.app import MCPApp from mcp_agent.server.app_server import create_mcp_server_for_app from mcp_agent.executor.workflow import Workflow, WorkflowResult -from temporalio import workflow # Initialize logging logging.basicConfig(level=logging.INFO) diff --git a/src/mcp_agent/server/app_server.py b/src/mcp_agent/server/app_server.py index 6b1a798c5..5e8d586f0 100644 --- a/src/mcp_agent/server/app_server.py +++ b/src/mcp_agent/server/app_server.py @@ -728,8 +728,6 @@ async def _handle_async_request_task(): # Signal the workflow with the result using method-specific signal try: - from temporalio.client import Client - from temporalio import workflow # Try to get Temporal client from the app context app = _get_attached_app(mcp_server) From 7d64c8468b64d408f3f6a75be994f15fcca0088f Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Wed, 17 Sep 2025 21:07:43 +0100 Subject: [PATCH 05/15] fix error message --- src/mcp_agent/server/app_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp_agent/server/app_server.py b/src/mcp_agent/server/app_server.py index 5e8d586f0..ec8ecdae5 100644 --- a/src/mcp_agent/server/app_server.py +++ b/src/mcp_agent/server/app_server.py @@ -685,7 +685,7 @@ async def _async_relay_request(request: Request): pass if method != "sampling/createMessage" and method != "elicitation/create": - logger.error(f"async not supported for method {method} ({type(method)})") + logger.error(f"async not supported for method {method}") return JSONResponse({"error": f"async not supported for method {method}"}, status_code=405) From e3e944617f9d88026070acded4351646a7979880 Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Thu, 18 Sep 2025 14:32:49 +0100 Subject: [PATCH 06/15] review comments --- .../temporal/mcp_agent.config.yaml | 2 +- .../temporal/mcp_agent.secrets.yaml.example | 2 +- .../mcp_elicitation/temporal/requirements.txt | 1 + src/mcp_agent/app.py | 8 +- .../executor/temporal/session_proxy.py | 139 ++++++++++++------ .../executor/temporal/system_activities.py | 2 +- src/mcp_agent/executor/workflow.py | 8 +- src/mcp_agent/mcp/client_proxy.py | 11 +- 8 files changed, 107 insertions(+), 66 deletions(-) diff --git a/examples/mcp/mcp_elicitation/temporal/mcp_agent.config.yaml b/examples/mcp/mcp_elicitation/temporal/mcp_agent.config.yaml index 3e27f7435..186222535 100644 --- a/examples/mcp/mcp_elicitation/temporal/mcp_agent.config.yaml +++ b/examples/mcp/mcp_elicitation/temporal/mcp_agent.config.yaml @@ -1,4 +1,4 @@ -$schema: ../../schema/mcp-agent.config.schema.json +$schema: ../../../../schema/mcp-agent.config.schema.json execution_engine: temporal diff --git a/examples/mcp/mcp_elicitation/temporal/mcp_agent.secrets.yaml.example b/examples/mcp/mcp_elicitation/temporal/mcp_agent.secrets.yaml.example index d009bdbd0..930cf3648 100644 --- a/examples/mcp/mcp_elicitation/temporal/mcp_agent.secrets.yaml.example +++ b/examples/mcp/mcp_elicitation/temporal/mcp_agent.secrets.yaml.example @@ -1,4 +1,4 @@ -$schema: ../../schema/mcp-agent.config.schema.json +$schema: ../../../../schema/mcp-agent.config.schema.json openai: api_key: openai_api_key diff --git a/examples/mcp/mcp_elicitation/temporal/requirements.txt b/examples/mcp/mcp_elicitation/temporal/requirements.txt index 7c184f770..5f239ce9d 100644 --- a/examples/mcp/mcp_elicitation/temporal/requirements.txt +++ b/examples/mcp/mcp_elicitation/temporal/requirements.txt @@ -4,3 +4,4 @@ mcp-agent # Additional dependencies specific to this example anthropic openai +temporalio diff --git a/src/mcp_agent/app.py b/src/mcp_agent/app.py index d6e008e1b..cd6e29781 100644 --- a/src/mcp_agent/app.py +++ b/src/mcp_agent/app.py @@ -670,17 +670,13 @@ async def _user_response(self, response: dict[str,Any]): # Import here to avoid circular dependencies try: from temporalio import workflow - from mcp_agent.executor.temporal.session_proxy import _workflow_states + from mcp_agent.executor.temporal.session_proxy import set_signal_response if workflow.in_workflow(): workflow_info = workflow.info() workflow_key = f"{workflow_info.run_id}" - if workflow_key not in _workflow_states: - _workflow_states[workflow_key] = {} - - _workflow_states[workflow_key]['response_data'] = response - _workflow_states[workflow_key]['response_received'] = True + set_signal_response(workflow_key, response) except ImportError: # Fallback for non-temporal environments pass diff --git a/src/mcp_agent/executor/temporal/session_proxy.py b/src/mcp_agent/executor/temporal/session_proxy.py index 8bf9e471f..b47e37579 100644 --- a/src/mcp_agent/executor/temporal/session_proxy.py +++ b/src/mcp_agent/executor/temporal/session_proxy.py @@ -19,9 +19,60 @@ from mcp_agent.executor.temporal.system_activities import SystemActivities from mcp_agent.executor.temporal.temporal_context import get_execution_id - # Global state for signal handling (will be stored per workflow instance) -_workflow_states: Dict[str, Dict[str, Any]] = {} +# Stores +# run_id -> { +# response_received: bool # Whether a response has been received +# response_data: Any # The actual response data (typically a dict) +# } +_WORKFLOW_SIGNAL_LOCK = asyncio.Lock() +_workflow_signal_states: Dict[str, Dict[str, Any]] = {} + + +def reset_signal_response(execution_id: str) -> None: + """ + Reset the signal response state for a given workflow execution ID, ready to accept a new signal + """ + async with _WORKFLOW_SIGNAL_LOCK: + if execution_id not in _workflow_signal_states: + _workflow_signal_states[execution_id] = {} + + _workflow_signal_states[execution_id]['response_data'] = None + _workflow_signal_states[execution_id]['response_received'] = False + + +def set_signal_response(execution_id: str, data: Any) -> None: + """ + Register that a signal response has been received for a given workflow execution ID. + """ + async with _WORKFLOW_SIGNAL_LOCK: + if execution_id not in _workflow_signal_states: + _workflow_signal_states[execution_id] = {} + + _workflow_signal_states[execution_id]['response_data'] = data + _workflow_signal_states[execution_id]['response_received'] = True + + +def has_signal_response(execution_id: str) -> bool: + """ + Check if a signal response has been received for a given workflow execution ID. + """ + async with _WORKFLOW_SIGNAL_LOCK: + if execution_id not in _workflow_signal_states: + return False + return _workflow_signal_states[execution_id]['response_received'] + + +def get_signal_response(execution_id: str) -> Any: + """ + Retrieve the signal response data for a given workflow execution ID. + """ + async with _WORKFLOW_SIGNAL_LOCK: + if execution_id not in _workflow_signal_states or \ + not _workflow_signal_states[execution_id]['response_received']: + raise RuntimeError("No signal response received yet") + return _workflow_signal_states[execution_id]['response_data'] + class SessionProxy(ServerSession): """ @@ -109,7 +160,7 @@ async def notify(self, method: str, params: Dict[str, Any] | None = None) -> boo return True async def request( - self, method: str, params: Dict[str, Any] | None = None + self, method: str, params: Dict[str, Any] | None = None ) -> Dict[str, Any]: """Send a server->client request and return the client's response. The result is a plain JSON-serializable dict. @@ -119,33 +170,35 @@ async def request( return {"error": "missing_execution_id"} if _in_workflow_runtime(): - from temporalio import workflow act = self._context.task_registry.get_activity("mcp_relay_request") await self._executor.execute( act, - True, exec_id, method, params or {}, + make_async_call=True, # Use the async APIs with signalling for response ) # Wait for the _elicitation_response signal to be triggered - await workflow.wait_condition( - lambda: _workflow_states.get(exec_id, {}).get('response_received', False) + await _twf.wait_condition( + lambda: has_signal_response(exec_id) ) - return _workflow_states.get(exec_id, {}).get('response_data', {"error": "no_response"}) + return get_signal_response(exec_id) # Non-workflow (activity/asyncio): direct call and wait for result return await self._system_activities.relay_request( - False, exec_id, method, params or {} + exec_id, + method, + params or {}, + make_async_call=False, # Do not use the async APIs, but the synchronous ones instead ) async def send_notification( - self, - notification: types.ServerNotification, - related_request_id: types.RequestId | None = None, + self, + notification: types.ServerNotification, + related_request_id: types.RequestId | None = None, ) -> None: root = notification.root params: Dict[str, Any] | None = None @@ -163,10 +216,10 @@ async def send_notification( await self.notify(root.method, params) # type: ignore[attr-defined] async def send_request( - self, - request: types.ServerRequest, - result_type: Type[Any], - metadata: ServerMessageMetadata | None = None, + self, + request: types.ServerRequest, + result_type: Type[Any], + metadata: ServerMessageMetadata | None = None, ) -> Any: root = request.root params: Dict[str, Any] | None = None @@ -186,11 +239,11 @@ async def send_request( return payload async def send_log_message( - self, - level: types.LoggingLevel, - data: Any, - logger: str | None = None, - related_request_id: types.RequestId | None = None, + self, + level: types.LoggingLevel, + data: Any, + logger: str | None = None, + related_request_id: types.RequestId | None = None, ) -> None: """Best-effort log forwarding to the client's UI.""" # Prefer activity-based forwarding inside workflow for determinism @@ -223,12 +276,12 @@ async def send_log_message( await self.notify("notifications/message", params) async def send_progress_notification( - self, - progress_token: str | int, - progress: float, - total: float | None = None, - message: str | None = None, - related_request_id: str | None = None, + self, + progress_token: str | int, + progress: float, + total: float | None = None, + message: str | None = None, + related_request_id: str | None = None, ) -> None: params: Dict[str, Any] = { "progressToken": progress_token, @@ -263,17 +316,17 @@ async def list_roots(self) -> types.ListRootsResult: return types.ListRootsResult.model_validate(result) async def create_message( - self, - messages: List[types.SamplingMessage], - *, - max_tokens: int, - system_prompt: str | None = None, - include_context: types.IncludeContext | None = None, - temperature: float | None = None, - stop_sequences: List[str] | None = None, - metadata: Dict[str, Any] | None = None, - model_preferences: types.ModelPreferences | None = None, - related_request_id: types.RequestId | None = None, + self, + messages: List[types.SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: types.IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: List[str] | None = None, + metadata: Dict[str, Any] | None = None, + model_preferences: types.ModelPreferences | None = None, + related_request_id: types.RequestId | None = None, ) -> types.CreateMessageResult: params: Dict[str, Any] = { "messages": [m.model_dump(by_alias=True, mode="json") for m in messages], @@ -304,10 +357,10 @@ async def create_message( raise RuntimeError(f"sampling/createMessage returned invalid result: {e}") async def elicit( - self, - message: str, - requestedSchema: types.ElicitRequestedSchema, - related_request_id: types.RequestId | None = None, + self, + message: str, + requestedSchema: types.ElicitRequestedSchema, + related_request_id: types.RequestId | None = None, ) -> types.ElicitResult: params: Dict[str, Any] = { "message": message, @@ -340,6 +393,6 @@ async def notify(self, method: str, params: Dict[str, Any] | None = None) -> Non await self._proxy.notify(method, params or {}) async def request( - self, method: str, params: Dict[str, Any] | None = None + self, method: str, params: Dict[str, Any] | None = None ) -> Dict[str, Any]: return await self._proxy.request(method, params or {}) diff --git a/src/mcp_agent/executor/temporal/system_activities.py b/src/mcp_agent/executor/temporal/system_activities.py index 9b9ece8f8..fd383b1be 100644 --- a/src/mcp_agent/executor/temporal/system_activities.py +++ b/src/mcp_agent/executor/temporal/system_activities.py @@ -90,7 +90,7 @@ async def relay_notify( @activity.defn(name="mcp_relay_request") async def relay_request( - self, make_async_call: bool, execution_id: str, method: str, params: Dict[str, Any] | None = None + self, execution_id: str, method: str, params: Dict[str, Any] | None = None, make_async_call: bool = False, ) -> Dict[str, Any]: gateway_url = getattr(self.context, "gateway_url", None) gateway_token = getattr(self.context, "gateway_token", None) diff --git a/src/mcp_agent/executor/workflow.py b/src/mcp_agent/executor/workflow.py index 49b24bc4a..c0246abce 100644 --- a/src/mcp_agent/executor/workflow.py +++ b/src/mcp_agent/executor/workflow.py @@ -435,17 +435,13 @@ async def _user_response(self, response: Dict[str, Any]): # Import here to avoid circular dependencies try: from temporalio import workflow - from mcp_agent.executor.temporal.session_proxy import _workflow_states + from mcp_agent.executor.temporal.session_proxy import set_signal_response if workflow.in_workflow(): workflow_info = workflow.info() workflow_key = f"{workflow_info.run_id}" - if workflow_key not in _workflow_states: - _workflow_states[workflow_key] = {} - - _workflow_states[workflow_key]['response_data'] = response - _workflow_states[workflow_key]['response_received'] = True + set_signal_response(workflow_key, response) except ImportError: # Fallback for non-temporal environments pass diff --git a/src/mcp_agent/mcp/client_proxy.py b/src/mcp_agent/mcp/client_proxy.py index 605761088..cfa10a3ad 100644 --- a/src/mcp_agent/mcp/client_proxy.py +++ b/src/mcp_agent/mcp/client_proxy.py @@ -174,15 +174,10 @@ async def request_via_proxy( if not in_temporal: return {"error": "not_in_workflow_or_activity"} - from mcp_agent.executor.temporal.session_proxy import _workflow_states + from mcp_agent.executor.temporal.session_proxy import reset_signal_response - # Initialize workflow state if not present - if execution_id not in _workflow_states: - _workflow_states[execution_id] = {} - - # Reset the signal response state - _workflow_states[execution_id]['response_data'] = None - _workflow_states[execution_id]['response_received'] = False + # Reset the signal response state, so we're ready to accept a new response signal + reset_signal_response(execution_id) # Make the HTTP request (but don't return the response directly) base = _resolve_gateway_url(gateway_url=gateway_url, context_gateway_url=None) From a6df1f8dfdb76f70ba8f8e1ecd341fef6d7ab70c Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Thu, 18 Sep 2025 15:28:44 +0100 Subject: [PATCH 07/15] make locks properly async --- src/mcp_agent/app.py | 2 +- .../executor/temporal/session_proxy.py | 33 ++++++++++++++----- .../executor/temporal/system_activities.py | 3 +- src/mcp_agent/executor/workflow.py | 2 +- src/mcp_agent/mcp/client_proxy.py | 2 +- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/mcp_agent/app.py b/src/mcp_agent/app.py index 591e7efab..d209a3d3f 100644 --- a/src/mcp_agent/app.py +++ b/src/mcp_agent/app.py @@ -687,7 +687,7 @@ async def _user_response(self, response: dict[str,Any]): workflow_info = workflow.info() workflow_key = f"{workflow_info.run_id}" - set_signal_response(workflow_key, response) + await set_signal_response(workflow_key, response) except ImportError: # Fallback for non-temporal environments pass diff --git a/src/mcp_agent/executor/temporal/session_proxy.py b/src/mcp_agent/executor/temporal/session_proxy.py index b47e37579..72b3a82c3 100644 --- a/src/mcp_agent/executor/temporal/session_proxy.py +++ b/src/mcp_agent/executor/temporal/session_proxy.py @@ -29,7 +29,7 @@ _workflow_signal_states: Dict[str, Dict[str, Any]] = {} -def reset_signal_response(execution_id: str) -> None: +async def reset_signal_response(execution_id: str) -> None: """ Reset the signal response state for a given workflow execution ID, ready to accept a new signal """ @@ -41,7 +41,7 @@ def reset_signal_response(execution_id: str) -> None: _workflow_signal_states[execution_id]['response_received'] = False -def set_signal_response(execution_id: str, data: Any) -> None: +async def set_signal_response(execution_id: str, data: Any) -> None: """ Register that a signal response has been received for a given workflow execution ID. """ @@ -53,7 +53,7 @@ def set_signal_response(execution_id: str, data: Any) -> None: _workflow_signal_states[execution_id]['response_received'] = True -def has_signal_response(execution_id: str) -> bool: +async def has_signal_response(execution_id: str) -> bool: """ Check if a signal response has been received for a given workflow execution ID. """ @@ -63,7 +63,7 @@ def has_signal_response(execution_id: str) -> bool: return _workflow_signal_states[execution_id]['response_received'] -def get_signal_response(execution_id: str) -> Any: +async def get_signal_response(execution_id: str) -> Any: """ Retrieve the signal response data for a given workflow execution ID. """ @@ -74,6 +74,23 @@ def get_signal_response(execution_id: str) -> Any: return _workflow_signal_states[execution_id]['response_data'] +def has_signal_response_sync(execution_id: str) -> bool: + """ + Synchronously check if a signal response has been received for a given workflow execution ID. + This method is safe to use in Temporal wait_condition lambdas since it doesn't use async/await. + + Only reads when the lock is not currently held to minimize race conditions. + Returns False conservatively if the lock is held or if no response is available. + """ + # Only read if lock is not currently held + if _WORKFLOW_SIGNAL_LOCK.locked(): + return False # Conservative: assume no response if lock is held + + if execution_id not in _workflow_signal_states: + return False + return _workflow_signal_states[execution_id]['response_received'] + + class SessionProxy(ServerSession): """ SessionProxy acts like an MCP `ServerSession` for code running under the @@ -174,25 +191,25 @@ async def request( await self._executor.execute( act, + True, # Use the async APIs with signalling for response exec_id, method, params or {}, - make_async_call=True, # Use the async APIs with signalling for response ) # Wait for the _elicitation_response signal to be triggered await _twf.wait_condition( - lambda: has_signal_response(exec_id) + lambda: has_signal_response_sync(exec_id) ) - return get_signal_response(exec_id) + return await get_signal_response(exec_id) # Non-workflow (activity/asyncio): direct call and wait for result return await self._system_activities.relay_request( + False, # Do not use the async APIs, but the synchronous ones instead exec_id, method, params or {}, - make_async_call=False, # Do not use the async APIs, but the synchronous ones instead ) async def send_notification( diff --git a/src/mcp_agent/executor/temporal/system_activities.py b/src/mcp_agent/executor/temporal/system_activities.py index fd383b1be..c4bd6cc85 100644 --- a/src/mcp_agent/executor/temporal/system_activities.py +++ b/src/mcp_agent/executor/temporal/system_activities.py @@ -90,10 +90,11 @@ async def relay_notify( @activity.defn(name="mcp_relay_request") async def relay_request( - self, execution_id: str, method: str, params: Dict[str, Any] | None = None, make_async_call: bool = False, + self, make_async_call: bool, execution_id: str, method: str, params: Dict[str, Any] | None = None ) -> Dict[str, Any]: gateway_url = getattr(self.context, "gateway_url", None) gateway_token = getattr(self.context, "gateway_token", None) + return await request_via_proxy( make_async_call=make_async_call, execution_id=execution_id, diff --git a/src/mcp_agent/executor/workflow.py b/src/mcp_agent/executor/workflow.py index c0246abce..6cb4ff518 100644 --- a/src/mcp_agent/executor/workflow.py +++ b/src/mcp_agent/executor/workflow.py @@ -441,7 +441,7 @@ async def _user_response(self, response: Dict[str, Any]): workflow_info = workflow.info() workflow_key = f"{workflow_info.run_id}" - set_signal_response(workflow_key, response) + await set_signal_response(workflow_key, response) except ImportError: # Fallback for non-temporal environments pass diff --git a/src/mcp_agent/mcp/client_proxy.py b/src/mcp_agent/mcp/client_proxy.py index cfa10a3ad..bc4180460 100644 --- a/src/mcp_agent/mcp/client_proxy.py +++ b/src/mcp_agent/mcp/client_proxy.py @@ -177,7 +177,7 @@ async def request_via_proxy( from mcp_agent.executor.temporal.session_proxy import reset_signal_response # Reset the signal response state, so we're ready to accept a new response signal - reset_signal_response(execution_id) + await reset_signal_response(execution_id) # Make the HTTP request (but don't return the response directly) base = _resolve_gateway_url(gateway_url=gateway_url, context_gateway_url=None) From c98b7d10f4f30b32409511c72556f59be4c5f5d8 Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Thu, 18 Sep 2025 16:02:46 +0100 Subject: [PATCH 08/15] fix overlaoded methods --- src/mcp_agent/workflows/factory.py | 59 +++++++++++++++++------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/src/mcp_agent/workflows/factory.py b/src/mcp_agent/workflows/factory.py index df22718db..3f8044c07 100644 --- a/src/mcp_agent/workflows/factory.py +++ b/src/mcp_agent/workflows/factory.py @@ -72,27 +72,24 @@ def create_llm( model: str | ModelPreferences | None = None, request_params: RequestParams | None = None, context: Context | None = None, -) -> AugmentedLLM: - """ - Create an Augmented LLM from an agent or agent spec. - """ - agent = ( - agent if isinstance(agent, Agent) else agent_from_spec(agent, context=context) - ) +) -> AugmentedLLM: ... - factory = _llm_factory( - provider=provider, - model=model, - request_params=request_params, - context=context, - ) - return factory(agent=agent) +@overload +def create_llm( + agent: str, + server_names: List[str] | None = None, + instruction: str | None = None, + provider: str = "openai", + model: str | ModelPreferences | None = None, + request_params: RequestParams | None = None, + context: Context | None = None, +) -> AugmentedLLM: ... -@overload def create_llm( - agent_name: str, + agent: Agent | AgentSpec | None = None, + agent_name: str | None = None, server_names: List[str] | None = None, instruction: str | None = None, provider: str = "openai", @@ -101,19 +98,31 @@ def create_llm( context: Context | None = None, ) -> AugmentedLLM: """ - Create an Augmented LLM. + Create an Augmented LLM from an agent, agent spec, or agent name. """ + if isinstance(agent_name, str): + # Handle the case where first argument is agent_name (string) + agent_obj = agent_from_spec( + AgentSpec( + name=agent_name, instruction=instruction, server_names=server_names or [] + ), + context=context, + ) + elif isinstance(agent, AgentSpec): + # Handle AgentSpec case + agent_obj = agent_from_spec(agent, context=context) + else: + # Handle Agent case + agent_obj = agent - agent = agent_from_spec( - AgentSpec( - name=agent_name, instruction=instruction, server_names=server_names or [] - ), - context=context, - ) factory = _llm_factory( - provider=provider, model=model, request_params=request_params, context=context + provider=provider, + model=model, + request_params=request_params, + context=context, ) - return factory(agent=agent) + + return factory(agent=agent_obj) async def create_router_llm( From 7a92ffeba39cc34396a3b227484edc5c7bca7c03 Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Thu, 18 Sep 2025 19:26:24 +0100 Subject: [PATCH 09/15] add human_interaction handler using MCP elicitation --- README.md | 12 +- examples/human_input/temporal/README.md | 82 +++++++ examples/human_input/temporal/client.py | 200 ++++++++++++++++++ examples/human_input/temporal/main.py | 83 ++++++++ .../temporal/mcp_agent.config.yaml | 22 ++ .../temporal/mcp_agent.secrets.yaml.example | 7 + .../human_input/temporal/requirements.txt | 7 + examples/human_input/temporal/worker.py | 31 +++ examples/mcp/mcp_elicitation/main.py | 2 +- .../mcp/mcp_elicitation/temporal/client.py | 2 +- examples/mcp_agent_server/asyncio/client.py | 2 +- examples/mcp_agent_server/asyncio/main.py | 2 +- .../temporal/basic_agent_server.py | 2 +- examples/usecases/mcp_realtor_agent/main.py | 2 +- examples/workflows/workflow_swarm/main.py | 2 +- .../mcp_agent_server/elicitation/client.py | 2 +- .../mcp_agent_server/elicitation/server.py | 2 +- .../mcp_agent_server/reference/client.py | 2 +- .../mcp_agent_server/reference/server.py | 2 +- .../{handler.py => console_handler.py} | 0 .../human_input/elicitation_handler.py | 123 +++++++++++ tests/human_input/test_elicitation_handler.py | 159 ++++++++++++++ 22 files changed, 731 insertions(+), 17 deletions(-) create mode 100644 examples/human_input/temporal/README.md create mode 100644 examples/human_input/temporal/client.py create mode 100644 examples/human_input/temporal/main.py create mode 100644 examples/human_input/temporal/mcp_agent.config.yaml create mode 100644 examples/human_input/temporal/mcp_agent.secrets.yaml.example create mode 100644 examples/human_input/temporal/requirements.txt create mode 100644 examples/human_input/temporal/worker.py rename src/mcp_agent/human_input/{handler.py => console_handler.py} (100%) create mode 100644 src/mcp_agent/human_input/elicitation_handler.py create mode 100644 tests/human_input/test_elicitation_handler.py diff --git a/README.md b/README.md index 5ea198009..21f6a5448 100644 --- a/README.md +++ b/README.md @@ -568,16 +568,16 @@ orchestrator = Orchestrator( The [Swarm example](examples/workflows/workflow_swarm/main.py) shows this in action. ```python -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback lost_baggage = SwarmAgent( name="Lost baggage traversal", instruction=lambda context_variables: f""" { - FLY_AIR_AGENT_PROMPT.format( - customer_context=context_variables.get("customer_context", "None"), - flight_context=context_variables.get("flight_context", "None"), - ) + FLY_AIR_AGENT_PROMPT.format( + customer_context=context_variables.get("customer_context", "None"), + flight_context=context_variables.get("flight_context", "None"), + ) }\n Lost baggage policy: policies/lost_baggage_policy.md""", functions=[ escalate_to_agent, @@ -586,7 +586,7 @@ lost_baggage = SwarmAgent( case_resolved, ], server_names=["fetch", "filesystem"], - human_input_callback=console_input_callback, # Request input from the console + human_input_callback=console_input_callback, # Request input from the console ) ``` diff --git a/examples/human_input/temporal/README.md b/examples/human_input/temporal/README.md new file mode 100644 index 000000000..714c1ac18 --- /dev/null +++ b/examples/human_input/temporal/README.md @@ -0,0 +1,82 @@ +# Human interactions in Temporal + +This example demonstrates how to implement human interactions in an MCP running as a Temporal workflow. +Human input can be used for approvals or data entry. +In this case, we ask a human to provide their name, so we can create a personalised greeting. + +## Set up + +First, clone the repo and navigate to the human_input example: + +```bash +git clone https://github.com/lastmile-ai/mcp-agent.git +cd mcp-agent/examples/human_input/temporal +``` + +Install `uv` (if you don’t have it): + +```bash +pip install uv +``` + +## Set up api keys + +In `mcp_agent.secrets.yaml`, set your OpenAI `api_key`. + +## Setting Up Temporal Server + +Before running this example, you need to have a Temporal server running: + +1. Install the Temporal CLI by following the instructions at: https://docs.temporal.io/cli/ + +2. Start a local Temporal server: + ```bash + temporal server start-dev + ``` + +This will start a Temporal server on `localhost:7233` (the default address configured in `mcp_agent.config.yaml`). + +You can use the Temporal Web UI to monitor your workflows by visiting `http://localhost:8233` in your browser. + +## Run locally + +In three separate terminal windows, run the following: + +```bash +# this runs the mcp app +uv run main.py +``` + +```bash +# this runs the temporal worker that will execute the workflows +uv run worker.py +``` + +```bash +# this runs the client +uv run client.py +``` + +You will be prompted for input after the agent makes the initial tool call. + +## Details + +Notice how in `main.py` the `human_input_callback` is set to `elicitation_input_callback`. +This makes sure that human input is sought via elicitation. +In `client.py`, on the other hand, it is set to `console_elicitation_callback`. +This way, the client will prompt for input in the console whenever an upstream request for human input is made. + +The following diagram shows the components involved and the flow of requests and responses. + +```plaintext +┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Temporal │───1──▶│ MCP App │◀──2──▶│ Client │◀──3──▶│ User │ +│ worker │◀──4───│ │ │ │ │ (via console)│ +└──────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +In the diagram, +- (1) uses a HTTPS request to tell the MCP App that the workflow wants to make a request, +- (2) uses the MCP protocol for sending the request to the client and receiving the response, +- (3) uses a console prompt to get the input from the user, and +- (4) uses a Temporal signal to send the response back to the workflow. diff --git a/examples/human_input/temporal/client.py b/examples/human_input/temporal/client.py new file mode 100644 index 000000000..b98df2a59 --- /dev/null +++ b/examples/human_input/temporal/client.py @@ -0,0 +1,200 @@ +import asyncio +import time +from mcp_agent.app import MCPApp +from mcp_agent.config import Settings, LoggerSettings, MCPSettings +import yaml +from mcp_agent.elicitation.handler import console_elicitation_callback +from mcp_agent.config import MCPServerSettings +from mcp_agent.core.context import Context +from mcp_agent.mcp.gen_client import gen_client +from datetime import timedelta +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp import ClientSession +from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession +from mcp.types import CallToolResult, LoggingMessageNotificationParams +from mcp_agent.human_input.console_handler import console_input_callback +try: + from exceptiongroup import ExceptionGroup as _ExceptionGroup # Python 3.10 backport +except Exception: # pragma: no cover + _ExceptionGroup = None # type: ignore +try: + from anyio import BrokenResourceError as _BrokenResourceError +except Exception: # pragma: no cover + _BrokenResourceError = None # type: ignore + + +async def main(): + # Create MCPApp to get the server registry, with console handlers + # IMPORTANT: This client acts as the “upstream MCP client” for the server. + # When the server requests sampling (sampling/createMessage), the client-side + # MCPApp must be able to service that request locally (approval prompts + LLM call). + # Those client-local flows are not running inside a Temporal workflow, so they + # must use the asyncio executor. If this were set to "temporal", local sampling + # would crash with: "TemporalExecutor.execute must be called from within a workflow". + # + # We programmatically construct Settings here (mirroring examples/basic/mcp_basic_agent/main.py) + # so everything is self-contained in this client: + settings = Settings( + execution_engine="asyncio", + logger=LoggerSettings(level="info"), + mcp=MCPSettings( + servers={ + "basic_agent_server": MCPServerSettings( + name="basic_agent_server", + description="Local workflow server running the basic agent example", + transport="sse", + # Use a routable loopback host; 0.0.0.0 is a bind address, not a client URL + url="http://127.0.0.1:8000/sse", + ) + } + ), + ) + # Load secrets (API keys, etc.) if a secrets file is available and merge into settings. + # We intentionally deep-merge the secrets on top of our base settings so + # credentials are applied without overriding our executor or server endpoint. + try: + secrets_path = Settings.find_secrets() + if secrets_path and secrets_path.exists(): + with open(secrets_path, "r", encoding="utf-8") as f: + secrets_dict = yaml.safe_load(f) or {} + + def _deep_merge(base: dict, overlay: dict) -> dict: + out = dict(base) + for k, v in (overlay or {}).items(): + if k in out and isinstance(out[k], dict) and isinstance(v, dict): + out[k] = _deep_merge(out[k], v) + else: + out[k] = v + return out + + base_dict = settings.model_dump(mode="json") + merged = _deep_merge(base_dict, secrets_dict) + settings = Settings(**merged) + except Exception: + # Best-effort: continue without secrets if parsing fails + pass + app = MCPApp( + name="workflow_mcp_client", + # Disable sampling approval prompts entirely to keep flows non-interactive. + # Elicitation remains interactive via console_elicitation_callback. + human_input_callback=console_input_callback, + elicitation_callback=console_elicitation_callback, + settings=settings, + ) + async with app.run() as client_app: + logger = client_app.logger + context = client_app.context + + # Connect to the workflow server + try: + logger.info("Connecting to workflow server...") + + # Server connection is configured via Settings above (no runtime mutation needed) + + # Connect to the workflow server + # Define a logging callback to receive server-side log notifications + async def on_server_log(params: LoggingMessageNotificationParams) -> None: + # Pretty-print server logs locally for demonstration + level = params.level.upper() + name = params.logger or "server" + # params.data can be any JSON-serializable data + print(f"[SERVER LOG] [{level}] [{name}] {params.data}") + + # Provide a client session factory that installs our logging callback + # and prints non-logging notifications to the console + class ConsolePrintingClientSession(MCPAgentClientSession): + async def _received_notification(self, notification): # type: ignore[override] + try: + method = getattr(notification.root, "method", None) + except Exception: + method = None + + # Avoid duplicating server log prints (handled by logging_callback) + if method and method != "notifications/message": + try: + data = notification.model_dump() + except Exception: + data = str(notification) + print(f"[SERVER NOTIFY] {method}: {data}") + + return await super()._received_notification(notification) + + def make_session( + read_stream: MemoryObjectReceiveStream, + write_stream: MemoryObjectSendStream, + read_timeout_seconds: timedelta | None, + context: Context | None = None, + ) -> ClientSession: + return ConsolePrintingClientSession( + read_stream=read_stream, + write_stream=write_stream, + read_timeout_seconds=read_timeout_seconds, + logging_callback=on_server_log, + context=context, + ) + + # Connect to the workflow server + async with gen_client( + "basic_agent_server", + context.server_registry, + client_session_factory=make_session, + ) as server: + # Ask server to send logs at the requested level (default info) + level = "info" + print(f"[client] Setting server logging level to: {level}") + try: + await server.set_logging_level(level) + except Exception: + # Older servers may not support logging capability + print("[client] Server does not support logging/setLevel") + + # Call the `book_table` tool defined via `@app.tool` + run_result = await server.call_tool( + "greet", + arguments={} + ) + print(f"[client] Workflow run result: {run_result}") + except Exception as e: + # Tolerate benign shutdown races from SSE client (BrokenResourceError within ExceptionGroup) + if _ExceptionGroup is not None and isinstance(e, _ExceptionGroup): + subs = getattr(e, "exceptions", []) or [] + if ( + _BrokenResourceError is not None + and subs + and all(isinstance(se, _BrokenResourceError) for se in subs) + ): + logger.debug("Ignored BrokenResourceError from SSE shutdown") + else: + raise + elif _BrokenResourceError is not None and isinstance( + e, _BrokenResourceError + ): + logger.debug("Ignored BrokenResourceError from SSE shutdown") + elif "BrokenResourceError" in str(e): + logger.debug( + "Ignored BrokenResourceError from SSE shutdown (string match)" + ) + else: + raise + + +def _tool_result_to_json(tool_result: CallToolResult): + if tool_result.content and len(tool_result.content) > 0: + text = tool_result.content[0].text + try: + # Try to parse the response as JSON if it's a string + import json + + return json.loads(text) + except (json.JSONDecodeError, TypeError): + # If it's not valid JSON, just use the text + return None + + +if __name__ == "__main__": + start = time.time() + asyncio.run(main()) + end = time.time() + t = end - start + + print(f"Total run time: {t:.2f}s") diff --git a/examples/human_input/temporal/main.py b/examples/human_input/temporal/main.py new file mode 100644 index 000000000..e2858b17d --- /dev/null +++ b/examples/human_input/temporal/main.py @@ -0,0 +1,83 @@ +""" +Example demonstrating how to use the elicitation-based human input handler +for Temporal workflows. + +This example shows how the new handler enables LLMs to request user input +when running in Temporal workflows by routing requests through the MCP +elicitation framework instead of direct console I/O. +""" +import asyncio +from mcp_agent.app import MCPApp +from mcp_agent.human_input.elicitation_handler import elicitation_input_callback + +from mcp_agent.agents.agent import Agent +from mcp_agent.core.context import Context +from mcp_agent.server.app_server import create_mcp_server_for_app +from mcp_agent.workflows.llm.augmented_llm_openai import OpenAIAugmentedLLM + + +# Create a single FastMCPApp instance (which extends MCPApp) +# We don't need to explicitly create a tool for human interaction; providing the human_input_callback will +# automatically create a tool for the agent to use. +app = MCPApp( + name="basic_agent_server", + description="Basic agent server example", + human_input_callback=elicitation_input_callback, # Use elicitation handler for human input in temporal workflows +) + + +@app.tool +async def greet(app_ctx: Context | None = None) -> str: + """ + Run the basic agent workflow using the app.tool decorator to set up the workflow. + The code in this function is run in workflow context. + LLM calls are executed in the activity context. + You can use the app_ctx to access the executor to run activities explicitly. + Functions decorated with @app.workflow_task will be run in activity context. + + Args: + input: none + + Returns: + str: The greeting result from the agent + """ + + app = app_ctx.app + + logger = app.logger + logger.info("[workflow-mode] Running greet_tool") + + greeting_agent = Agent( + name="greeter", + instruction="""You are a friendly assistant.""", + server_names=[], + ) + + async with greeting_agent: + finder_llm = await greeting_agent.attach_llm(OpenAIAugmentedLLM) + + result = await finder_llm.generate_str( + message="Ask the user for their name and greet them.", + ) + logger.info("[workflow-mode] greet_tool agent result", data={"result": result}) + + return result + + +async def main(): + async with app.run() as agent_app: + # Log registered workflows and agent configurations + agent_app.logger.info(f"Creating MCP server for {agent_app.name}") + + agent_app.logger.info("Registered workflows:") + for workflow_id in agent_app.workflows: + agent_app.logger.info(f" - {workflow_id}") + # Create the MCP server that exposes both workflows and agent configurations + mcp_server = create_mcp_server_for_app(agent_app) + + # Run the server + await mcp_server.run_sse_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/human_input/temporal/mcp_agent.config.yaml b/examples/human_input/temporal/mcp_agent.config.yaml new file mode 100644 index 000000000..186222535 --- /dev/null +++ b/examples/human_input/temporal/mcp_agent.config.yaml @@ -0,0 +1,22 @@ +$schema: ../../../../schema/mcp-agent.config.schema.json + +execution_engine: temporal + +temporal: + host: "localhost:7233" # Default Temporal server address + namespace: "default" # Default Temporal namespace + task_queue: "mcp-agent" # Task queue for workflows and activities + max_concurrent_activities: 10 # Maximum number of concurrent activities + +logger: + transports: [file] + level: debug + path_settings: + path_pattern: "logs/mcp-agent-{unique_id}.jsonl" + unique_id: "timestamp" # Options: "timestamp" or "session_id" + timestamp_format: "%Y%m%d_%H%M%S" + +openai: + # Secrets (API keys, etc.) are stored in an mcp_agent.secrets.yaml file which can be gitignored + # default_model: "o3-mini" + default_model: "gpt-4o-mini" diff --git a/examples/human_input/temporal/mcp_agent.secrets.yaml.example b/examples/human_input/temporal/mcp_agent.secrets.yaml.example new file mode 100644 index 000000000..930cf3648 --- /dev/null +++ b/examples/human_input/temporal/mcp_agent.secrets.yaml.example @@ -0,0 +1,7 @@ +$schema: ../../../../schema/mcp-agent.config.schema.json + +openai: + api_key: openai_api_key + +anthropic: + api_key: anthropic_api_key diff --git a/examples/human_input/temporal/requirements.txt b/examples/human_input/temporal/requirements.txt new file mode 100644 index 000000000..5f239ce9d --- /dev/null +++ b/examples/human_input/temporal/requirements.txt @@ -0,0 +1,7 @@ +# Core framework dependency +mcp-agent + +# Additional dependencies specific to this example +anthropic +openai +temporalio diff --git a/examples/human_input/temporal/worker.py b/examples/human_input/temporal/worker.py new file mode 100644 index 000000000..39b2a3c67 --- /dev/null +++ b/examples/human_input/temporal/worker.py @@ -0,0 +1,31 @@ +""" +Worker script for the Temporal workflow example. +This script starts a Temporal worker that can execute workflows and activities. +Run this script in a separate terminal window before running the main.py script. + +This leverages the TemporalExecutor's start_worker method to handle the worker setup. +""" + +import asyncio +import logging + + +from mcp_agent.executor.temporal import create_temporal_worker_for_app + +from main import app + +# Initialize logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def main(): + """ + Start a Temporal worker for the example workflows using the app's executor. + """ + async with create_temporal_worker_for_app(app) as worker: + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/mcp/mcp_elicitation/main.py b/examples/mcp/mcp_elicitation/main.py index 1dcce620b..2d091501f 100644 --- a/examples/mcp/mcp_elicitation/main.py +++ b/examples/mcp/mcp_elicitation/main.py @@ -3,7 +3,7 @@ from mcp_agent.app import MCPApp from mcp_agent.agents.agent import Agent -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp_agent.workflows.llm.augmented_llm_openai import OpenAIAugmentedLLM diff --git a/examples/mcp/mcp_elicitation/temporal/client.py b/examples/mcp/mcp_elicitation/temporal/client.py index b6c4d114c..33cb1aade 100644 --- a/examples/mcp/mcp_elicitation/temporal/client.py +++ b/examples/mcp/mcp_elicitation/temporal/client.py @@ -14,7 +14,7 @@ from mcp import ClientSession from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession from mcp.types import CallToolResult, LoggingMessageNotificationParams -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback try: from exceptiongroup import ExceptionGroup as _ExceptionGroup # Python 3.10 backport except Exception: # pragma: no cover diff --git a/examples/mcp_agent_server/asyncio/client.py b/examples/mcp_agent_server/asyncio/client.py index 6c229098c..3bdee2a95 100644 --- a/examples/mcp_agent_server/asyncio/client.py +++ b/examples/mcp_agent_server/asyncio/client.py @@ -12,7 +12,7 @@ from mcp_agent.executor.workflow import WorkflowExecution from mcp_agent.mcp.gen_client import gen_client from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from rich import print diff --git a/examples/mcp_agent_server/asyncio/main.py b/examples/mcp_agent_server/asyncio/main.py index 0d5350e12..1a863c2a4 100644 --- a/examples/mcp_agent_server/asyncio/main.py +++ b/examples/mcp_agent_server/asyncio/main.py @@ -25,7 +25,7 @@ from mcp_agent.workflows.parallel.parallel_llm import ParallelLLM from mcp_agent.executor.workflow import Workflow, WorkflowResult from mcp_agent.tracing.token_counter import TokenNode -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp_agent.mcp.gen_client import gen_client from mcp_agent.config import MCPServerSettings diff --git a/examples/mcp_agent_server/temporal/basic_agent_server.py b/examples/mcp_agent_server/temporal/basic_agent_server.py index 468a01430..cf0e41840 100644 --- a/examples/mcp_agent_server/temporal/basic_agent_server.py +++ b/examples/mcp_agent_server/temporal/basic_agent_server.py @@ -19,7 +19,7 @@ from mcp_agent.server.app_server import create_mcp_server_for_app from mcp_agent.executor.workflow import Workflow, WorkflowResult from mcp_agent.workflows.llm.augmented_llm_openai import OpenAIAugmentedLLM -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp_agent.mcp.gen_client import gen_client from mcp_agent.config import MCPServerSettings diff --git a/examples/usecases/mcp_realtor_agent/main.py b/examples/usecases/mcp_realtor_agent/main.py index 744618fb7..d8d09681f 100644 --- a/examples/usecases/mcp_realtor_agent/main.py +++ b/examples/usecases/mcp_realtor_agent/main.py @@ -12,7 +12,7 @@ from datetime import datetime from mcp_agent.app import MCPApp from mcp_agent.agents.agent import Agent -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp_agent.workflows.orchestrator.orchestrator import Orchestrator from mcp_agent.workflows.llm.augmented_llm import RequestParams diff --git a/examples/workflows/workflow_swarm/main.py b/examples/workflows/workflow_swarm/main.py index d6c090b31..46813d449 100644 --- a/examples/workflows/workflow_swarm/main.py +++ b/examples/workflows/workflow_swarm/main.py @@ -5,7 +5,7 @@ from mcp_agent.app import MCPApp from mcp_agent.workflows.swarm.swarm import DoneAgent, SwarmAgent from mcp_agent.workflows.swarm.swarm_anthropic import AnthropicSwarm -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback app = MCPApp( name="airline_customer_service", human_input_callback=console_input_callback diff --git a/src/mcp_agent/data/examples/mcp_agent_server/elicitation/client.py b/src/mcp_agent/data/examples/mcp_agent_server/elicitation/client.py index 5bad8e3b2..bbba1167f 100644 --- a/src/mcp_agent/data/examples/mcp_agent_server/elicitation/client.py +++ b/src/mcp_agent/data/examples/mcp_agent_server/elicitation/client.py @@ -16,7 +16,7 @@ from mcp_agent.config import Settings from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession from mcp_agent.mcp.gen_client import gen_client -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import ClientSession diff --git a/src/mcp_agent/data/examples/mcp_agent_server/elicitation/server.py b/src/mcp_agent/data/examples/mcp_agent_server/elicitation/server.py index 57fbcf83a..2243764ab 100644 --- a/src/mcp_agent/data/examples/mcp_agent_server/elicitation/server.py +++ b/src/mcp_agent/data/examples/mcp_agent_server/elicitation/server.py @@ -15,7 +15,7 @@ from mcp_agent.app import MCPApp from mcp_agent.core.context import Context as AppContext from mcp_agent.server.app_server import create_mcp_server_for_app -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp.types import ElicitRequestedSchema from pydantic import BaseModel, Field diff --git a/src/mcp_agent/data/examples/mcp_agent_server/reference/client.py b/src/mcp_agent/data/examples/mcp_agent_server/reference/client.py index 9ed0747fb..da3f639f2 100644 --- a/src/mcp_agent/data/examples/mcp_agent_server/reference/client.py +++ b/src/mcp_agent/data/examples/mcp_agent_server/reference/client.py @@ -19,7 +19,7 @@ from mcp_agent.core.context import Context from mcp_agent.config import Settings from mcp_agent.mcp.gen_client import gen_client -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import ClientSession diff --git a/src/mcp_agent/data/examples/mcp_agent_server/reference/server.py b/src/mcp_agent/data/examples/mcp_agent_server/reference/server.py index 447f8d335..a2aeb8447 100644 --- a/src/mcp_agent/data/examples/mcp_agent_server/reference/server.py +++ b/src/mcp_agent/data/examples/mcp_agent_server/reference/server.py @@ -25,7 +25,7 @@ from mcp_agent.app import MCPApp from mcp_agent.core.context import Context as AppContext from mcp_agent.server.app_server import create_mcp_server_for_app -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp_agent.agents.agent import Agent diff --git a/src/mcp_agent/human_input/handler.py b/src/mcp_agent/human_input/console_handler.py similarity index 100% rename from src/mcp_agent/human_input/handler.py rename to src/mcp_agent/human_input/console_handler.py diff --git a/src/mcp_agent/human_input/elicitation_handler.py b/src/mcp_agent/human_input/elicitation_handler.py new file mode 100644 index 000000000..1aea42360 --- /dev/null +++ b/src/mcp_agent/human_input/elicitation_handler.py @@ -0,0 +1,123 @@ +import asyncio + +import mcp.types as types +from mcp_agent.human_input.types import HumanInputRequest, HumanInputResponse +from mcp_agent.logging.logger import get_logger + +logger = get_logger(__name__) + + +def _create_elicitation_message(request: HumanInputRequest) -> str: + """Convert HumanInputRequest to elicitation message format.""" + message = request.prompt + if request.description: + message = f"{request.description}\n\n{message}" + + return message + + +def _handle_elicitation_response( + result: types.ElicitResult, + request: HumanInputRequest +) -> HumanInputResponse: + """Convert ElicitResult back to HumanInputResponse.""" + request_id = request.request_id or "" + + # Handle different action types + if result.action == "accept": + if result.content and isinstance(result.content, dict): + response_text = result.content.get("response", "") + + # Handle slash commands that might be in the response + response_text = response_text.strip() + if response_text.lower() in ["/decline", "/cancel"]: + return HumanInputResponse(request_id=request_id, response=response_text.lower()) + + return HumanInputResponse(request_id=request_id, response=response_text) + else: + # Fallback if content is not in expected format + return HumanInputResponse(request_id=request_id, response="") + + elif result.action == "decline": + return HumanInputResponse(request_id=request_id, response="decline") + + elif result.action == "cancel": + return HumanInputResponse(request_id=request_id, response="cancel") + + else: + # Unknown action, treat as cancel + logger.warning(f"Unknown elicitation action: {result.action}") + return HumanInputResponse(request_id=request_id, response="cancel") + + +async def elicitation_input_callback(request: HumanInputRequest) -> HumanInputResponse: + """ + Handle human input requests using MCP elicitation. + """ + + # Try to get the context and session proxy + try: + from mcp_agent.core.context import get_current_context + context = get_current_context() + if context is None: + raise RuntimeError("No context available for elicitation") + except Exception: + raise RuntimeError("No context available for elicitation") + + upstream_session = context.upstream_session + + if not upstream_session: + raise RuntimeError("Session required for elicitation") + + try: + message = _create_elicitation_message(request) + + logger.debug( + "Sending elicitation request for human input", + data={ + "request_id": request.request_id, + "description": request.description, + "timeout_seconds": request.timeout_seconds + } + ) + + # Send the elicitation request + result = await upstream_session.elicit( + message=message, + requestedSchema={ + "type": "object", + "properties": { + "response": { + "type": "string", + "description": "The response or input" + } + }, + "required": ["response"] + }, + related_request_id=request.request_id + ) + + # Convert the result back to HumanInputResponse + response = _handle_elicitation_response(result, request) + + logger.debug( + "Received elicitation response for human input", + data={ + "request_id": request.request_id, + "action": result.action, + "response_length": len(response.response) + } + ) + + return response + + except asyncio.TimeoutError: + logger.warning(f"Elicitation timeout for request {request.request_id}") + raise TimeoutError("No response received within timeout period") from None + + except Exception as e: + logger.error( + f"Elicitation failed for human input request {request.request_id}", + data={"error": str(e)} + ) + raise RuntimeError(f"Elicitation failed: {e}") from e diff --git a/tests/human_input/test_elicitation_handler.py b/tests/human_input/test_elicitation_handler.py new file mode 100644 index 000000000..d9686d29b --- /dev/null +++ b/tests/human_input/test_elicitation_handler.py @@ -0,0 +1,159 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock +import mcp.types as types +from mcp_agent.executor.temporal.session_proxy import SessionProxy +from mcp_agent.human_input.types import HumanInputRequest, HumanInputResponse +from mcp_agent.human_input.elicitation_handler import ( + elicitation_input_callback, + _create_elicitation_message, + _handle_elicitation_response +) + + +class TestElicitationHandler: + """Test the elicitation-based human input handler.""" + + def test_create_elicitation_message_basic(self): + """Test basic message creation.""" + request = HumanInputRequest(prompt="Please enter your name") + message = _create_elicitation_message(request) + + assert "Please enter your name" in message + + def test_create_elicitation_message_with_description(self): + """Test message creation with description.""" + request = HumanInputRequest( + prompt="Enter your name", + description="We need your name for the booking" + ) + message = _create_elicitation_message(request) + + assert "We need your name for the booking" in message + assert "Enter your name" in message + + def test_create_elicitation_message_with_timeout(self): + """Test message creation with timeout.""" + request = HumanInputRequest( + prompt="Enter your name", + timeout_seconds=30 + ) + message = _create_elicitation_message(request) + + assert "Enter your name" in message + assert "Timeout" not in message + assert "30" not in message + + def test_handle_elicitation_response_accept(self): + """Test handling accept response.""" + request = HumanInputRequest(prompt="Test", request_id="test-123") + result = types.ElicitResult( + action="accept", + content={"response": "John Doe"} + ) + + response = _handle_elicitation_response(result, request) + + assert isinstance(response, HumanInputResponse) + assert response.request_id == "test-123" + assert response.response == "John Doe" + + def test_handle_elicitation_response_decline(self): + """Test handling decline response.""" + request = HumanInputRequest(prompt="Test", request_id="test-123") + result = types.ElicitResult(action="decline") + + response = _handle_elicitation_response(result, request) + + assert response.request_id == "test-123" + assert response.response == "decline" + + def test_handle_elicitation_response_cancel(self): + """Test handling cancel response.""" + request = HumanInputRequest(prompt="Test", request_id="test-123") + result = types.ElicitResult(action="cancel") + + response = _handle_elicitation_response(result, request) + + assert response.request_id == "test-123" + assert response.response == "cancel" + + + @pytest.mark.asyncio + async def test_elicitation_input_callback_success(self): + """Test successful elicitation callback.""" + # Mock the context and session proxy + mock_context = MagicMock() + mock_session = AsyncMock(spec=SessionProxy) + + # Mock the elicit method to return a successful response + mock_session.elicit.return_value = types.ElicitResult( + action="accept", + content={"response": "Test response"} + ) + + mock_context.upstream_session = mock_session + + # Mock get_current_context() to return our mock context + with pytest.MonkeyPatch.context() as m: + m.setattr("mcp_agent.core.context.get_current_context", lambda: mock_context) + + request = HumanInputRequest( + prompt="Please enter something", + request_id="test-123" + ) + + response = await elicitation_input_callback(request) + + assert isinstance(response, HumanInputResponse) + assert response.request_id == "test-123" + assert response.response == "Test response" + + # Verify the session proxy was called correctly + mock_session.elicit.assert_called_once() + call_args = mock_session.elicit.call_args + assert "Please enter something" in call_args.kwargs["message"] + assert call_args.kwargs["related_request_id"] == "test-123" + + @pytest.mark.asyncio + async def test_elicitation_input_callback_no_context(self): + """Test callback when no context is available.""" + with pytest.MonkeyPatch.context() as m: + m.setattr("mcp_agent.core.context.get_current_context", lambda: None) + + request = HumanInputRequest(prompt="Test") + + with pytest.raises(RuntimeError, match="No context available"): + await elicitation_input_callback(request) + + @pytest.mark.asyncio + async def test_elicitation_input_callback_no_session(self): + """Test callback when SessionProxy is not available.""" + mock_context = MagicMock() + mock_context.upstream_session = None + + with pytest.MonkeyPatch.context() as m: + m.setattr("mcp_agent.core.context.get_current_context", lambda: mock_context) + + request = HumanInputRequest(prompt="Test") + + with pytest.raises(RuntimeError, match="Session required for elicitation"): + await elicitation_input_callback(request) + + @pytest.mark.asyncio + async def test_elicitation_input_callback_elicit_failure(self): + """Test callback when elicitation fails.""" + mock_context = MagicMock() + mock_session = AsyncMock(spec=SessionProxy) + + # Mock the elicit method to raise an exception + mock_session.elicit.side_effect = Exception("Elicitation failed") + + mock_context.upstream_session = mock_session + + with pytest.MonkeyPatch.context() as m: + m.setattr("mcp_agent.core.context.get_current_context", lambda: mock_context) + + request = HumanInputRequest(prompt="Test") + + with pytest.raises(RuntimeError, match="Elicitation failed"): + await elicitation_input_callback(request) From a3d8222a530ed1f3fa198877b37d4bf96ef1fd3e Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Fri, 19 Sep 2025 14:10:09 +0100 Subject: [PATCH 10/15] use singal_mailbox --- src/mcp_agent/app.py | 34 +------ .../executor/temporal/session_proxy.py | 96 ++++--------------- src/mcp_agent/executor/workflow.py | 17 ---- src/mcp_agent/mcp/client_proxy.py | 17 ++-- src/mcp_agent/server/app_server.py | 20 ++-- 5 files changed, 46 insertions(+), 138 deletions(-) diff --git a/src/mcp_agent/app.py b/src/mcp_agent/app.py index d209a3d3f..f6d335f4a 100644 --- a/src/mcp_agent/app.py +++ b/src/mcp_agent/app.py @@ -32,7 +32,7 @@ register_temporal_decorators, ) from mcp_agent.executor.task_registry import ActivityRegistry -from mcp_agent.executor.workflow_signal import SignalWaitCallback +from mcp_agent.executor.workflow_signal import Signal, SignalWaitCallback from mcp_agent.executor.workflow_task import GlobalWorkflowTaskRegistry from mcp_agent.human_input.types import HumanInputCallback from mcp_agent.elicitation.types import ElicitationCallback @@ -42,6 +42,7 @@ from mcp_agent.workflows.llm.llm_selector import ModelSelector from mcp_agent.workflows.factory import load_agent_specs_from_dir + if TYPE_CHECKING: from mcp_agent.agents.agent_spec import AgentSpec from mcp_agent.executor.workflow import Workflow @@ -675,29 +676,11 @@ async def _run(self, *args, **kwargs): # type: ignore[no-redef] else: decorated_run = self.workflow_run(_run) - # Create signal handler for elicitation response - async def _user_response(self, response: dict[str,Any]): - """Signal handler that receives elicitation responses.""" - # Import here to avoid circular dependencies - try: - from temporalio import workflow - from mcp_agent.executor.temporal.session_proxy import set_signal_response - - if workflow.in_workflow(): - workflow_info = workflow.info() - workflow_key = f"{workflow_info.run_id}" - - await set_signal_response(workflow_key, response) - except ImportError: - # Fallback for non-temporal environments - pass - # Build the Workflow subclass dynamically cls_dict: Dict[str, Any] = { "__doc__": description or (fn.__doc__ or ""), "run": decorated_run, "__mcp_agent_param_source_fn__": fn, - "_user_response": _user_response, } if mark_sync_tool: cls_dict["__mcp_agent_sync_tool__"] = True @@ -706,19 +689,6 @@ async def _user_response(self, response: dict[str,Any]): auto_cls = type(f"AutoWorkflow_{workflow_name}", (_Workflow,), cls_dict) - # Apply the workflow signal decorator to the signal handler - try: - signal_handler = getattr(auto_cls, "_user_response") - engine_type = self.config.execution_engine - signal_decorator = self._decorator_registry.get_workflow_signal_decorator( - engine_type - ) - if signal_decorator: - decorated_signal = signal_decorator(name="_user_response")(signal_handler) - setattr(auto_cls, "_user_response", decorated_signal) - except Exception: - pass - # Workaround for Temporal: publish the dynamically created class as a # top-level (module global) so it is not considered a "local class". # Temporal requires workflow classes to be importable from a module. diff --git a/src/mcp_agent/executor/temporal/session_proxy.py b/src/mcp_agent/executor/temporal/session_proxy.py index 72b3a82c3..83a8cefed 100644 --- a/src/mcp_agent/executor/temporal/session_proxy.py +++ b/src/mcp_agent/executor/temporal/session_proxy.py @@ -19,77 +19,6 @@ from mcp_agent.executor.temporal.system_activities import SystemActivities from mcp_agent.executor.temporal.temporal_context import get_execution_id -# Global state for signal handling (will be stored per workflow instance) -# Stores -# run_id -> { -# response_received: bool # Whether a response has been received -# response_data: Any # The actual response data (typically a dict) -# } -_WORKFLOW_SIGNAL_LOCK = asyncio.Lock() -_workflow_signal_states: Dict[str, Dict[str, Any]] = {} - - -async def reset_signal_response(execution_id: str) -> None: - """ - Reset the signal response state for a given workflow execution ID, ready to accept a new signal - """ - async with _WORKFLOW_SIGNAL_LOCK: - if execution_id not in _workflow_signal_states: - _workflow_signal_states[execution_id] = {} - - _workflow_signal_states[execution_id]['response_data'] = None - _workflow_signal_states[execution_id]['response_received'] = False - - -async def set_signal_response(execution_id: str, data: Any) -> None: - """ - Register that a signal response has been received for a given workflow execution ID. - """ - async with _WORKFLOW_SIGNAL_LOCK: - if execution_id not in _workflow_signal_states: - _workflow_signal_states[execution_id] = {} - - _workflow_signal_states[execution_id]['response_data'] = data - _workflow_signal_states[execution_id]['response_received'] = True - - -async def has_signal_response(execution_id: str) -> bool: - """ - Check if a signal response has been received for a given workflow execution ID. - """ - async with _WORKFLOW_SIGNAL_LOCK: - if execution_id not in _workflow_signal_states: - return False - return _workflow_signal_states[execution_id]['response_received'] - - -async def get_signal_response(execution_id: str) -> Any: - """ - Retrieve the signal response data for a given workflow execution ID. - """ - async with _WORKFLOW_SIGNAL_LOCK: - if execution_id not in _workflow_signal_states or \ - not _workflow_signal_states[execution_id]['response_received']: - raise RuntimeError("No signal response received yet") - return _workflow_signal_states[execution_id]['response_data'] - - -def has_signal_response_sync(execution_id: str) -> bool: - """ - Synchronously check if a signal response has been received for a given workflow execution ID. - This method is safe to use in Temporal wait_condition lambdas since it doesn't use async/await. - - Only reads when the lock is not currently held to minimize race conditions. - Returns False conservatively if the lock is held or if no response is available. - """ - # Only read if lock is not currently held - if _WORKFLOW_SIGNAL_LOCK.locked(): - return False # Conservative: assume no response if lock is held - - if execution_id not in _workflow_signal_states: - return False - return _workflow_signal_states[execution_id]['response_received'] - class SessionProxy(ServerSession): """ @@ -189,7 +118,7 @@ async def request( if _in_workflow_runtime(): act = self._context.task_registry.get_activity("mcp_relay_request") - await self._executor.execute( + execution_info = await self._executor.execute( act, True, # Use the async APIs with signalling for response exec_id, @@ -197,12 +126,27 @@ async def request( params or {}, ) - # Wait for the _elicitation_response signal to be triggered - await _twf.wait_condition( - lambda: has_signal_response_sync(exec_id) + if execution_info.get("error", "") != "": + return execution_info + + signal_name = execution_info.get("signal_name", "") + + if signal_name == "": + return {"error": "no_signal_name_returned_from_activity"} + + # Wait for the response via workflow signal + info = _twf.info() + payload = await self._context.executor.wait_for_signal( # type: ignore[attr-defined] + signal_name, + workflow_id=info.workflow_id, + run_id=info.run_id, + signal_description=f"Waiting for async response to {method}", + # Timeout can be controlled by Temporal workflow/activity timeouts ) - return await get_signal_response(exec_id) + return_value = _twf.payload_converter().from_payload(payload.payload, dict) + + return return_value # Non-workflow (activity/asyncio): direct call and wait for result return await self._system_activities.relay_request( diff --git a/src/mcp_agent/executor/workflow.py b/src/mcp_agent/executor/workflow.py index 6cb4ff518..723c99bb9 100644 --- a/src/mcp_agent/executor/workflow.py +++ b/src/mcp_agent/executor/workflow.py @@ -429,23 +429,6 @@ async def cancel(self) -> bool: from temporalio.common import RawValue from typing import Sequence - @workflow.signal() - async def _user_response(self, response: Dict[str, Any]): - """Signal handler that receives user responses.""" - # Import here to avoid circular dependencies - try: - from temporalio import workflow - from mcp_agent.executor.temporal.session_proxy import set_signal_response - - if workflow.in_workflow(): - workflow_info = workflow.info() - workflow_key = f"{workflow_info.run_id}" - - await set_signal_response(workflow_key, response) - except ImportError: - # Fallback for non-temporal environments - pass - @workflow.signal(dynamic=True) async def _signal_receiver(self, name: str, args: Sequence[RawValue]): """Dynamic signal handler for Temporal workflows.""" diff --git a/src/mcp_agent/mcp/client_proxy.py b/src/mcp_agent/mcp/client_proxy.py index bc4180460..ba065f1a5 100644 --- a/src/mcp_agent/mcp/client_proxy.py +++ b/src/mcp_agent/mcp/client_proxy.py @@ -2,6 +2,7 @@ import os import httpx +import uuid from urllib.parse import quote @@ -174,14 +175,11 @@ async def request_via_proxy( if not in_temporal: return {"error": "not_in_workflow_or_activity"} - from mcp_agent.executor.temporal.session_proxy import reset_signal_response - - # Reset the signal response state, so we're ready to accept a new response signal - await reset_signal_response(execution_id) + signal_name = f"mcp_rpc_{method}_{uuid.uuid4().hex}" # Make the HTTP request (but don't return the response directly) base = _resolve_gateway_url(gateway_url=gateway_url, context_gateway_url=None) - url = f"{base}/internal/session/by-run/{workflow_id}/{quote(execution_id, safe='')}/async-request" + url = f"{base}/internal/session/by-run/{quote(workflow_id, safe='')}/{quote(execution_id, safe='')}/async-request" headers: Dict[str, str] = {} tok = gateway_token or os.environ.get("MCP_GATEWAY_TOKEN") if tok: @@ -205,12 +203,19 @@ async def request_via_proxy( timeout = timeout_float async with httpx.AsyncClient(timeout=timeout) as client: r = await client.post( - url, json={"method": method, "params": params or {}}, headers=headers + url, + json={ + "method": method, + "params": params or {}, + "signal_name": signal_name + }, + headers=headers ) except httpx.RequestError: return {"error": "request_failed"} if r.status_code >= 400: return {"error": r.text} + return {"error": "", "signal_name": signal_name} else: # Use original synchronous approach for non-workflow contexts base = _resolve_gateway_url(gateway_url=gateway_url, context_gateway_url=None) diff --git a/src/mcp_agent/server/app_server.py b/src/mcp_agent/server/app_server.py index ec8ecdae5..d7d83e287 100644 --- a/src/mcp_agent/server/app_server.py +++ b/src/mcp_agent/server/app_server.py @@ -679,6 +679,13 @@ async def _async_relay_request(request: Request): workflow_id = request.path_params.get("workflow_id") method = body.get("method") params = body.get("params") or {} + signal_name = body.get("signal_name") + + # Check authentication + auth_error = _check_gateway_auth(request) + if auth_error: + return auth_error + try: logger.info(f"[async-request] incoming execution_id={execution_id} method={method}") except Exception: @@ -689,10 +696,8 @@ async def _async_relay_request(request: Request): return JSONResponse({"error": f"async not supported for method {method}"}, status_code=405) - # Check authentication - auth_error = _check_gateway_auth(request) - if auth_error: - return auth_error + if not signal_name: + return JSONResponse({"error": "missing_signal_name"}, status_code=400) # Create background task to handle the request and signal the workflow async def _handle_async_request_task(): @@ -728,7 +733,6 @@ async def _handle_async_request_task(): # Signal the workflow with the result using method-specific signal try: - # Try to get Temporal client from the app context app = _get_attached_app(mcp_server) if app and app.context and hasattr(app.context, 'executor'): @@ -742,7 +746,7 @@ async def _handle_async_request_task(): run_id=execution_id ) - await workflow_handle.signal("_user_response", result) + await workflow_handle.signal(signal_name, result) logger.info(f"[async-request] signaled workflow {execution_id} " f"with {method} result using signal") except Exception as signal_error: @@ -758,7 +762,9 @@ async def _handle_async_request_task(): asyncio.create_task(_handle_async_request_task()) # Return immediately with 200 status to indicate request was received - return JSONResponse({"status": "received", "execution_id": execution_id, "method": method}) + return JSONResponse( + {"status": "received", "execution_id": execution_id, "method": method, "signal_name": signal_name} + ) @mcp_server.custom_route( "/internal/workflows/log", methods=["POST"], include_in_schema=False From af59efcec6ed52e89a9a671f775c65da74d1e486 Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Fri, 19 Sep 2025 14:13:06 +0100 Subject: [PATCH 11/15] fix linting eror --- src/mcp_agent/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp_agent/app.py b/src/mcp_agent/app.py index f6d335f4a..f40349a95 100644 --- a/src/mcp_agent/app.py +++ b/src/mcp_agent/app.py @@ -32,7 +32,7 @@ register_temporal_decorators, ) from mcp_agent.executor.task_registry import ActivityRegistry -from mcp_agent.executor.workflow_signal import Signal, SignalWaitCallback +from mcp_agent.executor.workflow_signal import SignalWaitCallback from mcp_agent.executor.workflow_task import GlobalWorkflowTaskRegistry from mcp_agent.human_input.types import HumanInputCallback from mcp_agent.elicitation.types import ElicitationCallback From e49f96c5e2fe6b15b71dcf78c1881cd6d36f464b Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Thu, 18 Sep 2025 19:26:24 +0100 Subject: [PATCH 12/15] add human_interaction handler using MCP elicitation --- README.md | 12 +- examples/human_input/temporal/README.md | 82 +++++++ examples/human_input/temporal/client.py | 200 ++++++++++++++++++ examples/human_input/temporal/main.py | 83 ++++++++ .../temporal/mcp_agent.config.yaml | 22 ++ .../temporal/mcp_agent.secrets.yaml.example | 7 + .../human_input/temporal/requirements.txt | 7 + examples/human_input/temporal/worker.py | 31 +++ examples/mcp/mcp_elicitation/main.py | 2 +- .../mcp/mcp_elicitation/temporal/client.py | 2 +- examples/mcp_agent_server/asyncio/client.py | 2 +- examples/mcp_agent_server/asyncio/main.py | 2 +- .../temporal/basic_agent_server.py | 2 +- examples/usecases/mcp_realtor_agent/main.py | 2 +- examples/workflows/workflow_swarm/main.py | 2 +- .../mcp_agent_server/elicitation/client.py | 2 +- .../mcp_agent_server/elicitation/server.py | 2 +- .../mcp_agent_server/reference/client.py | 2 +- .../mcp_agent_server/reference/server.py | 2 +- .../{handler.py => console_handler.py} | 0 .../human_input/elicitation_handler.py | 123 +++++++++++ tests/human_input/test_elicitation_handler.py | 159 ++++++++++++++ 22 files changed, 731 insertions(+), 17 deletions(-) create mode 100644 examples/human_input/temporal/README.md create mode 100644 examples/human_input/temporal/client.py create mode 100644 examples/human_input/temporal/main.py create mode 100644 examples/human_input/temporal/mcp_agent.config.yaml create mode 100644 examples/human_input/temporal/mcp_agent.secrets.yaml.example create mode 100644 examples/human_input/temporal/requirements.txt create mode 100644 examples/human_input/temporal/worker.py rename src/mcp_agent/human_input/{handler.py => console_handler.py} (100%) create mode 100644 src/mcp_agent/human_input/elicitation_handler.py create mode 100644 tests/human_input/test_elicitation_handler.py diff --git a/README.md b/README.md index 5ea198009..21f6a5448 100644 --- a/README.md +++ b/README.md @@ -568,16 +568,16 @@ orchestrator = Orchestrator( The [Swarm example](examples/workflows/workflow_swarm/main.py) shows this in action. ```python -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback lost_baggage = SwarmAgent( name="Lost baggage traversal", instruction=lambda context_variables: f""" { - FLY_AIR_AGENT_PROMPT.format( - customer_context=context_variables.get("customer_context", "None"), - flight_context=context_variables.get("flight_context", "None"), - ) + FLY_AIR_AGENT_PROMPT.format( + customer_context=context_variables.get("customer_context", "None"), + flight_context=context_variables.get("flight_context", "None"), + ) }\n Lost baggage policy: policies/lost_baggage_policy.md""", functions=[ escalate_to_agent, @@ -586,7 +586,7 @@ lost_baggage = SwarmAgent( case_resolved, ], server_names=["fetch", "filesystem"], - human_input_callback=console_input_callback, # Request input from the console + human_input_callback=console_input_callback, # Request input from the console ) ``` diff --git a/examples/human_input/temporal/README.md b/examples/human_input/temporal/README.md new file mode 100644 index 000000000..714c1ac18 --- /dev/null +++ b/examples/human_input/temporal/README.md @@ -0,0 +1,82 @@ +# Human interactions in Temporal + +This example demonstrates how to implement human interactions in an MCP running as a Temporal workflow. +Human input can be used for approvals or data entry. +In this case, we ask a human to provide their name, so we can create a personalised greeting. + +## Set up + +First, clone the repo and navigate to the human_input example: + +```bash +git clone https://github.com/lastmile-ai/mcp-agent.git +cd mcp-agent/examples/human_input/temporal +``` + +Install `uv` (if you don’t have it): + +```bash +pip install uv +``` + +## Set up api keys + +In `mcp_agent.secrets.yaml`, set your OpenAI `api_key`. + +## Setting Up Temporal Server + +Before running this example, you need to have a Temporal server running: + +1. Install the Temporal CLI by following the instructions at: https://docs.temporal.io/cli/ + +2. Start a local Temporal server: + ```bash + temporal server start-dev + ``` + +This will start a Temporal server on `localhost:7233` (the default address configured in `mcp_agent.config.yaml`). + +You can use the Temporal Web UI to monitor your workflows by visiting `http://localhost:8233` in your browser. + +## Run locally + +In three separate terminal windows, run the following: + +```bash +# this runs the mcp app +uv run main.py +``` + +```bash +# this runs the temporal worker that will execute the workflows +uv run worker.py +``` + +```bash +# this runs the client +uv run client.py +``` + +You will be prompted for input after the agent makes the initial tool call. + +## Details + +Notice how in `main.py` the `human_input_callback` is set to `elicitation_input_callback`. +This makes sure that human input is sought via elicitation. +In `client.py`, on the other hand, it is set to `console_elicitation_callback`. +This way, the client will prompt for input in the console whenever an upstream request for human input is made. + +The following diagram shows the components involved and the flow of requests and responses. + +```plaintext +┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Temporal │───1──▶│ MCP App │◀──2──▶│ Client │◀──3──▶│ User │ +│ worker │◀──4───│ │ │ │ │ (via console)│ +└──────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +In the diagram, +- (1) uses a HTTPS request to tell the MCP App that the workflow wants to make a request, +- (2) uses the MCP protocol for sending the request to the client and receiving the response, +- (3) uses a console prompt to get the input from the user, and +- (4) uses a Temporal signal to send the response back to the workflow. diff --git a/examples/human_input/temporal/client.py b/examples/human_input/temporal/client.py new file mode 100644 index 000000000..b98df2a59 --- /dev/null +++ b/examples/human_input/temporal/client.py @@ -0,0 +1,200 @@ +import asyncio +import time +from mcp_agent.app import MCPApp +from mcp_agent.config import Settings, LoggerSettings, MCPSettings +import yaml +from mcp_agent.elicitation.handler import console_elicitation_callback +from mcp_agent.config import MCPServerSettings +from mcp_agent.core.context import Context +from mcp_agent.mcp.gen_client import gen_client +from datetime import timedelta +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp import ClientSession +from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession +from mcp.types import CallToolResult, LoggingMessageNotificationParams +from mcp_agent.human_input.console_handler import console_input_callback +try: + from exceptiongroup import ExceptionGroup as _ExceptionGroup # Python 3.10 backport +except Exception: # pragma: no cover + _ExceptionGroup = None # type: ignore +try: + from anyio import BrokenResourceError as _BrokenResourceError +except Exception: # pragma: no cover + _BrokenResourceError = None # type: ignore + + +async def main(): + # Create MCPApp to get the server registry, with console handlers + # IMPORTANT: This client acts as the “upstream MCP client” for the server. + # When the server requests sampling (sampling/createMessage), the client-side + # MCPApp must be able to service that request locally (approval prompts + LLM call). + # Those client-local flows are not running inside a Temporal workflow, so they + # must use the asyncio executor. If this were set to "temporal", local sampling + # would crash with: "TemporalExecutor.execute must be called from within a workflow". + # + # We programmatically construct Settings here (mirroring examples/basic/mcp_basic_agent/main.py) + # so everything is self-contained in this client: + settings = Settings( + execution_engine="asyncio", + logger=LoggerSettings(level="info"), + mcp=MCPSettings( + servers={ + "basic_agent_server": MCPServerSettings( + name="basic_agent_server", + description="Local workflow server running the basic agent example", + transport="sse", + # Use a routable loopback host; 0.0.0.0 is a bind address, not a client URL + url="http://127.0.0.1:8000/sse", + ) + } + ), + ) + # Load secrets (API keys, etc.) if a secrets file is available and merge into settings. + # We intentionally deep-merge the secrets on top of our base settings so + # credentials are applied without overriding our executor or server endpoint. + try: + secrets_path = Settings.find_secrets() + if secrets_path and secrets_path.exists(): + with open(secrets_path, "r", encoding="utf-8") as f: + secrets_dict = yaml.safe_load(f) or {} + + def _deep_merge(base: dict, overlay: dict) -> dict: + out = dict(base) + for k, v in (overlay or {}).items(): + if k in out and isinstance(out[k], dict) and isinstance(v, dict): + out[k] = _deep_merge(out[k], v) + else: + out[k] = v + return out + + base_dict = settings.model_dump(mode="json") + merged = _deep_merge(base_dict, secrets_dict) + settings = Settings(**merged) + except Exception: + # Best-effort: continue without secrets if parsing fails + pass + app = MCPApp( + name="workflow_mcp_client", + # Disable sampling approval prompts entirely to keep flows non-interactive. + # Elicitation remains interactive via console_elicitation_callback. + human_input_callback=console_input_callback, + elicitation_callback=console_elicitation_callback, + settings=settings, + ) + async with app.run() as client_app: + logger = client_app.logger + context = client_app.context + + # Connect to the workflow server + try: + logger.info("Connecting to workflow server...") + + # Server connection is configured via Settings above (no runtime mutation needed) + + # Connect to the workflow server + # Define a logging callback to receive server-side log notifications + async def on_server_log(params: LoggingMessageNotificationParams) -> None: + # Pretty-print server logs locally for demonstration + level = params.level.upper() + name = params.logger or "server" + # params.data can be any JSON-serializable data + print(f"[SERVER LOG] [{level}] [{name}] {params.data}") + + # Provide a client session factory that installs our logging callback + # and prints non-logging notifications to the console + class ConsolePrintingClientSession(MCPAgentClientSession): + async def _received_notification(self, notification): # type: ignore[override] + try: + method = getattr(notification.root, "method", None) + except Exception: + method = None + + # Avoid duplicating server log prints (handled by logging_callback) + if method and method != "notifications/message": + try: + data = notification.model_dump() + except Exception: + data = str(notification) + print(f"[SERVER NOTIFY] {method}: {data}") + + return await super()._received_notification(notification) + + def make_session( + read_stream: MemoryObjectReceiveStream, + write_stream: MemoryObjectSendStream, + read_timeout_seconds: timedelta | None, + context: Context | None = None, + ) -> ClientSession: + return ConsolePrintingClientSession( + read_stream=read_stream, + write_stream=write_stream, + read_timeout_seconds=read_timeout_seconds, + logging_callback=on_server_log, + context=context, + ) + + # Connect to the workflow server + async with gen_client( + "basic_agent_server", + context.server_registry, + client_session_factory=make_session, + ) as server: + # Ask server to send logs at the requested level (default info) + level = "info" + print(f"[client] Setting server logging level to: {level}") + try: + await server.set_logging_level(level) + except Exception: + # Older servers may not support logging capability + print("[client] Server does not support logging/setLevel") + + # Call the `book_table` tool defined via `@app.tool` + run_result = await server.call_tool( + "greet", + arguments={} + ) + print(f"[client] Workflow run result: {run_result}") + except Exception as e: + # Tolerate benign shutdown races from SSE client (BrokenResourceError within ExceptionGroup) + if _ExceptionGroup is not None and isinstance(e, _ExceptionGroup): + subs = getattr(e, "exceptions", []) or [] + if ( + _BrokenResourceError is not None + and subs + and all(isinstance(se, _BrokenResourceError) for se in subs) + ): + logger.debug("Ignored BrokenResourceError from SSE shutdown") + else: + raise + elif _BrokenResourceError is not None and isinstance( + e, _BrokenResourceError + ): + logger.debug("Ignored BrokenResourceError from SSE shutdown") + elif "BrokenResourceError" in str(e): + logger.debug( + "Ignored BrokenResourceError from SSE shutdown (string match)" + ) + else: + raise + + +def _tool_result_to_json(tool_result: CallToolResult): + if tool_result.content and len(tool_result.content) > 0: + text = tool_result.content[0].text + try: + # Try to parse the response as JSON if it's a string + import json + + return json.loads(text) + except (json.JSONDecodeError, TypeError): + # If it's not valid JSON, just use the text + return None + + +if __name__ == "__main__": + start = time.time() + asyncio.run(main()) + end = time.time() + t = end - start + + print(f"Total run time: {t:.2f}s") diff --git a/examples/human_input/temporal/main.py b/examples/human_input/temporal/main.py new file mode 100644 index 000000000..e2858b17d --- /dev/null +++ b/examples/human_input/temporal/main.py @@ -0,0 +1,83 @@ +""" +Example demonstrating how to use the elicitation-based human input handler +for Temporal workflows. + +This example shows how the new handler enables LLMs to request user input +when running in Temporal workflows by routing requests through the MCP +elicitation framework instead of direct console I/O. +""" +import asyncio +from mcp_agent.app import MCPApp +from mcp_agent.human_input.elicitation_handler import elicitation_input_callback + +from mcp_agent.agents.agent import Agent +from mcp_agent.core.context import Context +from mcp_agent.server.app_server import create_mcp_server_for_app +from mcp_agent.workflows.llm.augmented_llm_openai import OpenAIAugmentedLLM + + +# Create a single FastMCPApp instance (which extends MCPApp) +# We don't need to explicitly create a tool for human interaction; providing the human_input_callback will +# automatically create a tool for the agent to use. +app = MCPApp( + name="basic_agent_server", + description="Basic agent server example", + human_input_callback=elicitation_input_callback, # Use elicitation handler for human input in temporal workflows +) + + +@app.tool +async def greet(app_ctx: Context | None = None) -> str: + """ + Run the basic agent workflow using the app.tool decorator to set up the workflow. + The code in this function is run in workflow context. + LLM calls are executed in the activity context. + You can use the app_ctx to access the executor to run activities explicitly. + Functions decorated with @app.workflow_task will be run in activity context. + + Args: + input: none + + Returns: + str: The greeting result from the agent + """ + + app = app_ctx.app + + logger = app.logger + logger.info("[workflow-mode] Running greet_tool") + + greeting_agent = Agent( + name="greeter", + instruction="""You are a friendly assistant.""", + server_names=[], + ) + + async with greeting_agent: + finder_llm = await greeting_agent.attach_llm(OpenAIAugmentedLLM) + + result = await finder_llm.generate_str( + message="Ask the user for their name and greet them.", + ) + logger.info("[workflow-mode] greet_tool agent result", data={"result": result}) + + return result + + +async def main(): + async with app.run() as agent_app: + # Log registered workflows and agent configurations + agent_app.logger.info(f"Creating MCP server for {agent_app.name}") + + agent_app.logger.info("Registered workflows:") + for workflow_id in agent_app.workflows: + agent_app.logger.info(f" - {workflow_id}") + # Create the MCP server that exposes both workflows and agent configurations + mcp_server = create_mcp_server_for_app(agent_app) + + # Run the server + await mcp_server.run_sse_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/human_input/temporal/mcp_agent.config.yaml b/examples/human_input/temporal/mcp_agent.config.yaml new file mode 100644 index 000000000..186222535 --- /dev/null +++ b/examples/human_input/temporal/mcp_agent.config.yaml @@ -0,0 +1,22 @@ +$schema: ../../../../schema/mcp-agent.config.schema.json + +execution_engine: temporal + +temporal: + host: "localhost:7233" # Default Temporal server address + namespace: "default" # Default Temporal namespace + task_queue: "mcp-agent" # Task queue for workflows and activities + max_concurrent_activities: 10 # Maximum number of concurrent activities + +logger: + transports: [file] + level: debug + path_settings: + path_pattern: "logs/mcp-agent-{unique_id}.jsonl" + unique_id: "timestamp" # Options: "timestamp" or "session_id" + timestamp_format: "%Y%m%d_%H%M%S" + +openai: + # Secrets (API keys, etc.) are stored in an mcp_agent.secrets.yaml file which can be gitignored + # default_model: "o3-mini" + default_model: "gpt-4o-mini" diff --git a/examples/human_input/temporal/mcp_agent.secrets.yaml.example b/examples/human_input/temporal/mcp_agent.secrets.yaml.example new file mode 100644 index 000000000..930cf3648 --- /dev/null +++ b/examples/human_input/temporal/mcp_agent.secrets.yaml.example @@ -0,0 +1,7 @@ +$schema: ../../../../schema/mcp-agent.config.schema.json + +openai: + api_key: openai_api_key + +anthropic: + api_key: anthropic_api_key diff --git a/examples/human_input/temporal/requirements.txt b/examples/human_input/temporal/requirements.txt new file mode 100644 index 000000000..5f239ce9d --- /dev/null +++ b/examples/human_input/temporal/requirements.txt @@ -0,0 +1,7 @@ +# Core framework dependency +mcp-agent + +# Additional dependencies specific to this example +anthropic +openai +temporalio diff --git a/examples/human_input/temporal/worker.py b/examples/human_input/temporal/worker.py new file mode 100644 index 000000000..39b2a3c67 --- /dev/null +++ b/examples/human_input/temporal/worker.py @@ -0,0 +1,31 @@ +""" +Worker script for the Temporal workflow example. +This script starts a Temporal worker that can execute workflows and activities. +Run this script in a separate terminal window before running the main.py script. + +This leverages the TemporalExecutor's start_worker method to handle the worker setup. +""" + +import asyncio +import logging + + +from mcp_agent.executor.temporal import create_temporal_worker_for_app + +from main import app + +# Initialize logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def main(): + """ + Start a Temporal worker for the example workflows using the app's executor. + """ + async with create_temporal_worker_for_app(app) as worker: + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/mcp/mcp_elicitation/main.py b/examples/mcp/mcp_elicitation/main.py index 1dcce620b..2d091501f 100644 --- a/examples/mcp/mcp_elicitation/main.py +++ b/examples/mcp/mcp_elicitation/main.py @@ -3,7 +3,7 @@ from mcp_agent.app import MCPApp from mcp_agent.agents.agent import Agent -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp_agent.workflows.llm.augmented_llm_openai import OpenAIAugmentedLLM diff --git a/examples/mcp/mcp_elicitation/temporal/client.py b/examples/mcp/mcp_elicitation/temporal/client.py index b6c4d114c..33cb1aade 100644 --- a/examples/mcp/mcp_elicitation/temporal/client.py +++ b/examples/mcp/mcp_elicitation/temporal/client.py @@ -14,7 +14,7 @@ from mcp import ClientSession from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession from mcp.types import CallToolResult, LoggingMessageNotificationParams -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback try: from exceptiongroup import ExceptionGroup as _ExceptionGroup # Python 3.10 backport except Exception: # pragma: no cover diff --git a/examples/mcp_agent_server/asyncio/client.py b/examples/mcp_agent_server/asyncio/client.py index 6c229098c..3bdee2a95 100644 --- a/examples/mcp_agent_server/asyncio/client.py +++ b/examples/mcp_agent_server/asyncio/client.py @@ -12,7 +12,7 @@ from mcp_agent.executor.workflow import WorkflowExecution from mcp_agent.mcp.gen_client import gen_client from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from rich import print diff --git a/examples/mcp_agent_server/asyncio/main.py b/examples/mcp_agent_server/asyncio/main.py index 0d5350e12..1a863c2a4 100644 --- a/examples/mcp_agent_server/asyncio/main.py +++ b/examples/mcp_agent_server/asyncio/main.py @@ -25,7 +25,7 @@ from mcp_agent.workflows.parallel.parallel_llm import ParallelLLM from mcp_agent.executor.workflow import Workflow, WorkflowResult from mcp_agent.tracing.token_counter import TokenNode -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp_agent.mcp.gen_client import gen_client from mcp_agent.config import MCPServerSettings diff --git a/examples/mcp_agent_server/temporal/basic_agent_server.py b/examples/mcp_agent_server/temporal/basic_agent_server.py index 468a01430..cf0e41840 100644 --- a/examples/mcp_agent_server/temporal/basic_agent_server.py +++ b/examples/mcp_agent_server/temporal/basic_agent_server.py @@ -19,7 +19,7 @@ from mcp_agent.server.app_server import create_mcp_server_for_app from mcp_agent.executor.workflow import Workflow, WorkflowResult from mcp_agent.workflows.llm.augmented_llm_openai import OpenAIAugmentedLLM -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp_agent.mcp.gen_client import gen_client from mcp_agent.config import MCPServerSettings diff --git a/examples/usecases/mcp_realtor_agent/main.py b/examples/usecases/mcp_realtor_agent/main.py index 744618fb7..d8d09681f 100644 --- a/examples/usecases/mcp_realtor_agent/main.py +++ b/examples/usecases/mcp_realtor_agent/main.py @@ -12,7 +12,7 @@ from datetime import datetime from mcp_agent.app import MCPApp from mcp_agent.agents.agent import Agent -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp_agent.workflows.orchestrator.orchestrator import Orchestrator from mcp_agent.workflows.llm.augmented_llm import RequestParams diff --git a/examples/workflows/workflow_swarm/main.py b/examples/workflows/workflow_swarm/main.py index d6c090b31..46813d449 100644 --- a/examples/workflows/workflow_swarm/main.py +++ b/examples/workflows/workflow_swarm/main.py @@ -5,7 +5,7 @@ from mcp_agent.app import MCPApp from mcp_agent.workflows.swarm.swarm import DoneAgent, SwarmAgent from mcp_agent.workflows.swarm.swarm_anthropic import AnthropicSwarm -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback app = MCPApp( name="airline_customer_service", human_input_callback=console_input_callback diff --git a/src/mcp_agent/data/examples/mcp_agent_server/elicitation/client.py b/src/mcp_agent/data/examples/mcp_agent_server/elicitation/client.py index 5bad8e3b2..bbba1167f 100644 --- a/src/mcp_agent/data/examples/mcp_agent_server/elicitation/client.py +++ b/src/mcp_agent/data/examples/mcp_agent_server/elicitation/client.py @@ -16,7 +16,7 @@ from mcp_agent.config import Settings from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession from mcp_agent.mcp.gen_client import gen_client -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import ClientSession diff --git a/src/mcp_agent/data/examples/mcp_agent_server/elicitation/server.py b/src/mcp_agent/data/examples/mcp_agent_server/elicitation/server.py index 57fbcf83a..2243764ab 100644 --- a/src/mcp_agent/data/examples/mcp_agent_server/elicitation/server.py +++ b/src/mcp_agent/data/examples/mcp_agent_server/elicitation/server.py @@ -15,7 +15,7 @@ from mcp_agent.app import MCPApp from mcp_agent.core.context import Context as AppContext from mcp_agent.server.app_server import create_mcp_server_for_app -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp.types import ElicitRequestedSchema from pydantic import BaseModel, Field diff --git a/src/mcp_agent/data/examples/mcp_agent_server/reference/client.py b/src/mcp_agent/data/examples/mcp_agent_server/reference/client.py index 9ed0747fb..da3f639f2 100644 --- a/src/mcp_agent/data/examples/mcp_agent_server/reference/client.py +++ b/src/mcp_agent/data/examples/mcp_agent_server/reference/client.py @@ -19,7 +19,7 @@ from mcp_agent.core.context import Context from mcp_agent.config import Settings from mcp_agent.mcp.gen_client import gen_client -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import ClientSession diff --git a/src/mcp_agent/data/examples/mcp_agent_server/reference/server.py b/src/mcp_agent/data/examples/mcp_agent_server/reference/server.py index 447f8d335..a2aeb8447 100644 --- a/src/mcp_agent/data/examples/mcp_agent_server/reference/server.py +++ b/src/mcp_agent/data/examples/mcp_agent_server/reference/server.py @@ -25,7 +25,7 @@ from mcp_agent.app import MCPApp from mcp_agent.core.context import Context as AppContext from mcp_agent.server.app_server import create_mcp_server_for_app -from mcp_agent.human_input.handler import console_input_callback +from mcp_agent.human_input.console_handler import console_input_callback from mcp_agent.elicitation.handler import console_elicitation_callback from mcp_agent.agents.agent import Agent diff --git a/src/mcp_agent/human_input/handler.py b/src/mcp_agent/human_input/console_handler.py similarity index 100% rename from src/mcp_agent/human_input/handler.py rename to src/mcp_agent/human_input/console_handler.py diff --git a/src/mcp_agent/human_input/elicitation_handler.py b/src/mcp_agent/human_input/elicitation_handler.py new file mode 100644 index 000000000..1aea42360 --- /dev/null +++ b/src/mcp_agent/human_input/elicitation_handler.py @@ -0,0 +1,123 @@ +import asyncio + +import mcp.types as types +from mcp_agent.human_input.types import HumanInputRequest, HumanInputResponse +from mcp_agent.logging.logger import get_logger + +logger = get_logger(__name__) + + +def _create_elicitation_message(request: HumanInputRequest) -> str: + """Convert HumanInputRequest to elicitation message format.""" + message = request.prompt + if request.description: + message = f"{request.description}\n\n{message}" + + return message + + +def _handle_elicitation_response( + result: types.ElicitResult, + request: HumanInputRequest +) -> HumanInputResponse: + """Convert ElicitResult back to HumanInputResponse.""" + request_id = request.request_id or "" + + # Handle different action types + if result.action == "accept": + if result.content and isinstance(result.content, dict): + response_text = result.content.get("response", "") + + # Handle slash commands that might be in the response + response_text = response_text.strip() + if response_text.lower() in ["/decline", "/cancel"]: + return HumanInputResponse(request_id=request_id, response=response_text.lower()) + + return HumanInputResponse(request_id=request_id, response=response_text) + else: + # Fallback if content is not in expected format + return HumanInputResponse(request_id=request_id, response="") + + elif result.action == "decline": + return HumanInputResponse(request_id=request_id, response="decline") + + elif result.action == "cancel": + return HumanInputResponse(request_id=request_id, response="cancel") + + else: + # Unknown action, treat as cancel + logger.warning(f"Unknown elicitation action: {result.action}") + return HumanInputResponse(request_id=request_id, response="cancel") + + +async def elicitation_input_callback(request: HumanInputRequest) -> HumanInputResponse: + """ + Handle human input requests using MCP elicitation. + """ + + # Try to get the context and session proxy + try: + from mcp_agent.core.context import get_current_context + context = get_current_context() + if context is None: + raise RuntimeError("No context available for elicitation") + except Exception: + raise RuntimeError("No context available for elicitation") + + upstream_session = context.upstream_session + + if not upstream_session: + raise RuntimeError("Session required for elicitation") + + try: + message = _create_elicitation_message(request) + + logger.debug( + "Sending elicitation request for human input", + data={ + "request_id": request.request_id, + "description": request.description, + "timeout_seconds": request.timeout_seconds + } + ) + + # Send the elicitation request + result = await upstream_session.elicit( + message=message, + requestedSchema={ + "type": "object", + "properties": { + "response": { + "type": "string", + "description": "The response or input" + } + }, + "required": ["response"] + }, + related_request_id=request.request_id + ) + + # Convert the result back to HumanInputResponse + response = _handle_elicitation_response(result, request) + + logger.debug( + "Received elicitation response for human input", + data={ + "request_id": request.request_id, + "action": result.action, + "response_length": len(response.response) + } + ) + + return response + + except asyncio.TimeoutError: + logger.warning(f"Elicitation timeout for request {request.request_id}") + raise TimeoutError("No response received within timeout period") from None + + except Exception as e: + logger.error( + f"Elicitation failed for human input request {request.request_id}", + data={"error": str(e)} + ) + raise RuntimeError(f"Elicitation failed: {e}") from e diff --git a/tests/human_input/test_elicitation_handler.py b/tests/human_input/test_elicitation_handler.py new file mode 100644 index 000000000..d9686d29b --- /dev/null +++ b/tests/human_input/test_elicitation_handler.py @@ -0,0 +1,159 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock +import mcp.types as types +from mcp_agent.executor.temporal.session_proxy import SessionProxy +from mcp_agent.human_input.types import HumanInputRequest, HumanInputResponse +from mcp_agent.human_input.elicitation_handler import ( + elicitation_input_callback, + _create_elicitation_message, + _handle_elicitation_response +) + + +class TestElicitationHandler: + """Test the elicitation-based human input handler.""" + + def test_create_elicitation_message_basic(self): + """Test basic message creation.""" + request = HumanInputRequest(prompt="Please enter your name") + message = _create_elicitation_message(request) + + assert "Please enter your name" in message + + def test_create_elicitation_message_with_description(self): + """Test message creation with description.""" + request = HumanInputRequest( + prompt="Enter your name", + description="We need your name for the booking" + ) + message = _create_elicitation_message(request) + + assert "We need your name for the booking" in message + assert "Enter your name" in message + + def test_create_elicitation_message_with_timeout(self): + """Test message creation with timeout.""" + request = HumanInputRequest( + prompt="Enter your name", + timeout_seconds=30 + ) + message = _create_elicitation_message(request) + + assert "Enter your name" in message + assert "Timeout" not in message + assert "30" not in message + + def test_handle_elicitation_response_accept(self): + """Test handling accept response.""" + request = HumanInputRequest(prompt="Test", request_id="test-123") + result = types.ElicitResult( + action="accept", + content={"response": "John Doe"} + ) + + response = _handle_elicitation_response(result, request) + + assert isinstance(response, HumanInputResponse) + assert response.request_id == "test-123" + assert response.response == "John Doe" + + def test_handle_elicitation_response_decline(self): + """Test handling decline response.""" + request = HumanInputRequest(prompt="Test", request_id="test-123") + result = types.ElicitResult(action="decline") + + response = _handle_elicitation_response(result, request) + + assert response.request_id == "test-123" + assert response.response == "decline" + + def test_handle_elicitation_response_cancel(self): + """Test handling cancel response.""" + request = HumanInputRequest(prompt="Test", request_id="test-123") + result = types.ElicitResult(action="cancel") + + response = _handle_elicitation_response(result, request) + + assert response.request_id == "test-123" + assert response.response == "cancel" + + + @pytest.mark.asyncio + async def test_elicitation_input_callback_success(self): + """Test successful elicitation callback.""" + # Mock the context and session proxy + mock_context = MagicMock() + mock_session = AsyncMock(spec=SessionProxy) + + # Mock the elicit method to return a successful response + mock_session.elicit.return_value = types.ElicitResult( + action="accept", + content={"response": "Test response"} + ) + + mock_context.upstream_session = mock_session + + # Mock get_current_context() to return our mock context + with pytest.MonkeyPatch.context() as m: + m.setattr("mcp_agent.core.context.get_current_context", lambda: mock_context) + + request = HumanInputRequest( + prompt="Please enter something", + request_id="test-123" + ) + + response = await elicitation_input_callback(request) + + assert isinstance(response, HumanInputResponse) + assert response.request_id == "test-123" + assert response.response == "Test response" + + # Verify the session proxy was called correctly + mock_session.elicit.assert_called_once() + call_args = mock_session.elicit.call_args + assert "Please enter something" in call_args.kwargs["message"] + assert call_args.kwargs["related_request_id"] == "test-123" + + @pytest.mark.asyncio + async def test_elicitation_input_callback_no_context(self): + """Test callback when no context is available.""" + with pytest.MonkeyPatch.context() as m: + m.setattr("mcp_agent.core.context.get_current_context", lambda: None) + + request = HumanInputRequest(prompt="Test") + + with pytest.raises(RuntimeError, match="No context available"): + await elicitation_input_callback(request) + + @pytest.mark.asyncio + async def test_elicitation_input_callback_no_session(self): + """Test callback when SessionProxy is not available.""" + mock_context = MagicMock() + mock_context.upstream_session = None + + with pytest.MonkeyPatch.context() as m: + m.setattr("mcp_agent.core.context.get_current_context", lambda: mock_context) + + request = HumanInputRequest(prompt="Test") + + with pytest.raises(RuntimeError, match="Session required for elicitation"): + await elicitation_input_callback(request) + + @pytest.mark.asyncio + async def test_elicitation_input_callback_elicit_failure(self): + """Test callback when elicitation fails.""" + mock_context = MagicMock() + mock_session = AsyncMock(spec=SessionProxy) + + # Mock the elicit method to raise an exception + mock_session.elicit.side_effect = Exception("Elicitation failed") + + mock_context.upstream_session = mock_session + + with pytest.MonkeyPatch.context() as m: + m.setattr("mcp_agent.core.context.get_current_context", lambda: mock_context) + + request = HumanInputRequest(prompt="Test") + + with pytest.raises(RuntimeError, match="Elicitation failed"): + await elicitation_input_callback(request) From dcf6d0a98ac55192e8a11f2d636211fef78d0e57 Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Fri, 19 Sep 2025 16:07:24 +0100 Subject: [PATCH 13/15] review comments --- .../executor/temporal/session_proxy.py | 72 ++++++++++--------- src/mcp_agent/workflows/factory.py | 2 +- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/mcp_agent/executor/temporal/session_proxy.py b/src/mcp_agent/executor/temporal/session_proxy.py index 83a8cefed..0179bac0d 100644 --- a/src/mcp_agent/executor/temporal/session_proxy.py +++ b/src/mcp_agent/executor/temporal/session_proxy.py @@ -126,12 +126,12 @@ async def request( params or {}, ) - if execution_info.get("error", "") != "": + if execution_info.get("error"): return execution_info signal_name = execution_info.get("signal_name", "") - if signal_name == "": + if not signal_name: return {"error": "no_signal_name_returned_from_activity"} # Wait for the response via workflow signal @@ -144,9 +144,13 @@ async def request( # Timeout can be controlled by Temporal workflow/activity timeouts ) - return_value = _twf.payload_converter().from_payload(payload.payload, dict) - - return return_value + pc = _twf.payload_converter() + # Support either a Temporal payload wrapper or a plain dict + if hasattr(payload, "payload"): + return pc.from_payload(payload.payload, dict) + if isinstance(payload, dict): + return payload + return pc.from_payload(payload, dict) # Non-workflow (activity/asyncio): direct call and wait for result return await self._system_activities.relay_request( @@ -157,9 +161,9 @@ async def request( ) async def send_notification( - self, - notification: types.ServerNotification, - related_request_id: types.RequestId | None = None, + self, + notification: types.ServerNotification, + related_request_id: types.RequestId | None = None, ) -> None: root = notification.root params: Dict[str, Any] | None = None @@ -177,10 +181,10 @@ async def send_notification( await self.notify(root.method, params) # type: ignore[attr-defined] async def send_request( - self, - request: types.ServerRequest, - result_type: Type[Any], - metadata: ServerMessageMetadata | None = None, + self, + request: types.ServerRequest, + result_type: Type[Any], + metadata: ServerMessageMetadata | None = None, ) -> Any: root = request.root params: Dict[str, Any] | None = None @@ -200,11 +204,11 @@ async def send_request( return payload async def send_log_message( - self, - level: types.LoggingLevel, - data: Any, - logger: str | None = None, - related_request_id: types.RequestId | None = None, + self, + level: types.LoggingLevel, + data: Any, + logger: str | None = None, + related_request_id: types.RequestId | None = None, ) -> None: """Best-effort log forwarding to the client's UI.""" # Prefer activity-based forwarding inside workflow for determinism @@ -237,12 +241,12 @@ async def send_log_message( await self.notify("notifications/message", params) async def send_progress_notification( - self, - progress_token: str | int, - progress: float, - total: float | None = None, - message: str | None = None, - related_request_id: str | None = None, + self, + progress_token: str | int, + progress: float, + total: float | None = None, + message: str | None = None, + related_request_id: str | None = None, ) -> None: params: Dict[str, Any] = { "progressToken": progress_token, @@ -277,17 +281,17 @@ async def list_roots(self) -> types.ListRootsResult: return types.ListRootsResult.model_validate(result) async def create_message( - self, - messages: List[types.SamplingMessage], - *, - max_tokens: int, - system_prompt: str | None = None, - include_context: types.IncludeContext | None = None, - temperature: float | None = None, - stop_sequences: List[str] | None = None, - metadata: Dict[str, Any] | None = None, - model_preferences: types.ModelPreferences | None = None, - related_request_id: types.RequestId | None = None, + self, + messages: List[types.SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: types.IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: List[str] | None = None, + metadata: Dict[str, Any] | None = None, + model_preferences: types.ModelPreferences | None = None, + related_request_id: types.RequestId | None = None, ) -> types.CreateMessageResult: params: Dict[str, Any] = { "messages": [m.model_dump(by_alias=True, mode="json") for m in messages], diff --git a/src/mcp_agent/workflows/factory.py b/src/mcp_agent/workflows/factory.py index 3f8044c07..3a661adb3 100644 --- a/src/mcp_agent/workflows/factory.py +++ b/src/mcp_agent/workflows/factory.py @@ -77,7 +77,7 @@ def create_llm( @overload def create_llm( - agent: str, + agent_name: str, server_names: List[str] | None = None, instruction: str | None = None, provider: str = "openai", From aeb2a88957086051ab54411368a67d1bcaa0b93f Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Fri, 19 Sep 2025 16:32:01 +0100 Subject: [PATCH 14/15] update README, and client.py comment --- examples/human_input/temporal/README.md | 22 ++++++++++++++++------ examples/human_input/temporal/client.py | 2 +- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/examples/human_input/temporal/README.md b/examples/human_input/temporal/README.md index 714c1ac18..b2c7c89c1 100644 --- a/examples/human_input/temporal/README.md +++ b/examples/human_input/temporal/README.md @@ -69,14 +69,24 @@ This way, the client will prompt for input in the console whenever an upstream r The following diagram shows the components involved and the flow of requests and responses. ```plaintext +┌──────────┐ +│ LLM │ +│ │ +└──────────┘ + ▲ + │ + 1 + │ + ▼ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Temporal │───1──▶│ MCP App │◀──2──▶│ Client │◀──3──▶│ User │ -│ worker │◀──4───│ │ │ │ │ (via console)│ +│ Temporal │───2──▶│ MCP App │◀──3──▶│ Client │◀──4──▶│ User │ +│ worker │◀──5───│ │ │ │ │ (via console)│ └──────────┘ └──────────────┘ └──────────────┘ └──────────────┘ ``` In the diagram, -- (1) uses a HTTPS request to tell the MCP App that the workflow wants to make a request, -- (2) uses the MCP protocol for sending the request to the client and receiving the response, -- (3) uses a console prompt to get the input from the user, and -- (4) uses a Temporal signal to send the response back to the workflow. +- (1) uses the tool calling mechanism to call a system-provided tool for human input, +- (2) uses a HTTPS request to tell the MCP App that the workflow wants to make a request, +- (3) uses the MCP protocol for sending the request to the client and receiving the response, +- (4) uses a console prompt to get the input from the user, and +- (5) uses a Temporal signal to send the response back to the workflow. diff --git a/examples/human_input/temporal/client.py b/examples/human_input/temporal/client.py index b98df2a59..d1a7a0246 100644 --- a/examples/human_input/temporal/client.py +++ b/examples/human_input/temporal/client.py @@ -148,7 +148,7 @@ def make_session( # Older servers may not support logging capability print("[client] Server does not support logging/setLevel") - # Call the `book_table` tool defined via `@app.tool` + # Call the `greet` tool defined via `@app.tool` run_result = await server.call_tool( "greet", arguments={} From 82ca9367d2f3d8892fbd9e5be11b92ba92d5fa4c Mon Sep 17 00:00:00 2001 From: Roman van der Krogt Date: Fri, 19 Sep 2025 16:38:04 +0100 Subject: [PATCH 15/15] review comment --- examples/human_input/temporal/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/human_input/temporal/client.py b/examples/human_input/temporal/client.py index d1a7a0246..8b75a9cce 100644 --- a/examples/human_input/temporal/client.py +++ b/examples/human_input/temporal/client.py @@ -75,8 +75,7 @@ def _deep_merge(base: dict, overlay: dict) -> dict: pass app = MCPApp( name="workflow_mcp_client", - # Disable sampling approval prompts entirely to keep flows non-interactive. - # Elicitation remains interactive via console_elicitation_callback. + # In the client, we want to use `console_input_callback` to enable direct interaction through the console human_input_callback=console_input_callback, elicitation_callback=console_elicitation_callback, settings=settings,