coco-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coco/__init__.py +3 -0
- coco/agents/__init__.py +9 -0
- coco/agents/_subagent_transformer.py +307 -0
- coco/agents/factory.py +2164 -0
- coco/agents/middleware/__init__.py +100 -0
- coco/agents/middleware/_execution.py +385 -0
- coco/agents/middleware/_redaction.py +454 -0
- coco/agents/middleware/_retry.py +134 -0
- coco/agents/middleware/_trace_policy.py +61 -0
- coco/agents/middleware/context_editing.py +306 -0
- coco/agents/middleware/file_search.py +433 -0
- coco/agents/middleware/human_in_the_loop.py +680 -0
- coco/agents/middleware/internal_call_transformer.py +108 -0
- coco/agents/middleware/model_call_limit.py +267 -0
- coco/agents/middleware/model_fallback.py +417 -0
- coco/agents/middleware/model_retry.py +327 -0
- coco/agents/middleware/pii.py +878 -0
- coco/agents/middleware/provider_tool_search.py +307 -0
- coco/agents/middleware/shell_tool.py +949 -0
- coco/agents/middleware/summarization.py +910 -0
- coco/agents/middleware/todo.py +357 -0
- coco/agents/middleware/tool_call_limit.py +495 -0
- coco/agents/middleware/tool_emulator.py +251 -0
- coco/agents/middleware/tool_error.py +209 -0
- coco/agents/middleware/tool_retry.py +421 -0
- coco/agents/middleware/tool_selection.py +515 -0
- coco/agents/middleware/types.py +2201 -0
- coco/agents/structured_output.py +472 -0
- coco/chat_models/__init__.py +7 -0
- coco/chat_models/base.py +1082 -0
- coco/embeddings/__init__.py +18 -0
- coco/embeddings/base.py +279 -0
- coco/mcp/__init__.py +36 -0
- coco/mcp/adapter.py +245 -0
- coco/mcp/elicitation.py +394 -0
- coco/mcp/tools.py +307 -0
- coco/messages/__init__.py +73 -0
- coco/py.typed +0 -0
- coco/rate_limiters/__init__.py +13 -0
- coco/tools/__init__.py +22 -0
- coco/tools/tool_node.py +20 -0
- coco_anthropic/__init__.py +15 -0
- coco_anthropic/_client_utils.py +81 -0
- coco_anthropic/_compat.py +228 -0
- coco_anthropic/_sdk_compat.py +124 -0
- coco_anthropic/_version.py +3 -0
- coco_anthropic/chat_models.py +3349 -0
- coco_anthropic/data/__init__.py +1 -0
- coco_anthropic/data/_profiles.py +491 -0
- coco_anthropic/data/profile_augmentations.toml +69 -0
- coco_anthropic/experimental.py +140 -0
- coco_anthropic/llms.py +428 -0
- coco_anthropic/middleware/__init__.py +25 -0
- coco_anthropic/middleware/anthropic_tools.py +1197 -0
- coco_anthropic/middleware/bash.py +84 -0
- coco_anthropic/middleware/file_search.py +360 -0
- coco_anthropic/middleware/prompt_caching.py +276 -0
- coco_anthropic/output_parsers.py +93 -0
- coco_anthropic/py.typed +0 -0
- coco_core/__init__.py +20 -0
- coco_core/_api/__init__.py +87 -0
- coco_core/_api/beta_decorator.py +266 -0
- coco_core/_api/deprecation.py +637 -0
- coco_core/_api/internal.py +23 -0
- coco_core/_api/path.py +50 -0
- coco_core/_import_utils.py +41 -0
- coco_core/_protocol.py +625 -0
- coco_core/_security/__init__.py +36 -0
- coco_core/_security/_exceptions.py +9 -0
- coco_core/_security/_policy.py +313 -0
- coco_core/_security/_ssrf_protection.py +155 -0
- coco_core/_security/_transport.py +254 -0
- coco_core/agents.py +258 -0
- coco_core/caches.py +272 -0
- coco_core/callbacks/__init__.py +132 -0
- coco_core/callbacks/base.py +1229 -0
- coco_core/callbacks/file.py +266 -0
- coco_core/callbacks/manager.py +2826 -0
- coco_core/callbacks/stdout.py +123 -0
- coco_core/callbacks/streaming_stdout.py +154 -0
- coco_core/callbacks/usage.py +124 -0
- coco_core/chat_history.py +267 -0
- coco_core/chat_loaders.py +26 -0
- coco_core/chat_sessions.py +19 -0
- coco_core/cross_encoders.py +18 -0
- coco_core/document_loaders/__init__.py +36 -0
- coco_core/document_loaders/base.py +155 -0
- coco_core/document_loaders/blob_loaders.py +38 -0
- coco_core/documents/__init__.py +55 -0
- coco_core/documents/base.py +347 -0
- coco_core/documents/compressor.py +74 -0
- coco_core/documents/transformers.py +79 -0
- coco_core/embeddings/__init__.py +31 -0
- coco_core/embeddings/embeddings.py +78 -0
- coco_core/embeddings/fake.py +129 -0
- coco_core/env.py +22 -0
- coco_core/example_selectors/__init__.py +47 -0
- coco_core/example_selectors/base.py +60 -0
- coco_core/example_selectors/length_based.py +131 -0
- coco_core/example_selectors/semantic_similarity.py +366 -0
- coco_core/exceptions.py +166 -0
- coco_core/globals.py +72 -0
- coco_core/indexing/__init__.py +53 -0
- coco_core/indexing/api.py +954 -0
- coco_core/indexing/base.py +661 -0
- coco_core/indexing/in_memory.py +104 -0
- coco_core/language_models/__init__.py +116 -0
- coco_core/language_models/_compat_bridge.py +844 -0
- coco_core/language_models/_utils.py +343 -0
- coco_core/language_models/base.py +500 -0
- coco_core/language_models/chat_model_stream.py +1525 -0
- coco_core/language_models/chat_models.py +2748 -0
- coco_core/language_models/fake.py +137 -0
- coco_core/language_models/fake_chat_models.py +396 -0
- coco_core/language_models/llms.py +1569 -0
- coco_core/language_models/model_profile.py +183 -0
- coco_core/load/__init__.py +44 -0
- coco_core/load/_validation.py +191 -0
- coco_core/load/dump.py +120 -0
- coco_core/load/load.py +828 -0
- coco_core/load/mapping.py +1097 -0
- coco_core/load/serializable.py +406 -0
- coco_core/messages/__init__.py +198 -0
- coco_core/messages/ai.py +854 -0
- coco_core/messages/base.py +518 -0
- coco_core/messages/block_translators/__init__.py +112 -0
- coco_core/messages/block_translators/anthropic.py +534 -0
- coco_core/messages/block_translators/bedrock.py +108 -0
- coco_core/messages/block_translators/bedrock_converse.py +329 -0
- coco_core/messages/block_translators/coco_v0.py +303 -0
- coco_core/messages/block_translators/google_genai.py +610 -0
- coco_core/messages/block_translators/google_vertexai.py +21 -0
- coco_core/messages/block_translators/groq.py +157 -0
- coco_core/messages/block_translators/openai.py +1089 -0
- coco_core/messages/chat.py +64 -0
- coco_core/messages/content.py +1488 -0
- coco_core/messages/function.py +62 -0
- coco_core/messages/human.py +70 -0
- coco_core/messages/modifier.py +33 -0
- coco_core/messages/system.py +70 -0
- coco_core/messages/tool.py +418 -0
- coco_core/messages/utils.py +2442 -0
- coco_core/output_parsers/__init__.py +101 -0
- coco_core/output_parsers/base.py +361 -0
- coco_core/output_parsers/format_instructions.py +16 -0
- coco_core/output_parsers/json.py +139 -0
- coco_core/output_parsers/list.py +249 -0
- coco_core/output_parsers/openai_functions.py +324 -0
- coco_core/output_parsers/openai_tools.py +383 -0
- coco_core/output_parsers/pydantic.py +141 -0
- coco_core/output_parsers/string.py +63 -0
- coco_core/output_parsers/transform.py +175 -0
- coco_core/output_parsers/xml.py +301 -0
- coco_core/outputs/__init__.py +66 -0
- coco_core/outputs/chat_generation.py +147 -0
- coco_core/outputs/chat_result.py +39 -0
- coco_core/outputs/generation.py +80 -0
- coco_core/outputs/llm_result.py +112 -0
- coco_core/outputs/run_info.py +22 -0
- coco_core/prompt_values.py +161 -0
- coco_core/prompts/__init__.py +101 -0
- coco_core/prompts/base.py +508 -0
- coco_core/prompts/chat.py +1495 -0
- coco_core/prompts/dict.py +177 -0
- coco_core/prompts/few_shot.py +483 -0
- coco_core/prompts/few_shot_with_templates.py +239 -0
- coco_core/prompts/image.py +177 -0
- coco_core/prompts/loading.py +296 -0
- coco_core/prompts/message.py +97 -0
- coco_core/prompts/prompt.py +312 -0
- coco_core/prompts/string.py +399 -0
- coco_core/prompts/structured.py +214 -0
- coco_core/py.typed +0 -0
- coco_core/rate_limiters.py +254 -0
- coco_core/retrievers.py +328 -0
- coco_core/runnables/__init__.py +136 -0
- coco_core/runnables/base.py +6725 -0
- coco_core/runnables/branch.py +457 -0
- coco_core/runnables/config.py +712 -0
- coco_core/runnables/configurable.py +710 -0
- coco_core/runnables/fallbacks.py +663 -0
- coco_core/runnables/graph.py +741 -0
- coco_core/runnables/graph_ascii.py +366 -0
- coco_core/runnables/graph_mermaid.py +503 -0
- coco_core/runnables/graph_png.py +215 -0
- coco_core/runnables/history.py +631 -0
- coco_core/runnables/passthrough.py +849 -0
- coco_core/runnables/retry.py +379 -0
- coco_core/runnables/router.py +239 -0
- coco_core/runnables/schema.py +188 -0
- coco_core/runnables/utils.py +798 -0
- coco_core/stores.py +291 -0
- coco_core/structured_query.py +203 -0
- coco_core/sys_info.py +154 -0
- coco_core/tools/__init__.py +95 -0
- coco_core/tools/base.py +1948 -0
- coco_core/tools/convert.py +497 -0
- coco_core/tools/render.py +67 -0
- coco_core/tools/retriever.py +94 -0
- coco_core/tools/simple.py +208 -0
- coco_core/tools/structured.py +322 -0
- coco_core/tracers/__init__.py +47 -0
- coco_core/tracers/_compat.py +95 -0
- coco_core/tracers/_context.py +337 -0
- coco_core/tracers/_streaming.py +53 -0
- coco_core/tracers/base.py +955 -0
- coco_core/tracers/coco.py +279 -0
- coco_core/tracers/context.py +205 -0
- coco_core/tracers/core.py +766 -0
- coco_core/tracers/event_stream.py +1101 -0
- coco_core/tracers/log_stream.py +765 -0
- coco_core/tracers/memory_stream.py +200 -0
- coco_core/tracers/root_listeners.py +130 -0
- coco_core/tracers/run_collector.py +40 -0
- coco_core/tracers/schemas.py +14 -0
- coco_core/tracers/stdout.py +205 -0
- coco_core/utils/__init__.py +111 -0
- coco_core/utils/_gateway.py +274 -0
- coco_core/utils/_merge.py +218 -0
- coco_core/utils/aiter.py +357 -0
- coco_core/utils/env.py +86 -0
- coco_core/utils/formatting.py +80 -0
- coco_core/utils/function_calling.py +869 -0
- coco_core/utils/html.py +132 -0
- coco_core/utils/image.py +15 -0
- coco_core/utils/input.py +82 -0
- coco_core/utils/interactive_env.py +12 -0
- coco_core/utils/iter.py +229 -0
- coco_core/utils/json.py +230 -0
- coco_core/utils/json_schema.py +273 -0
- coco_core/utils/mustache.py +706 -0
- coco_core/utils/pydantic.py +631 -0
- coco_core/utils/strings.py +69 -0
- coco_core/utils/usage.py +61 -0
- coco_core/utils/utils.py +528 -0
- coco_core/utils/uuid.py +57 -0
- coco_core/vectorstores/__init__.py +53 -0
- coco_core/vectorstores/base.py +1111 -0
- coco_core/vectorstores/in_memory.py +546 -0
- coco_core/vectorstores/utils.py +163 -0
- coco_core/version.py +3 -0
- coco_framework-0.1.0.dist-info/METADATA +184 -0
- coco_framework-0.1.0.dist-info/RECORD +400 -0
- coco_framework-0.1.0.dist-info/WHEEL +4 -0
- coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
- coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
- coco_graph/_internal/__init__.py +4 -0
- coco_graph/_internal/_cache.py +31 -0
- coco_graph/_internal/_config.py +474 -0
- coco_graph/_internal/_constants.py +140 -0
- coco_graph/_internal/_fields.py +213 -0
- coco_graph/_internal/_future.py +220 -0
- coco_graph/_internal/_pydantic.py +275 -0
- coco_graph/_internal/_queue.py +124 -0
- coco_graph/_internal/_replay.py +90 -0
- coco_graph/_internal/_retry.py +29 -0
- coco_graph/_internal/_runnable.py +969 -0
- coco_graph/_internal/_scratchpad.py +19 -0
- coco_graph/_internal/_serde.py +253 -0
- coco_graph/_internal/_timeout.py +25 -0
- coco_graph/_internal/_typing.py +54 -0
- coco_graph/cache/base/__init__.py +48 -0
- coco_graph/cache/base/py.typed +0 -0
- coco_graph/cache/memory/__init__.py +73 -0
- coco_graph/cache/redis/__init__.py +144 -0
- coco_graph/cache/sqlite/__init__.py +120 -0
- coco_graph/callbacks.py +394 -0
- coco_graph/channels/__init__.py +29 -0
- coco_graph/channels/any_value.py +72 -0
- coco_graph/channels/base.py +121 -0
- coco_graph/channels/binop.py +155 -0
- coco_graph/channels/delta.py +202 -0
- coco_graph/channels/ephemeral_value.py +79 -0
- coco_graph/channels/last_value.py +151 -0
- coco_graph/channels/named_barrier_value.py +167 -0
- coco_graph/channels/topic.py +94 -0
- coco_graph/channels/untracked_value.py +73 -0
- coco_graph/checkpoint/base/__init__.py +861 -0
- coco_graph/checkpoint/base/id.py +109 -0
- coco_graph/checkpoint/base/py.typed +0 -0
- coco_graph/checkpoint/memory/__init__.py +698 -0
- coco_graph/checkpoint/memory/py.typed +0 -0
- coco_graph/checkpoint/postgres/__init__.py +602 -0
- coco_graph/checkpoint/postgres/_ainternal.py +23 -0
- coco_graph/checkpoint/postgres/_internal.py +21 -0
- coco_graph/checkpoint/postgres/aio.py +691 -0
- coco_graph/checkpoint/postgres/base.py +666 -0
- coco_graph/checkpoint/postgres/py.typed +0 -0
- coco_graph/checkpoint/postgres/shallow.py +967 -0
- coco_graph/checkpoint/serde/__init__.py +0 -0
- coco_graph/checkpoint/serde/_msgpack.py +97 -0
- coco_graph/checkpoint/serde/base.py +64 -0
- coco_graph/checkpoint/serde/encrypted.py +80 -0
- coco_graph/checkpoint/serde/event_hooks.py +52 -0
- coco_graph/checkpoint/serde/jsonplus.py +883 -0
- coco_graph/checkpoint/serde/py.typed +0 -0
- coco_graph/checkpoint/serde/types.py +68 -0
- coco_graph/checkpoint/sqlite/__init__.py +645 -0
- coco_graph/checkpoint/sqlite/_delta.py +172 -0
- coco_graph/checkpoint/sqlite/aio.py +738 -0
- coco_graph/checkpoint/sqlite/py.typed +0 -0
- coco_graph/checkpoint/sqlite/utils.py +116 -0
- coco_graph/config.py +196 -0
- coco_graph/constants.py +64 -0
- coco_graph/errors.py +241 -0
- coco_graph/func/__init__.py +620 -0
- coco_graph/graph/__init__.py +12 -0
- coco_graph/graph/_branch.py +225 -0
- coco_graph/graph/_node.py +103 -0
- coco_graph/graph/message.py +437 -0
- coco_graph/graph/state.py +1978 -0
- coco_graph/graph/ui.py +227 -0
- coco_graph/managed/__init__.py +3 -0
- coco_graph/managed/base.py +31 -0
- coco_graph/managed/is_last_step.py +24 -0
- coco_graph/prebuilt/__init__.py +23 -0
- coco_graph/prebuilt/_tool_call_stream.py +117 -0
- coco_graph/prebuilt/_tool_call_transformer.py +165 -0
- coco_graph/prebuilt/chat_agent_executor.py +1015 -0
- coco_graph/prebuilt/interrupt.py +105 -0
- coco_graph/prebuilt/py.typed +0 -0
- coco_graph/prebuilt/tool_node.py +2030 -0
- coco_graph/prebuilt/tool_validator.py +221 -0
- coco_graph/pregel/__init__.py +3 -0
- coco_graph/pregel/_algo.py +1460 -0
- coco_graph/pregel/_call.py +298 -0
- coco_graph/pregel/_checkpoint.py +331 -0
- coco_graph/pregel/_config.py +0 -0
- coco_graph/pregel/_draw.py +294 -0
- coco_graph/pregel/_executor.py +223 -0
- coco_graph/pregel/_io.py +174 -0
- coco_graph/pregel/_log.py +3 -0
- coco_graph/pregel/_loop.py +1988 -0
- coco_graph/pregel/_messages.py +461 -0
- coco_graph/pregel/_read.py +312 -0
- coco_graph/pregel/_retry.py +854 -0
- coco_graph/pregel/_runner.py +941 -0
- coco_graph/pregel/_tools.py +268 -0
- coco_graph/pregel/_utils.py +221 -0
- coco_graph/pregel/_validate.py +120 -0
- coco_graph/pregel/_write.py +192 -0
- coco_graph/pregel/debug.py +302 -0
- coco_graph/pregel/main.py +4364 -0
- coco_graph/pregel/protocol.py +288 -0
- coco_graph/pregel/types.py +38 -0
- coco_graph/py.typed +0 -0
- coco_graph/runtime.py +350 -0
- coco_graph/store/base/__init__.py +1322 -0
- coco_graph/store/base/batch.py +371 -0
- coco_graph/store/base/embed.py +436 -0
- coco_graph/store/base/py.typed +0 -0
- coco_graph/store/memory/__init__.py +592 -0
- coco_graph/store/memory/py.typed +0 -0
- coco_graph/store/postgres/__init__.py +4 -0
- coco_graph/store/postgres/aio.py +592 -0
- coco_graph/store/postgres/base.py +1480 -0
- coco_graph/store/postgres/py.typed +0 -0
- coco_graph/store/sqlite/__init__.py +4 -0
- coco_graph/store/sqlite/aio.py +632 -0
- coco_graph/store/sqlite/base.py +1514 -0
- coco_graph/stream/__init__.py +45 -0
- coco_graph/stream/_convert.py +32 -0
- coco_graph/stream/_mux.py +523 -0
- coco_graph/stream/_types.py +330 -0
- coco_graph/stream/run_stream.py +735 -0
- coco_graph/stream/stream_channel.py +341 -0
- coco_graph/stream/transformers.py +1039 -0
- coco_graph/types.py +1079 -0
- coco_graph/typing.py +48 -0
- coco_graph/utils/__init__.py +1 -0
- coco_graph/utils/config.py +4 -0
- coco_graph/utils/runnable.py +3 -0
- coco_graph/version.py +13 -0
- coco_graph/warnings.py +69 -0
- coco_openai/__init__.py +20 -0
- coco_openai/_compat.py +45 -0
- coco_openai/_version.py +3 -0
- coco_openai/chat_models/__init__.py +6 -0
- coco_openai/chat_models/_client_utils.py +684 -0
- coco_openai/chat_models/_compat.py +525 -0
- coco_openai/chat_models/azure.py +1227 -0
- coco_openai/chat_models/base.py +5654 -0
- coco_openai/chat_models/codex.py +557 -0
- coco_openai/chatgpt_oauth.py +1063 -0
- coco_openai/data/__init__.py +1 -0
- coco_openai/data/_profiles.py +1655 -0
- coco_openai/data/profile_augmentations.toml +127 -0
- coco_openai/embeddings/__init__.py +6 -0
- coco_openai/embeddings/azure.py +240 -0
- coco_openai/embeddings/base.py +820 -0
- coco_openai/llms/__init__.py +6 -0
- coco_openai/llms/azure.py +249 -0
- coco_openai/llms/base.py +919 -0
- coco_openai/middleware/__init__.py +11 -0
- coco_openai/middleware/openai_moderation.py +484 -0
- coco_openai/output_parsers/__init__.py +9 -0
- coco_openai/output_parsers/tools.py +9 -0
- coco_openai/py.typed +0 -0
- coco_openai/tools/__init__.py +5 -0
- coco_openai/tools/custom_tool.py +111 -0
coco/mcp/elicitation.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Answer MCP elicitation requests with a CocoGraph interrupt.
|
|
2
|
+
|
|
3
|
+
A server needing input mid-call returns an `InputRequiredResult` and expects the
|
|
4
|
+
`tools/call` to be retried with answers. This module drives that loop, sourcing
|
|
5
|
+
each answer from `interrupt()` so the human reviewing the agent answers too.
|
|
6
|
+
|
|
7
|
+
We drive the loop here rather than via the SDK's `run_input_required_driver`
|
|
8
|
+
because that driver answers from callbacks run concurrently in a task group:
|
|
9
|
+
CocoGraph matches resume values to `interrupt()` calls by order, so concurrent
|
|
10
|
+
firing scrambles the matching, and FastMCP would swallow the `GraphInterrupt` as
|
|
11
|
+
an MCP error. Calling `interrupt()` from this frame keeps one per round.
|
|
12
|
+
|
|
13
|
+
Only elicitation is answered. Sampling, roots, and continuation rounds are
|
|
14
|
+
refused rather than half-served.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import warnings
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypedDict, TypeVar
|
|
21
|
+
|
|
22
|
+
from fastmcp.client.group import ClientGroup
|
|
23
|
+
from coco_graph.types import interrupt
|
|
24
|
+
from mcp.types import (
|
|
25
|
+
CallToolResult,
|
|
26
|
+
ElicitRequest,
|
|
27
|
+
ElicitRequestFormParams,
|
|
28
|
+
ElicitResult,
|
|
29
|
+
InputRequiredResult,
|
|
30
|
+
InputResponses,
|
|
31
|
+
)
|
|
32
|
+
from mcp.types.version import LATEST_MODERN_VERSION, is_version_at_least
|
|
33
|
+
from typing_extensions import NotRequired
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Coroutine
|
|
37
|
+
|
|
38
|
+
from fastmcp.client import Client
|
|
39
|
+
from mcp.client.session import ClientSession
|
|
40
|
+
from mcp.types import InputRequest
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_ResultT = TypeVar("_ResultT")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
ELICITATION_INTERRUPT_TYPE: Final = "mcp_elicitation"
|
|
47
|
+
"""Discriminator on the interrupt payload, so a handler can recognize it."""
|
|
48
|
+
|
|
49
|
+
_ACTIONS: Final = ("accept", "decline", "cancel")
|
|
50
|
+
"""Actions the wire accepts, for validating a resumed answer."""
|
|
51
|
+
|
|
52
|
+
MCPFormContent: TypeAlias = dict[str, str | int | float | bool | list[str] | None]
|
|
53
|
+
"""Values a form answer may carry, as narrow as `mcp.types.ElicitResult.content`."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MCPElicitationFormRequest(TypedDict):
|
|
57
|
+
"""A request for data matching a schema.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
key: The server's identifier for this request. An answer must be
|
|
61
|
+
returned under the same key.
|
|
62
|
+
message: The server's prompt, intended for a human to read.
|
|
63
|
+
mode: Always `'form'`.
|
|
64
|
+
requested_schema: JSON schema the answer's `content` must satisfy.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
key: str
|
|
68
|
+
message: str
|
|
69
|
+
mode: Literal["form"]
|
|
70
|
+
requested_schema: dict[str, Any]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class MCPElicitationUrlRequest(TypedDict):
|
|
74
|
+
"""A request for the human to visit an address.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
key: The server's identifier for this request. An answer must be
|
|
78
|
+
returned under the same key.
|
|
79
|
+
message: The server's prompt, intended for a human to read.
|
|
80
|
+
mode: Always `'url'`.
|
|
81
|
+
url: The address the human should visit.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
key: str
|
|
85
|
+
message: str
|
|
86
|
+
mode: Literal["url"]
|
|
87
|
+
url: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
MCPElicitationRequest: TypeAlias = MCPElicitationFormRequest | MCPElicitationUrlRequest
|
|
91
|
+
"""One question an MCP server asks before finishing a tool call. Narrow on `mode`."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MCPElicitationInterrupt(TypedDict):
|
|
95
|
+
"""Interrupt payload raised while an MCP tool call waits on input.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
type: Always `'mcp_elicitation'`.
|
|
99
|
+
tool_name: The MCP tool whose call is waiting.
|
|
100
|
+
requests: Every question this round, in the order they should be asked.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
type: Literal["mcp_elicitation"]
|
|
104
|
+
tool_name: str
|
|
105
|
+
requests: list[MCPElicitationRequest]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MCPElicitationAccept(TypedDict):
|
|
109
|
+
"""An answer to an `MCPElicitationRequest`.
|
|
110
|
+
|
|
111
|
+
Attributes:
|
|
112
|
+
action: Always `'accept'`.
|
|
113
|
+
content: The answer itself, matching the request's `requested_schema`.
|
|
114
|
+
Expected for a `'form'` request; a `'url'` request carries none.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
action: Literal["accept"]
|
|
118
|
+
content: NotRequired[MCPFormContent | None]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class MCPElicitationDecline(TypedDict):
|
|
122
|
+
"""A refusal to answer an `MCPElicitationRequest`, leaving the call to go on.
|
|
123
|
+
|
|
124
|
+
Attributes:
|
|
125
|
+
action: Always `'decline'`.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
action: Literal["decline"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class MCPElicitationCancel(TypedDict):
|
|
132
|
+
"""A refusal that abandons the tool call rather than just the question.
|
|
133
|
+
|
|
134
|
+
Attributes:
|
|
135
|
+
action: Always `'cancel'`.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
action: Literal["cancel"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
MCPElicitationResponse: TypeAlias = (
|
|
142
|
+
MCPElicitationAccept | MCPElicitationDecline | MCPElicitationCancel
|
|
143
|
+
)
|
|
144
|
+
"""One answer to an `MCPElicitationRequest`. Narrow on `action`; only accept carries content."""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class MCPElicitationResume(TypedDict):
|
|
148
|
+
"""Value used to resume a run interrupted by an MCP elicitation.
|
|
149
|
+
|
|
150
|
+
Attributes:
|
|
151
|
+
responses: One answer per request, keyed by the request's `key`.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
responses: dict[str, MCPElicitationResponse]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _describe_request(key: str, request: ElicitRequest) -> MCPElicitationRequest:
|
|
158
|
+
"""Render one server request as a payload a human can act on."""
|
|
159
|
+
params = request.params
|
|
160
|
+
if isinstance(params, ElicitRequestFormParams):
|
|
161
|
+
return MCPElicitationFormRequest(
|
|
162
|
+
key=key,
|
|
163
|
+
message=params.message,
|
|
164
|
+
mode="form",
|
|
165
|
+
requested_schema=params.requested_schema,
|
|
166
|
+
)
|
|
167
|
+
return MCPElicitationUrlRequest(
|
|
168
|
+
key=key,
|
|
169
|
+
message=params.message,
|
|
170
|
+
mode="url",
|
|
171
|
+
url=str(params.url),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _elicit_requests(
|
|
176
|
+
requests: dict[str, InputRequest],
|
|
177
|
+
tool_name: str,
|
|
178
|
+
) -> dict[str, ElicitRequest]:
|
|
179
|
+
"""Narrow a round's requests to elicitations, raising on sampling or roots."""
|
|
180
|
+
unsupported = sorted(
|
|
181
|
+
f"{key} ({request.method})"
|
|
182
|
+
for key, request in requests.items()
|
|
183
|
+
if not isinstance(request, ElicitRequest)
|
|
184
|
+
)
|
|
185
|
+
if unsupported:
|
|
186
|
+
msg = (
|
|
187
|
+
f"The MCP tool {tool_name!r} asked for input that interrupt-based "
|
|
188
|
+
f"elicitation cannot answer: {', '.join(unsupported)}. Only "
|
|
189
|
+
"elicitation requests are supported."
|
|
190
|
+
)
|
|
191
|
+
raise NotImplementedError(msg)
|
|
192
|
+
return {key: request for key, request in requests.items() if isinstance(request, ElicitRequest)}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _build_responses(
|
|
196
|
+
requests: dict[str, ElicitRequest],
|
|
197
|
+
answers: dict[str, MCPElicitationResponse],
|
|
198
|
+
tool_name: str,
|
|
199
|
+
) -> InputResponses:
|
|
200
|
+
"""Turn resumed answers into the server's responses, raising on any missing or malformed."""
|
|
201
|
+
missing = sorted(set(requests) - set(answers))
|
|
202
|
+
if missing:
|
|
203
|
+
msg = (
|
|
204
|
+
f"Resuming the MCP tool {tool_name!r} needs an answer for every "
|
|
205
|
+
f"elicitation request, but these had none: {', '.join(missing)}."
|
|
206
|
+
)
|
|
207
|
+
raise ValueError(msg)
|
|
208
|
+
|
|
209
|
+
responses: InputResponses = {}
|
|
210
|
+
for key in requests:
|
|
211
|
+
answer = answers[key]
|
|
212
|
+
action = answer.get("action")
|
|
213
|
+
if action not in _ACTIONS:
|
|
214
|
+
msg = (
|
|
215
|
+
f"Elicitation answer for {key!r} has action {action!r}; expected "
|
|
216
|
+
"'accept', 'decline', or 'cancel'."
|
|
217
|
+
)
|
|
218
|
+
raise ValueError(msg)
|
|
219
|
+
content = answer.get("content") if action == "accept" else None
|
|
220
|
+
responses[key] = ElicitResult(action=action, content=content)
|
|
221
|
+
return responses
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
_ARMED_MARKER: Final = "_coco_mcp_interrupt_armed"
|
|
225
|
+
"""Attribute stamped on a client the adapter armed for interrupt elicitation."""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def _declare_elicitation_capability(*_: object) -> dict[str, Any]:
|
|
229
|
+
"""Elicitation handler that only exists to advertise the capability.
|
|
230
|
+
|
|
231
|
+
FastMCP advertises `elicitation` only when a handler is set, so a client
|
|
232
|
+
that should drive the interrupt loop needs one. The loop answers from the
|
|
233
|
+
tool call, not this handler, so it never runs on a modern server. It only
|
|
234
|
+
fires when a legacy server initiates elicitation the old way — which the
|
|
235
|
+
interrupt loop cannot answer, hence the error.
|
|
236
|
+
"""
|
|
237
|
+
msg = (
|
|
238
|
+
"This MCP server asked for input over the legacy server-initiated "
|
|
239
|
+
"elicitation path, which interrupt-based elicitation cannot answer. It "
|
|
240
|
+
"needs a server that returns its requests as an `InputRequiredResult`, "
|
|
241
|
+
"or a pre-built client with your own `elicitation_handler`."
|
|
242
|
+
)
|
|
243
|
+
raise NotImplementedError(msg)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _arm_for_interrupts(client: Client[Any]) -> None:
|
|
247
|
+
"""Set the sentinel handler and mark the client as adapter-armed."""
|
|
248
|
+
client.set_elicitation_callback(_declare_elicitation_capability)
|
|
249
|
+
setattr(client, _ARMED_MARKER, True)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _drives_interrupts(client: Client[Any]) -> bool:
|
|
253
|
+
"""Report whether a call through `client` should drive the interrupt loop.
|
|
254
|
+
|
|
255
|
+
True only for a client the adapter armed (not one carrying a caller's own
|
|
256
|
+
handler) that is connected to a modern-era server. The `InputRequiredResult`
|
|
257
|
+
the loop answers is a modern feature (SEP-2322); a legacy server never
|
|
258
|
+
returns one, so the call falls back to a plain `call_tool` there.
|
|
259
|
+
"""
|
|
260
|
+
if not getattr(client, _ARMED_MARKER, False):
|
|
261
|
+
return False
|
|
262
|
+
version = getattr(client, "protocol_version", None)
|
|
263
|
+
return version is not None and is_version_at_least(version, LATEST_MODERN_VERSION)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def _await_monitored(client: Client[Any], coro: Coroutine[Any, Any, _ResultT]) -> _ResultT:
|
|
267
|
+
"""Race a session request against the session task so an error can't hang it.
|
|
268
|
+
|
|
269
|
+
On HTTP transports a server error surfaces in the background session task,
|
|
270
|
+
not in the awaiting coroutine. FastMCP's guard for this is private, so warn
|
|
271
|
+
and fall back if it ever moves.
|
|
272
|
+
"""
|
|
273
|
+
monitored = getattr(client, "_await_with_session_monitoring", None)
|
|
274
|
+
if monitored is None:
|
|
275
|
+
warnings.warn(
|
|
276
|
+
"This version of FastMCP does not expose "
|
|
277
|
+
"`Client._await_with_session_monitoring`, so an MCP elicitation "
|
|
278
|
+
"round cannot be raced against the session task. A transport "
|
|
279
|
+
"failure mid-elicitation may hang instead of raising.",
|
|
280
|
+
RuntimeWarning,
|
|
281
|
+
stacklevel=2,
|
|
282
|
+
)
|
|
283
|
+
return await coro
|
|
284
|
+
result: _ResultT = await monitored(coro)
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def _resolve_session(
|
|
289
|
+
client: Client[Any] | ClientGroup, tool_name: str
|
|
290
|
+
) -> tuple[Client[Any], ClientSession, str]:
|
|
291
|
+
"""Resolve the member client, its session, and the server's name for a tool.
|
|
292
|
+
|
|
293
|
+
For a group, `list_tools` namespaces the catalog as `{server}_{tool}` but the
|
|
294
|
+
serving client only knows the tool by its own name; `resolve_tool` maps the
|
|
295
|
+
namespaced name back to that member and its upstream name. For a single
|
|
296
|
+
client the tool is already addressed by its own name.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
client: The armed client or group serving the tool.
|
|
300
|
+
tool_name: The tool's name as this adapter published it.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
The member client, its connected session, and the name the member's
|
|
304
|
+
server knows the tool by.
|
|
305
|
+
"""
|
|
306
|
+
if isinstance(client, ClientGroup):
|
|
307
|
+
route = await client.resolve_tool(tool_name)
|
|
308
|
+
return route.client, route.client.session, route.upstream_name
|
|
309
|
+
return client, client.session, tool_name
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def _call_tool_with_interrupts(
|
|
313
|
+
client: Client[Any] | ClientGroup,
|
|
314
|
+
tool_name: str,
|
|
315
|
+
arguments: dict[str, Any],
|
|
316
|
+
) -> CallToolResult:
|
|
317
|
+
"""Call an MCP tool, answering each round of requested input with an interrupt.
|
|
318
|
+
|
|
319
|
+
`interrupt()` unwinds the whole call, so on resume it is re-issued from the
|
|
320
|
+
first round. A server that asks before doing work repeats nothing; one that
|
|
321
|
+
works first repeats that work once per round.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
client: A connected client armed to advertise the elicitation capability.
|
|
325
|
+
tool_name: The MCP tool to call.
|
|
326
|
+
arguments: Arguments for the tool.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
The tool's terminal result.
|
|
330
|
+
|
|
331
|
+
Raises:
|
|
332
|
+
GraphInterrupt: For each unanswered round. This is the mechanism.
|
|
333
|
+
NotImplementedError: On a sampling/roots request or a continuation round.
|
|
334
|
+
ValueError: If a resumed answer is missing or malformed.
|
|
335
|
+
"""
|
|
336
|
+
# FastMCP's public `call_tool` no longer forwards `allow_input_required`; it
|
|
337
|
+
# drives the input-required loop itself via a concurrent driver, which is the
|
|
338
|
+
# exact behavior this module replaces with one `interrupt()` per round. So
|
|
339
|
+
# drive it against the member session, whose `call_tool` still returns the
|
|
340
|
+
# `InputRequiredResult` for us to answer.
|
|
341
|
+
member, session, upstream_name = await _resolve_session(client, tool_name)
|
|
342
|
+
|
|
343
|
+
result = await _await_monitored(
|
|
344
|
+
member, session.call_tool(upstream_name, arguments, allow_input_required=True)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
while isinstance(result, InputRequiredResult):
|
|
348
|
+
if not result.input_requests:
|
|
349
|
+
# A round with only `request_state` is a continuation: the server is
|
|
350
|
+
# still working, with no question to interrupt on. Refused like sampling.
|
|
351
|
+
msg = (
|
|
352
|
+
f"The MCP tool {tool_name!r} returned a continuation round with "
|
|
353
|
+
"no input requests. Interrupt-based elicitation only answers "
|
|
354
|
+
"elicitation requests, not long-running work that resumes over "
|
|
355
|
+
"several round trips."
|
|
356
|
+
)
|
|
357
|
+
raise NotImplementedError(msg)
|
|
358
|
+
|
|
359
|
+
requests = _elicit_requests(result.input_requests, tool_name)
|
|
360
|
+
request: MCPElicitationInterrupt = {
|
|
361
|
+
"type": ELICITATION_INTERRUPT_TYPE,
|
|
362
|
+
"tool_name": tool_name,
|
|
363
|
+
"requests": [_describe_request(key, req) for key, req in requests.items()],
|
|
364
|
+
}
|
|
365
|
+
resume: MCPElicitationResume = interrupt(request)
|
|
366
|
+
responses = _build_responses(requests, resume["responses"], tool_name)
|
|
367
|
+
|
|
368
|
+
result = await _await_monitored(
|
|
369
|
+
member,
|
|
370
|
+
session.call_tool(
|
|
371
|
+
upstream_name,
|
|
372
|
+
arguments,
|
|
373
|
+
input_responses=responses,
|
|
374
|
+
request_state=result.request_state, # opaque; echoed back verbatim
|
|
375
|
+
allow_input_required=True,
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return result
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
__all__ = [
|
|
383
|
+
"ELICITATION_INTERRUPT_TYPE",
|
|
384
|
+
"MCPElicitationAccept",
|
|
385
|
+
"MCPElicitationCancel",
|
|
386
|
+
"MCPElicitationDecline",
|
|
387
|
+
"MCPElicitationFormRequest",
|
|
388
|
+
"MCPElicitationInterrupt",
|
|
389
|
+
"MCPElicitationRequest",
|
|
390
|
+
"MCPElicitationResponse",
|
|
391
|
+
"MCPElicitationResume",
|
|
392
|
+
"MCPElicitationUrlRequest",
|
|
393
|
+
"MCPFormContent",
|
|
394
|
+
]
|
coco/mcp/tools.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Convert MCP tools and tool results into Coco-native values.
|
|
2
|
+
|
|
3
|
+
Tool results follow `trustcoco-mcp-adapters`, so a call made through
|
|
4
|
+
`coco.mcp` reaches a model in the same shape as one loaded by that
|
|
5
|
+
package. Tool *metadata* is richer here: it is grouped under a single `mcp`
|
|
6
|
+
namespace with the tool's annotations and `_meta` under `mcp.tool` and the
|
|
7
|
+
serving server's identity under `mcp.server`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Protocol, TypedDict
|
|
13
|
+
|
|
14
|
+
from fastmcp.client.group import ClientGroup
|
|
15
|
+
from coco_core.messages.content import (
|
|
16
|
+
FileContentBlock,
|
|
17
|
+
ImageContentBlock,
|
|
18
|
+
TextContentBlock,
|
|
19
|
+
create_file_block,
|
|
20
|
+
create_image_block,
|
|
21
|
+
create_text_block,
|
|
22
|
+
)
|
|
23
|
+
from coco_core.tools import BaseTool, StructuredTool, ToolException
|
|
24
|
+
from mcp.types import (
|
|
25
|
+
AudioContent,
|
|
26
|
+
BlobResourceContents,
|
|
27
|
+
ContentBlock,
|
|
28
|
+
EmbeddedResource,
|
|
29
|
+
ImageContent,
|
|
30
|
+
ResourceLink,
|
|
31
|
+
TextContent,
|
|
32
|
+
TextResourceContents,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from coco.mcp.elicitation import _call_tool_with_interrupts, _drives_interrupts
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from fastmcp.client import Client
|
|
39
|
+
from mcp.types import Tool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _ToolCallResult(Protocol):
|
|
43
|
+
"""The result fields this module reads.
|
|
44
|
+
|
|
45
|
+
FastMCP's `CallToolResult` dataclass and the MCP SDK's own pydantic
|
|
46
|
+
`CallToolResult` both satisfy this, and elicitation-driven calls return the
|
|
47
|
+
latter.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
content: list[ContentBlock]
|
|
51
|
+
structured_content: dict[str, Any] | None
|
|
52
|
+
is_error: bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
ToolMessageContentBlock = TextContentBlock | ImageContentBlock | FileContentBlock
|
|
56
|
+
"""Coco content blocks an MCP tool result can convert into."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MCPToolArtifact(TypedDict):
|
|
60
|
+
"""Artifact attached to the `ToolMessage` produced by an MCP tool call.
|
|
61
|
+
|
|
62
|
+
Wrapping the structured content in a `TypedDict` leaves room for further
|
|
63
|
+
MCP result fields without changing the artifact's shape.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
structured_content: The `structuredContent` of the MCP tool result.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
structured_content: Any
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _summarize_tool_error(tool_content: list[ToolMessageContentBlock]) -> str:
|
|
73
|
+
"""Build a readable message from the content blocks of a failed tool call.
|
|
74
|
+
|
|
75
|
+
Image and file blocks are summarized by count rather than interpolated, so a
|
|
76
|
+
base64 payload never lands in an exception message.
|
|
77
|
+
"""
|
|
78
|
+
error_parts = [block["text"] for block in tool_content if block["type"] == "text"]
|
|
79
|
+
if error_parts:
|
|
80
|
+
return "\n".join(error_parts)
|
|
81
|
+
if tool_content:
|
|
82
|
+
return (
|
|
83
|
+
"The MCP tool reported an error with no text content "
|
|
84
|
+
f"({len(tool_content)} non-text content block(s))."
|
|
85
|
+
)
|
|
86
|
+
return "The MCP tool reported an error with empty content."
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _MCPToolExecutionError(ToolException):
|
|
90
|
+
"""An MCP tool that ran and reported failure, as `isError=True`.
|
|
91
|
+
|
|
92
|
+
Carries the converted content blocks so `_handle_mcp_tool_error` can hand the
|
|
93
|
+
server's own error detail to the model instead of ending the run.
|
|
94
|
+
|
|
95
|
+
Deliberately narrow: only `isError=True` results raise this. Transport and
|
|
96
|
+
conversion failures are not `ToolException`s, so they bypass error handling.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, tool_content: list[ToolMessageContentBlock]) -> None:
|
|
100
|
+
super().__init__(_summarize_tool_error(tool_content))
|
|
101
|
+
self.tool_content = tool_content
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _handle_mcp_tool_error(error: ToolException) -> list[ToolMessageContentBlock]:
|
|
105
|
+
"""Surface an MCP execution error to the model as failed tool output.
|
|
106
|
+
|
|
107
|
+
Wired as `handle_tool_error`, so what this returns becomes a `ToolMessage`
|
|
108
|
+
with `status="error"` carrying the server's own detail. Any other
|
|
109
|
+
`ToolException` is re-raised.
|
|
110
|
+
"""
|
|
111
|
+
if isinstance(error, _MCPToolExecutionError):
|
|
112
|
+
if error.tool_content:
|
|
113
|
+
return error.tool_content
|
|
114
|
+
# An empty `ToolMessage` is a fragile shape for some providers, so
|
|
115
|
+
# substitute a placeholder rather than pass no content at all.
|
|
116
|
+
return [create_text_block(text=str(error))]
|
|
117
|
+
raise error
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _convert_content_block(content: ContentBlock) -> ToolMessageContentBlock:
|
|
121
|
+
"""Convert one MCP content block into its Coco equivalent."""
|
|
122
|
+
if isinstance(content, TextContent):
|
|
123
|
+
return create_text_block(text=content.text)
|
|
124
|
+
|
|
125
|
+
if isinstance(content, ImageContent):
|
|
126
|
+
return create_image_block(base64=content.data, mime_type=content.mime_type)
|
|
127
|
+
|
|
128
|
+
if isinstance(content, AudioContent):
|
|
129
|
+
msg = (
|
|
130
|
+
"Converting MCP audio content to a Coco content block is not yet "
|
|
131
|
+
f"supported. Received audio with mime type: {content.mime_type}"
|
|
132
|
+
)
|
|
133
|
+
raise NotImplementedError(msg)
|
|
134
|
+
|
|
135
|
+
if isinstance(content, ResourceLink):
|
|
136
|
+
mime_type = content.mime_type or None
|
|
137
|
+
if mime_type and mime_type.startswith("image/"):
|
|
138
|
+
return create_image_block(url=content.uri, mime_type=mime_type)
|
|
139
|
+
return create_file_block(url=content.uri, mime_type=mime_type)
|
|
140
|
+
|
|
141
|
+
if isinstance(content, EmbeddedResource):
|
|
142
|
+
resource = content.resource
|
|
143
|
+
if isinstance(resource, TextResourceContents):
|
|
144
|
+
return create_text_block(text=resource.text)
|
|
145
|
+
if isinstance(resource, BlobResourceContents):
|
|
146
|
+
mime_type = resource.mime_type or None
|
|
147
|
+
if mime_type and mime_type.startswith("image/"):
|
|
148
|
+
return create_image_block(base64=resource.blob, mime_type=mime_type)
|
|
149
|
+
return create_file_block(base64=resource.blob, mime_type=mime_type)
|
|
150
|
+
# Unreachable while the SDK's resource union holds; see the note below.
|
|
151
|
+
msg = f"Unknown embedded resource type: {type(resource).__name__}" # type: ignore[unreachable]
|
|
152
|
+
raise ValueError(msg)
|
|
153
|
+
|
|
154
|
+
# Both unions are closed at type-check time, so mypy proves these two lines
|
|
155
|
+
# unreachable — and the `unreachable` ignores become unused-ignore errors the
|
|
156
|
+
# moment the SDK grows a member, which is what keeps this exhaustive.
|
|
157
|
+
#
|
|
158
|
+
# The runtime guards still earn their place: a union is only as closed as the
|
|
159
|
+
# installed `mcp`, and a caller on a newer SDK is better served by a named
|
|
160
|
+
# type than by the bare `AssertionError` an `assert_never` would raise.
|
|
161
|
+
msg = ( # type: ignore[unreachable]
|
|
162
|
+
f"Unknown MCP content type: {type(content).__name__}. This usually means "
|
|
163
|
+
"the installed `mcp` is newer than the version this adapter supports."
|
|
164
|
+
)
|
|
165
|
+
raise ValueError(msg)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _convert_call_tool_result(
|
|
169
|
+
result: _ToolCallResult,
|
|
170
|
+
) -> tuple[list[ToolMessageContentBlock], MCPToolArtifact | None]:
|
|
171
|
+
"""Split an MCP tool result into model-visible content and an artifact."""
|
|
172
|
+
tool_content = [_convert_content_block(block) for block in result.content]
|
|
173
|
+
|
|
174
|
+
if result.is_error:
|
|
175
|
+
raise _MCPToolExecutionError(tool_content)
|
|
176
|
+
|
|
177
|
+
artifact: MCPToolArtifact | None = None
|
|
178
|
+
if result.structured_content is not None:
|
|
179
|
+
artifact = MCPToolArtifact(structured_content=result.structured_content)
|
|
180
|
+
return tool_content, artifact
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _tool_metadata(tool: Tool, client: Client[Any] | None) -> dict[str, Any] | None:
|
|
184
|
+
"""Collect the MCP tool- and server-level metadata worth keeping.
|
|
185
|
+
|
|
186
|
+
Everything lives under a single `mcp` namespace so a consumer can tell an
|
|
187
|
+
MCP tool's provenance from any other metadata on the Coco tool, and so
|
|
188
|
+
tool-level fields (`annotations`, `_meta`) stay distinct from the identity
|
|
189
|
+
of the server that served the tool.
|
|
190
|
+
"""
|
|
191
|
+
tool_meta: dict[str, Any] = {}
|
|
192
|
+
if tool.annotations is not None:
|
|
193
|
+
# Snake_case (no `by_alias`) reads naturally from Python, and matches
|
|
194
|
+
# what `trustcoco-mcp-adapters` produces.
|
|
195
|
+
tool_meta["annotations"] = tool.annotations.model_dump(exclude_none=True)
|
|
196
|
+
if tool.meta is not None:
|
|
197
|
+
# `_meta` is the MCP wire field name; keep it so the key matches the
|
|
198
|
+
# protocol and the server's payload stays in one nested place.
|
|
199
|
+
tool_meta["_meta"] = tool.meta
|
|
200
|
+
|
|
201
|
+
mcp: dict[str, Any] = {}
|
|
202
|
+
if tool_meta:
|
|
203
|
+
mcp["tool"] = tool_meta
|
|
204
|
+
# Server identity comes off the connection, not the tool — a `Tool` carries
|
|
205
|
+
# no server field. It is populated while the client is connected, which is
|
|
206
|
+
# the case at conversion time.
|
|
207
|
+
if client is not None and client.server_info is not None:
|
|
208
|
+
mcp["server"] = client.server_info.model_dump(exclude_none=True)
|
|
209
|
+
return {"mcp": mcp} if mcp else None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _normalize_mcp_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
213
|
+
"""Keep open object arguments open during provider schema conversion."""
|
|
214
|
+
normalized = dict(schema)
|
|
215
|
+
properties = schema.get("properties")
|
|
216
|
+
if not isinstance(properties, dict):
|
|
217
|
+
return normalized
|
|
218
|
+
normalized["properties"] = dict(properties)
|
|
219
|
+
for name, value in properties.items():
|
|
220
|
+
if not isinstance(value, dict):
|
|
221
|
+
continue
|
|
222
|
+
types = value.get("type")
|
|
223
|
+
is_object = types == "object" or (isinstance(types, list) and "object" in types)
|
|
224
|
+
if (
|
|
225
|
+
is_object
|
|
226
|
+
and not value.get("properties")
|
|
227
|
+
and "additionalProperties" not in value
|
|
228
|
+
and "unevaluatedProperties" not in value
|
|
229
|
+
):
|
|
230
|
+
normalized["properties"][name] = {**value, "additionalProperties": True}
|
|
231
|
+
return normalized
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def as_coco_tool(
|
|
235
|
+
tool: Tool,
|
|
236
|
+
client: Client[Any] | ClientGroup,
|
|
237
|
+
) -> BaseTool:
|
|
238
|
+
"""Convert one MCP tool into a Coco tool.
|
|
239
|
+
|
|
240
|
+
The returned tool calls the MCP tool through `client` on every invocation.
|
|
241
|
+
FastMCP clients are reentrant, so the tool can open the client itself
|
|
242
|
+
whether or not a connection is already held elsewhere.
|
|
243
|
+
|
|
244
|
+
A server that needs input mid-call is answered with a CocoGraph
|
|
245
|
+
`interrupt()` when `client` carries the interrupt-driving sentinel handler
|
|
246
|
+
that `MCPAdapter` installs — so a human answers and the call resumes, see
|
|
247
|
+
`coco.mcp.elicitation`. A client that carries a different handler (a
|
|
248
|
+
caller's own) uses that handler instead, and one with no handler simply
|
|
249
|
+
never gets asked. Which path a call takes is read off the client, so the
|
|
250
|
+
behavior matches whatever it was armed with.
|
|
251
|
+
|
|
252
|
+
An MCP tool that runs and reports failure reaches the model as a
|
|
253
|
+
`ToolMessage` with `status="error"`, carrying the server's own error
|
|
254
|
+
content, so an agent can correct itself and retry. Transport failures and
|
|
255
|
+
unconvertible content propagate instead, since a model cannot act on them.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
tool: An MCP tool, as returned by `fastmcp.Client.list_tools`.
|
|
259
|
+
client: The FastMCP client to call the tool through.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
A Coco tool that invokes the MCP tool asynchronously.
|
|
263
|
+
|
|
264
|
+
Example:
|
|
265
|
+
```python
|
|
266
|
+
from fastmcp import Client
|
|
267
|
+
|
|
268
|
+
from coco.mcp import as_coco_tool
|
|
269
|
+
|
|
270
|
+
client = Client("https://example.com/mcp")
|
|
271
|
+
async with client:
|
|
272
|
+
mcp_tools = await client.list_tools()
|
|
273
|
+
tools = [await as_coco_tool(t, client) for t in mcp_tools]
|
|
274
|
+
```
|
|
275
|
+
"""
|
|
276
|
+
if isinstance(client, ClientGroup):
|
|
277
|
+
tool_route = await client.resolve_tool(tool.name)
|
|
278
|
+
requesting_client = tool_route.client
|
|
279
|
+
else:
|
|
280
|
+
requesting_client = client
|
|
281
|
+
drives_interrupts = _drives_interrupts(requesting_client)
|
|
282
|
+
|
|
283
|
+
async def call_tool(
|
|
284
|
+
**arguments: Any,
|
|
285
|
+
) -> tuple[list[ToolMessageContentBlock], MCPToolArtifact | None]:
|
|
286
|
+
"""Call the captured MCP tool and convert its result."""
|
|
287
|
+
result: _ToolCallResult
|
|
288
|
+
async with client:
|
|
289
|
+
if drives_interrupts:
|
|
290
|
+
result = await _call_tool_with_interrupts(client, tool.name, arguments)
|
|
291
|
+
else:
|
|
292
|
+
# Preserve MCP error results for conversion into failed tool messages.
|
|
293
|
+
result = await client.call_tool(tool.name, arguments, raise_on_error=False)
|
|
294
|
+
return _convert_call_tool_result(result)
|
|
295
|
+
|
|
296
|
+
return StructuredTool(
|
|
297
|
+
name=tool.name,
|
|
298
|
+
description=tool.description or "",
|
|
299
|
+
args_schema=_normalize_mcp_schema(tool.input_schema),
|
|
300
|
+
coroutine=call_tool,
|
|
301
|
+
response_format="content_and_artifact",
|
|
302
|
+
metadata=_tool_metadata(tool, requesting_client),
|
|
303
|
+
handle_tool_error=_handle_mcp_tool_error,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
__all__ = ["MCPToolArtifact", "as_coco_tool"]
|