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.
Files changed (400) hide show
  1. coco/__init__.py +3 -0
  2. coco/agents/__init__.py +9 -0
  3. coco/agents/_subagent_transformer.py +307 -0
  4. coco/agents/factory.py +2164 -0
  5. coco/agents/middleware/__init__.py +100 -0
  6. coco/agents/middleware/_execution.py +385 -0
  7. coco/agents/middleware/_redaction.py +454 -0
  8. coco/agents/middleware/_retry.py +134 -0
  9. coco/agents/middleware/_trace_policy.py +61 -0
  10. coco/agents/middleware/context_editing.py +306 -0
  11. coco/agents/middleware/file_search.py +433 -0
  12. coco/agents/middleware/human_in_the_loop.py +680 -0
  13. coco/agents/middleware/internal_call_transformer.py +108 -0
  14. coco/agents/middleware/model_call_limit.py +267 -0
  15. coco/agents/middleware/model_fallback.py +417 -0
  16. coco/agents/middleware/model_retry.py +327 -0
  17. coco/agents/middleware/pii.py +878 -0
  18. coco/agents/middleware/provider_tool_search.py +307 -0
  19. coco/agents/middleware/shell_tool.py +949 -0
  20. coco/agents/middleware/summarization.py +910 -0
  21. coco/agents/middleware/todo.py +357 -0
  22. coco/agents/middleware/tool_call_limit.py +495 -0
  23. coco/agents/middleware/tool_emulator.py +251 -0
  24. coco/agents/middleware/tool_error.py +209 -0
  25. coco/agents/middleware/tool_retry.py +421 -0
  26. coco/agents/middleware/tool_selection.py +515 -0
  27. coco/agents/middleware/types.py +2201 -0
  28. coco/agents/structured_output.py +472 -0
  29. coco/chat_models/__init__.py +7 -0
  30. coco/chat_models/base.py +1082 -0
  31. coco/embeddings/__init__.py +18 -0
  32. coco/embeddings/base.py +279 -0
  33. coco/mcp/__init__.py +36 -0
  34. coco/mcp/adapter.py +245 -0
  35. coco/mcp/elicitation.py +394 -0
  36. coco/mcp/tools.py +307 -0
  37. coco/messages/__init__.py +73 -0
  38. coco/py.typed +0 -0
  39. coco/rate_limiters/__init__.py +13 -0
  40. coco/tools/__init__.py +22 -0
  41. coco/tools/tool_node.py +20 -0
  42. coco_anthropic/__init__.py +15 -0
  43. coco_anthropic/_client_utils.py +81 -0
  44. coco_anthropic/_compat.py +228 -0
  45. coco_anthropic/_sdk_compat.py +124 -0
  46. coco_anthropic/_version.py +3 -0
  47. coco_anthropic/chat_models.py +3349 -0
  48. coco_anthropic/data/__init__.py +1 -0
  49. coco_anthropic/data/_profiles.py +491 -0
  50. coco_anthropic/data/profile_augmentations.toml +69 -0
  51. coco_anthropic/experimental.py +140 -0
  52. coco_anthropic/llms.py +428 -0
  53. coco_anthropic/middleware/__init__.py +25 -0
  54. coco_anthropic/middleware/anthropic_tools.py +1197 -0
  55. coco_anthropic/middleware/bash.py +84 -0
  56. coco_anthropic/middleware/file_search.py +360 -0
  57. coco_anthropic/middleware/prompt_caching.py +276 -0
  58. coco_anthropic/output_parsers.py +93 -0
  59. coco_anthropic/py.typed +0 -0
  60. coco_core/__init__.py +20 -0
  61. coco_core/_api/__init__.py +87 -0
  62. coco_core/_api/beta_decorator.py +266 -0
  63. coco_core/_api/deprecation.py +637 -0
  64. coco_core/_api/internal.py +23 -0
  65. coco_core/_api/path.py +50 -0
  66. coco_core/_import_utils.py +41 -0
  67. coco_core/_protocol.py +625 -0
  68. coco_core/_security/__init__.py +36 -0
  69. coco_core/_security/_exceptions.py +9 -0
  70. coco_core/_security/_policy.py +313 -0
  71. coco_core/_security/_ssrf_protection.py +155 -0
  72. coco_core/_security/_transport.py +254 -0
  73. coco_core/agents.py +258 -0
  74. coco_core/caches.py +272 -0
  75. coco_core/callbacks/__init__.py +132 -0
  76. coco_core/callbacks/base.py +1229 -0
  77. coco_core/callbacks/file.py +266 -0
  78. coco_core/callbacks/manager.py +2826 -0
  79. coco_core/callbacks/stdout.py +123 -0
  80. coco_core/callbacks/streaming_stdout.py +154 -0
  81. coco_core/callbacks/usage.py +124 -0
  82. coco_core/chat_history.py +267 -0
  83. coco_core/chat_loaders.py +26 -0
  84. coco_core/chat_sessions.py +19 -0
  85. coco_core/cross_encoders.py +18 -0
  86. coco_core/document_loaders/__init__.py +36 -0
  87. coco_core/document_loaders/base.py +155 -0
  88. coco_core/document_loaders/blob_loaders.py +38 -0
  89. coco_core/documents/__init__.py +55 -0
  90. coco_core/documents/base.py +347 -0
  91. coco_core/documents/compressor.py +74 -0
  92. coco_core/documents/transformers.py +79 -0
  93. coco_core/embeddings/__init__.py +31 -0
  94. coco_core/embeddings/embeddings.py +78 -0
  95. coco_core/embeddings/fake.py +129 -0
  96. coco_core/env.py +22 -0
  97. coco_core/example_selectors/__init__.py +47 -0
  98. coco_core/example_selectors/base.py +60 -0
  99. coco_core/example_selectors/length_based.py +131 -0
  100. coco_core/example_selectors/semantic_similarity.py +366 -0
  101. coco_core/exceptions.py +166 -0
  102. coco_core/globals.py +72 -0
  103. coco_core/indexing/__init__.py +53 -0
  104. coco_core/indexing/api.py +954 -0
  105. coco_core/indexing/base.py +661 -0
  106. coco_core/indexing/in_memory.py +104 -0
  107. coco_core/language_models/__init__.py +116 -0
  108. coco_core/language_models/_compat_bridge.py +844 -0
  109. coco_core/language_models/_utils.py +343 -0
  110. coco_core/language_models/base.py +500 -0
  111. coco_core/language_models/chat_model_stream.py +1525 -0
  112. coco_core/language_models/chat_models.py +2748 -0
  113. coco_core/language_models/fake.py +137 -0
  114. coco_core/language_models/fake_chat_models.py +396 -0
  115. coco_core/language_models/llms.py +1569 -0
  116. coco_core/language_models/model_profile.py +183 -0
  117. coco_core/load/__init__.py +44 -0
  118. coco_core/load/_validation.py +191 -0
  119. coco_core/load/dump.py +120 -0
  120. coco_core/load/load.py +828 -0
  121. coco_core/load/mapping.py +1097 -0
  122. coco_core/load/serializable.py +406 -0
  123. coco_core/messages/__init__.py +198 -0
  124. coco_core/messages/ai.py +854 -0
  125. coco_core/messages/base.py +518 -0
  126. coco_core/messages/block_translators/__init__.py +112 -0
  127. coco_core/messages/block_translators/anthropic.py +534 -0
  128. coco_core/messages/block_translators/bedrock.py +108 -0
  129. coco_core/messages/block_translators/bedrock_converse.py +329 -0
  130. coco_core/messages/block_translators/coco_v0.py +303 -0
  131. coco_core/messages/block_translators/google_genai.py +610 -0
  132. coco_core/messages/block_translators/google_vertexai.py +21 -0
  133. coco_core/messages/block_translators/groq.py +157 -0
  134. coco_core/messages/block_translators/openai.py +1089 -0
  135. coco_core/messages/chat.py +64 -0
  136. coco_core/messages/content.py +1488 -0
  137. coco_core/messages/function.py +62 -0
  138. coco_core/messages/human.py +70 -0
  139. coco_core/messages/modifier.py +33 -0
  140. coco_core/messages/system.py +70 -0
  141. coco_core/messages/tool.py +418 -0
  142. coco_core/messages/utils.py +2442 -0
  143. coco_core/output_parsers/__init__.py +101 -0
  144. coco_core/output_parsers/base.py +361 -0
  145. coco_core/output_parsers/format_instructions.py +16 -0
  146. coco_core/output_parsers/json.py +139 -0
  147. coco_core/output_parsers/list.py +249 -0
  148. coco_core/output_parsers/openai_functions.py +324 -0
  149. coco_core/output_parsers/openai_tools.py +383 -0
  150. coco_core/output_parsers/pydantic.py +141 -0
  151. coco_core/output_parsers/string.py +63 -0
  152. coco_core/output_parsers/transform.py +175 -0
  153. coco_core/output_parsers/xml.py +301 -0
  154. coco_core/outputs/__init__.py +66 -0
  155. coco_core/outputs/chat_generation.py +147 -0
  156. coco_core/outputs/chat_result.py +39 -0
  157. coco_core/outputs/generation.py +80 -0
  158. coco_core/outputs/llm_result.py +112 -0
  159. coco_core/outputs/run_info.py +22 -0
  160. coco_core/prompt_values.py +161 -0
  161. coco_core/prompts/__init__.py +101 -0
  162. coco_core/prompts/base.py +508 -0
  163. coco_core/prompts/chat.py +1495 -0
  164. coco_core/prompts/dict.py +177 -0
  165. coco_core/prompts/few_shot.py +483 -0
  166. coco_core/prompts/few_shot_with_templates.py +239 -0
  167. coco_core/prompts/image.py +177 -0
  168. coco_core/prompts/loading.py +296 -0
  169. coco_core/prompts/message.py +97 -0
  170. coco_core/prompts/prompt.py +312 -0
  171. coco_core/prompts/string.py +399 -0
  172. coco_core/prompts/structured.py +214 -0
  173. coco_core/py.typed +0 -0
  174. coco_core/rate_limiters.py +254 -0
  175. coco_core/retrievers.py +328 -0
  176. coco_core/runnables/__init__.py +136 -0
  177. coco_core/runnables/base.py +6725 -0
  178. coco_core/runnables/branch.py +457 -0
  179. coco_core/runnables/config.py +712 -0
  180. coco_core/runnables/configurable.py +710 -0
  181. coco_core/runnables/fallbacks.py +663 -0
  182. coco_core/runnables/graph.py +741 -0
  183. coco_core/runnables/graph_ascii.py +366 -0
  184. coco_core/runnables/graph_mermaid.py +503 -0
  185. coco_core/runnables/graph_png.py +215 -0
  186. coco_core/runnables/history.py +631 -0
  187. coco_core/runnables/passthrough.py +849 -0
  188. coco_core/runnables/retry.py +379 -0
  189. coco_core/runnables/router.py +239 -0
  190. coco_core/runnables/schema.py +188 -0
  191. coco_core/runnables/utils.py +798 -0
  192. coco_core/stores.py +291 -0
  193. coco_core/structured_query.py +203 -0
  194. coco_core/sys_info.py +154 -0
  195. coco_core/tools/__init__.py +95 -0
  196. coco_core/tools/base.py +1948 -0
  197. coco_core/tools/convert.py +497 -0
  198. coco_core/tools/render.py +67 -0
  199. coco_core/tools/retriever.py +94 -0
  200. coco_core/tools/simple.py +208 -0
  201. coco_core/tools/structured.py +322 -0
  202. coco_core/tracers/__init__.py +47 -0
  203. coco_core/tracers/_compat.py +95 -0
  204. coco_core/tracers/_context.py +337 -0
  205. coco_core/tracers/_streaming.py +53 -0
  206. coco_core/tracers/base.py +955 -0
  207. coco_core/tracers/coco.py +279 -0
  208. coco_core/tracers/context.py +205 -0
  209. coco_core/tracers/core.py +766 -0
  210. coco_core/tracers/event_stream.py +1101 -0
  211. coco_core/tracers/log_stream.py +765 -0
  212. coco_core/tracers/memory_stream.py +200 -0
  213. coco_core/tracers/root_listeners.py +130 -0
  214. coco_core/tracers/run_collector.py +40 -0
  215. coco_core/tracers/schemas.py +14 -0
  216. coco_core/tracers/stdout.py +205 -0
  217. coco_core/utils/__init__.py +111 -0
  218. coco_core/utils/_gateway.py +274 -0
  219. coco_core/utils/_merge.py +218 -0
  220. coco_core/utils/aiter.py +357 -0
  221. coco_core/utils/env.py +86 -0
  222. coco_core/utils/formatting.py +80 -0
  223. coco_core/utils/function_calling.py +869 -0
  224. coco_core/utils/html.py +132 -0
  225. coco_core/utils/image.py +15 -0
  226. coco_core/utils/input.py +82 -0
  227. coco_core/utils/interactive_env.py +12 -0
  228. coco_core/utils/iter.py +229 -0
  229. coco_core/utils/json.py +230 -0
  230. coco_core/utils/json_schema.py +273 -0
  231. coco_core/utils/mustache.py +706 -0
  232. coco_core/utils/pydantic.py +631 -0
  233. coco_core/utils/strings.py +69 -0
  234. coco_core/utils/usage.py +61 -0
  235. coco_core/utils/utils.py +528 -0
  236. coco_core/utils/uuid.py +57 -0
  237. coco_core/vectorstores/__init__.py +53 -0
  238. coco_core/vectorstores/base.py +1111 -0
  239. coco_core/vectorstores/in_memory.py +546 -0
  240. coco_core/vectorstores/utils.py +163 -0
  241. coco_core/version.py +3 -0
  242. coco_framework-0.1.0.dist-info/METADATA +184 -0
  243. coco_framework-0.1.0.dist-info/RECORD +400 -0
  244. coco_framework-0.1.0.dist-info/WHEEL +4 -0
  245. coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
  246. coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
  247. coco_graph/_internal/__init__.py +4 -0
  248. coco_graph/_internal/_cache.py +31 -0
  249. coco_graph/_internal/_config.py +474 -0
  250. coco_graph/_internal/_constants.py +140 -0
  251. coco_graph/_internal/_fields.py +213 -0
  252. coco_graph/_internal/_future.py +220 -0
  253. coco_graph/_internal/_pydantic.py +275 -0
  254. coco_graph/_internal/_queue.py +124 -0
  255. coco_graph/_internal/_replay.py +90 -0
  256. coco_graph/_internal/_retry.py +29 -0
  257. coco_graph/_internal/_runnable.py +969 -0
  258. coco_graph/_internal/_scratchpad.py +19 -0
  259. coco_graph/_internal/_serde.py +253 -0
  260. coco_graph/_internal/_timeout.py +25 -0
  261. coco_graph/_internal/_typing.py +54 -0
  262. coco_graph/cache/base/__init__.py +48 -0
  263. coco_graph/cache/base/py.typed +0 -0
  264. coco_graph/cache/memory/__init__.py +73 -0
  265. coco_graph/cache/redis/__init__.py +144 -0
  266. coco_graph/cache/sqlite/__init__.py +120 -0
  267. coco_graph/callbacks.py +394 -0
  268. coco_graph/channels/__init__.py +29 -0
  269. coco_graph/channels/any_value.py +72 -0
  270. coco_graph/channels/base.py +121 -0
  271. coco_graph/channels/binop.py +155 -0
  272. coco_graph/channels/delta.py +202 -0
  273. coco_graph/channels/ephemeral_value.py +79 -0
  274. coco_graph/channels/last_value.py +151 -0
  275. coco_graph/channels/named_barrier_value.py +167 -0
  276. coco_graph/channels/topic.py +94 -0
  277. coco_graph/channels/untracked_value.py +73 -0
  278. coco_graph/checkpoint/base/__init__.py +861 -0
  279. coco_graph/checkpoint/base/id.py +109 -0
  280. coco_graph/checkpoint/base/py.typed +0 -0
  281. coco_graph/checkpoint/memory/__init__.py +698 -0
  282. coco_graph/checkpoint/memory/py.typed +0 -0
  283. coco_graph/checkpoint/postgres/__init__.py +602 -0
  284. coco_graph/checkpoint/postgres/_ainternal.py +23 -0
  285. coco_graph/checkpoint/postgres/_internal.py +21 -0
  286. coco_graph/checkpoint/postgres/aio.py +691 -0
  287. coco_graph/checkpoint/postgres/base.py +666 -0
  288. coco_graph/checkpoint/postgres/py.typed +0 -0
  289. coco_graph/checkpoint/postgres/shallow.py +967 -0
  290. coco_graph/checkpoint/serde/__init__.py +0 -0
  291. coco_graph/checkpoint/serde/_msgpack.py +97 -0
  292. coco_graph/checkpoint/serde/base.py +64 -0
  293. coco_graph/checkpoint/serde/encrypted.py +80 -0
  294. coco_graph/checkpoint/serde/event_hooks.py +52 -0
  295. coco_graph/checkpoint/serde/jsonplus.py +883 -0
  296. coco_graph/checkpoint/serde/py.typed +0 -0
  297. coco_graph/checkpoint/serde/types.py +68 -0
  298. coco_graph/checkpoint/sqlite/__init__.py +645 -0
  299. coco_graph/checkpoint/sqlite/_delta.py +172 -0
  300. coco_graph/checkpoint/sqlite/aio.py +738 -0
  301. coco_graph/checkpoint/sqlite/py.typed +0 -0
  302. coco_graph/checkpoint/sqlite/utils.py +116 -0
  303. coco_graph/config.py +196 -0
  304. coco_graph/constants.py +64 -0
  305. coco_graph/errors.py +241 -0
  306. coco_graph/func/__init__.py +620 -0
  307. coco_graph/graph/__init__.py +12 -0
  308. coco_graph/graph/_branch.py +225 -0
  309. coco_graph/graph/_node.py +103 -0
  310. coco_graph/graph/message.py +437 -0
  311. coco_graph/graph/state.py +1978 -0
  312. coco_graph/graph/ui.py +227 -0
  313. coco_graph/managed/__init__.py +3 -0
  314. coco_graph/managed/base.py +31 -0
  315. coco_graph/managed/is_last_step.py +24 -0
  316. coco_graph/prebuilt/__init__.py +23 -0
  317. coco_graph/prebuilt/_tool_call_stream.py +117 -0
  318. coco_graph/prebuilt/_tool_call_transformer.py +165 -0
  319. coco_graph/prebuilt/chat_agent_executor.py +1015 -0
  320. coco_graph/prebuilt/interrupt.py +105 -0
  321. coco_graph/prebuilt/py.typed +0 -0
  322. coco_graph/prebuilt/tool_node.py +2030 -0
  323. coco_graph/prebuilt/tool_validator.py +221 -0
  324. coco_graph/pregel/__init__.py +3 -0
  325. coco_graph/pregel/_algo.py +1460 -0
  326. coco_graph/pregel/_call.py +298 -0
  327. coco_graph/pregel/_checkpoint.py +331 -0
  328. coco_graph/pregel/_config.py +0 -0
  329. coco_graph/pregel/_draw.py +294 -0
  330. coco_graph/pregel/_executor.py +223 -0
  331. coco_graph/pregel/_io.py +174 -0
  332. coco_graph/pregel/_log.py +3 -0
  333. coco_graph/pregel/_loop.py +1988 -0
  334. coco_graph/pregel/_messages.py +461 -0
  335. coco_graph/pregel/_read.py +312 -0
  336. coco_graph/pregel/_retry.py +854 -0
  337. coco_graph/pregel/_runner.py +941 -0
  338. coco_graph/pregel/_tools.py +268 -0
  339. coco_graph/pregel/_utils.py +221 -0
  340. coco_graph/pregel/_validate.py +120 -0
  341. coco_graph/pregel/_write.py +192 -0
  342. coco_graph/pregel/debug.py +302 -0
  343. coco_graph/pregel/main.py +4364 -0
  344. coco_graph/pregel/protocol.py +288 -0
  345. coco_graph/pregel/types.py +38 -0
  346. coco_graph/py.typed +0 -0
  347. coco_graph/runtime.py +350 -0
  348. coco_graph/store/base/__init__.py +1322 -0
  349. coco_graph/store/base/batch.py +371 -0
  350. coco_graph/store/base/embed.py +436 -0
  351. coco_graph/store/base/py.typed +0 -0
  352. coco_graph/store/memory/__init__.py +592 -0
  353. coco_graph/store/memory/py.typed +0 -0
  354. coco_graph/store/postgres/__init__.py +4 -0
  355. coco_graph/store/postgres/aio.py +592 -0
  356. coco_graph/store/postgres/base.py +1480 -0
  357. coco_graph/store/postgres/py.typed +0 -0
  358. coco_graph/store/sqlite/__init__.py +4 -0
  359. coco_graph/store/sqlite/aio.py +632 -0
  360. coco_graph/store/sqlite/base.py +1514 -0
  361. coco_graph/stream/__init__.py +45 -0
  362. coco_graph/stream/_convert.py +32 -0
  363. coco_graph/stream/_mux.py +523 -0
  364. coco_graph/stream/_types.py +330 -0
  365. coco_graph/stream/run_stream.py +735 -0
  366. coco_graph/stream/stream_channel.py +341 -0
  367. coco_graph/stream/transformers.py +1039 -0
  368. coco_graph/types.py +1079 -0
  369. coco_graph/typing.py +48 -0
  370. coco_graph/utils/__init__.py +1 -0
  371. coco_graph/utils/config.py +4 -0
  372. coco_graph/utils/runnable.py +3 -0
  373. coco_graph/version.py +13 -0
  374. coco_graph/warnings.py +69 -0
  375. coco_openai/__init__.py +20 -0
  376. coco_openai/_compat.py +45 -0
  377. coco_openai/_version.py +3 -0
  378. coco_openai/chat_models/__init__.py +6 -0
  379. coco_openai/chat_models/_client_utils.py +684 -0
  380. coco_openai/chat_models/_compat.py +525 -0
  381. coco_openai/chat_models/azure.py +1227 -0
  382. coco_openai/chat_models/base.py +5654 -0
  383. coco_openai/chat_models/codex.py +557 -0
  384. coco_openai/chatgpt_oauth.py +1063 -0
  385. coco_openai/data/__init__.py +1 -0
  386. coco_openai/data/_profiles.py +1655 -0
  387. coco_openai/data/profile_augmentations.toml +127 -0
  388. coco_openai/embeddings/__init__.py +6 -0
  389. coco_openai/embeddings/azure.py +240 -0
  390. coco_openai/embeddings/base.py +820 -0
  391. coco_openai/llms/__init__.py +6 -0
  392. coco_openai/llms/azure.py +249 -0
  393. coco_openai/llms/base.py +919 -0
  394. coco_openai/middleware/__init__.py +11 -0
  395. coco_openai/middleware/openai_moderation.py +484 -0
  396. coco_openai/output_parsers/__init__.py +9 -0
  397. coco_openai/output_parsers/tools.py +9 -0
  398. coco_openai/py.typed +0 -0
  399. coco_openai/tools/__init__.py +5 -0
  400. coco_openai/tools/custom_tool.py +111 -0
@@ -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"]