copilotkit-intelligence-runtime 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.
@@ -0,0 +1,75 @@
1
+ """Track unfinished AG-UI streams without retaining message or tool content."""
2
+
3
+ import json
4
+
5
+ from .models import Json
6
+
7
+
8
+ class EventFinalizer:
9
+ """Append TS-compatible closers only when the source omitted a terminal event."""
10
+
11
+ def __init__(self) -> None:
12
+ self.messages: dict[str, None] = {}
13
+ self.tools: dict[str, tuple[bool, bool]] = {}
14
+ self.terminal = False
15
+
16
+ def observe(self, event: Json) -> None:
17
+ """Retain only open identifiers and terminal status from an emitted event."""
18
+ kind = event.get("type")
19
+ message = event.get("messageId")
20
+ tool = event.get("toolCallId")
21
+ if kind in ("RUN_FINISHED", "RUN_ERROR"):
22
+ self.terminal = True
23
+ elif kind == "TEXT_MESSAGE_START" and isinstance(message, str):
24
+ self.messages[message] = None
25
+ elif kind == "TEXT_MESSAGE_END" and isinstance(message, str):
26
+ self.messages.pop(message, None)
27
+ elif kind == "TOOL_CALL_START" and isinstance(tool, str):
28
+ self.tools[tool] = (False, False)
29
+ elif isinstance(tool, str) and tool in self.tools:
30
+ ended, result = self.tools[tool]
31
+ self.tools[tool] = (
32
+ ended or kind == "TOOL_CALL_END",
33
+ result or kind == "TOOL_CALL_RESULT",
34
+ )
35
+
36
+ def finish(self, *, stop_requested: bool = False) -> list[Json]:
37
+ """Close missing streams and emit a clean stop or an incomplete-stream error."""
38
+ if self.terminal:
39
+ return []
40
+ message = (
41
+ "Run stopped by user"
42
+ if stop_requested
43
+ else "Run ended without emitting a terminal event"
44
+ )
45
+ result: list[Json] = [
46
+ {"type": "TEXT_MESSAGE_END", "messageId": key} for key in self.messages
47
+ ]
48
+ for key, (ended, has_result) in self.tools.items():
49
+ if not ended:
50
+ result.append({"type": "TOOL_CALL_END", "toolCallId": key})
51
+ if not has_result:
52
+ result.append(
53
+ {
54
+ "type": "TOOL_CALL_RESULT",
55
+ "toolCallId": key,
56
+ "messageId": f"{key}-result",
57
+ "role": "tool",
58
+ "content": json.dumps(
59
+ {
60
+ "status": "stopped" if stop_requested else "error",
61
+ "reason": "stop_requested"
62
+ if stop_requested
63
+ else "missing_terminal_event",
64
+ "message": message,
65
+ },
66
+ separators=(",", ":"),
67
+ ),
68
+ }
69
+ )
70
+ result.append(
71
+ {"type": "RUN_FINISHED"}
72
+ if stop_requested
73
+ else {"type": "RUN_ERROR", "message": message, "code": "INCOMPLETE_STREAM"}
74
+ )
75
+ return result
@@ -0,0 +1,287 @@
1
+ """Phoenix ingestion with independent control reception and acknowledged delivery."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
7
+ from uuid import uuid4
8
+
9
+ from websockets.asyncio.client import ClientConnection, connect
10
+ from websockets.exceptions import WebSocketException
11
+ from websockets.typing import Subprotocol
12
+
13
+ from .models import Json, RuntimeConfig
14
+ from .telemetry import Telemetry
15
+
16
+
17
+ class DeliveryRejected(ConnectionError):
18
+ """A permanent gateway rejection cannot be repaired through transport retries."""
19
+
20
+
21
+ class Gateway:
22
+ """One independent receiver and one ordered, bounded delivery lane per run."""
23
+
24
+ def __init__(
25
+ self, config: RuntimeConfig, thread_id: str, run_id: str, telemetry: Telemetry
26
+ ) -> None:
27
+ self.config = config
28
+ self.thread_id = thread_id
29
+ self.run_id = run_id
30
+ self.telemetry = telemetry
31
+ self.topic = f"ingestion:{run_id}"
32
+ self.socket: ClientConnection | None = None
33
+ self.ref = 0
34
+ self.join_ref = ""
35
+ self.sequence = 0
36
+ self.failed = False
37
+ self.supports_batch = False
38
+ self.stop_requested = asyncio.Event()
39
+ self._disconnected = asyncio.Event()
40
+ self._reader: asyncio.Task[None] | None = None
41
+ self._pending: dict[str, tuple[str, asyncio.Future[Json]]] = {}
42
+ self.lock = asyncio.Lock()
43
+ self._closed = False
44
+ self._deliveries: set[asyncio.Task[None]] = set()
45
+
46
+ async def join(self) -> None:
47
+ """Wait for authenticated join, retrying only recoverable startup failures."""
48
+ for attempt in range(self.config.max_delivery_attempts):
49
+ try:
50
+ await self._join_once()
51
+ return
52
+ except DeliveryRejected:
53
+ await self._disconnect()
54
+ raise
55
+ except (ConnectionError, TimeoutError, OSError, WebSocketException):
56
+ await self._disconnect()
57
+ if attempt + 1 == self.config.max_delivery_attempts:
58
+ raise
59
+ await asyncio.sleep(min(0.1 * 2**attempt, 2))
60
+
61
+ async def _join_once(self) -> None:
62
+ """Open a fresh socket, start its receiver, and negotiate batching capability."""
63
+ if self._closed:
64
+ raise DeliveryRejected("Gateway is closed")
65
+ parts = urlsplit(self.config.runner_url)
66
+ path = parts.path.rstrip("/")
67
+ if not path.endswith("/websocket"):
68
+ path += "/websocket"
69
+ query = dict(parse_qsl(parts.query))
70
+ query["vsn"] = "2.0.0"
71
+ url = urlunsplit((parts.scheme, parts.netloc, path, urlencode(query), ""))
72
+ token = base64.b64encode(self.config.api_key.encode()).decode().rstrip("=")
73
+ self.socket = await connect(
74
+ url,
75
+ subprotocols=[Subprotocol("phoenix"), Subprotocol("base64url.bearer.phx." + token)],
76
+ open_timeout=self.config.ack_timeout,
77
+ close_timeout=1,
78
+ max_size=self.config.max_body_bytes,
79
+ )
80
+ self._disconnected.clear()
81
+ self._reader = asyncio.create_task(
82
+ self._receive(self.socket), name="copilotkit-gateway-receiver"
83
+ )
84
+ try:
85
+ reply = await self._push(
86
+ "phx_join", {"thread_id": self.thread_id, "run_id": self.run_id}
87
+ )
88
+ self.supports_batch = "runner_event_batch_v1" in reply.get("capabilities", [])
89
+ except BaseException:
90
+ await self._disconnect()
91
+ raise
92
+
93
+ async def _receive(self, socket: ClientConnection) -> None:
94
+ """Read control frames while the agent is idle or a push waits for its ACK."""
95
+ try:
96
+ async for raw in socket:
97
+ frame = json.loads(raw)
98
+ if not isinstance(frame, list) or len(frame) != 5:
99
+ raise ConnectionError("Invalid Phoenix frame")
100
+ _, ref, topic, event, payload = frame
101
+ if event == "ag-ui" and topic == self.topic and isinstance(payload, dict):
102
+ if payload.get("type") == "CUSTOM" and payload.get("name") == "stop":
103
+ self.stop_requested.set()
104
+ elif event == "phx_reply" and ref in self._pending:
105
+ expected_topic, future = self._pending[ref]
106
+ if topic == expected_topic and not future.done():
107
+ if not isinstance(payload, dict):
108
+ future.set_exception(ConnectionError("Invalid Phoenix reply"))
109
+ else:
110
+ future.set_result(payload)
111
+ elif event in ("phx_close", "phx_error") and topic == self.topic:
112
+ raise ConnectionError("Phoenix channel closed")
113
+ except (ValueError, ConnectionError, OSError, WebSocketException):
114
+ pass
115
+ finally:
116
+ if self.socket is socket:
117
+ self._disconnected.set()
118
+ for _, future in self._pending.values():
119
+ if not future.done():
120
+ future.set_exception(ConnectionError("Gateway connection closed"))
121
+
122
+ async def _push(self, event: str, payload: Json, topic: str | None = None) -> Json:
123
+ """Match one reply reference without consuming another push's control messages."""
124
+ if self.socket is None or self._disconnected.is_set():
125
+ raise ConnectionError("Gateway is disconnected")
126
+ self.ref += 1
127
+ ref, target = str(self.ref), topic or self.topic
128
+ if event == "phx_join":
129
+ self.join_ref = ref
130
+ future: asyncio.Future[Json] = asyncio.get_running_loop().create_future()
131
+ self._pending[ref] = (target, future)
132
+ try:
133
+ async with asyncio.timeout(self.config.ack_timeout):
134
+ await self.socket.send(
135
+ json.dumps([self.join_ref, ref, target, event, payload], allow_nan=False)
136
+ )
137
+ reply = await future
138
+ response = reply.get("response", {})
139
+ response = response if isinstance(response, dict) else {}
140
+ if reply.get("status") != "ok":
141
+ retryable = (
142
+ response.get("retryable") is True
143
+ or response.get("reason") == "gateway_draining"
144
+ )
145
+ if response.get("retryable") is False or (event == "phx_join" and not retryable):
146
+ raise DeliveryRejected("Phoenix push permanently rejected")
147
+ raise ConnectionError("Phoenix push rejected")
148
+ return response
149
+ finally:
150
+ self._pending.pop(ref, None)
151
+ if future.done() and not future.cancelled():
152
+ future.exception()
153
+ else:
154
+ future.cancel()
155
+
156
+ async def send(self, event: Json) -> None:
157
+ """Send one immutable event, with backpressure until its durable ACK."""
158
+ await self.send_many([event])
159
+
160
+ async def send_many(self, events: list[Json]) -> None:
161
+ """Defer cancellation until the active immutable group reaches its ACK boundary."""
162
+ detached: list[Json] = json.loads(json.dumps(events, allow_nan=False))
163
+ task = asyncio.create_task(self._deliver(detached))
164
+ self._deliveries.add(task)
165
+ cancelled = False
166
+ try:
167
+ while not task.done():
168
+ try:
169
+ await asyncio.shield(task)
170
+ except asyncio.CancelledError:
171
+ # Each stop/lease cancellation must respect the same ACK boundary.
172
+ # Explicit abort() cancels the delivery itself, ending this loop.
173
+ cancelled = True
174
+ task.result()
175
+ if cancelled:
176
+ raise asyncio.CancelledError
177
+ finally:
178
+ self._deliveries.discard(task)
179
+
180
+ async def _deliver(self, events: list[Json]) -> None:
181
+ """Stamp at most 32 events atomically and replay the same payloads after disconnect."""
182
+ if not events or len(events) > 32:
183
+ raise ValueError("Gateway batches require between 1 and 32 events")
184
+ # send_many already detached and validated this snapshot.
185
+ async with self.lock:
186
+ if self.failed or self._closed:
187
+ raise DeliveryRejected("Gateway delivery is unavailable")
188
+ payloads = []
189
+ for index, event in enumerate(events):
190
+ if not isinstance(event, dict) or not isinstance(event.get("type"), str):
191
+ raise ValueError("Invalid AG-UI event")
192
+ metadata = event.get("metadata") or {}
193
+ if not isinstance(metadata, dict):
194
+ raise ValueError("Invalid event metadata")
195
+ payloads.append(
196
+ {
197
+ **event,
198
+ "threadId": self.thread_id,
199
+ "runId": self.run_id,
200
+ "thread_id": self.thread_id,
201
+ "run_id": self.run_id,
202
+ "metadata": {
203
+ **metadata,
204
+ "cpki_event_id": str(uuid4()),
205
+ "cpki_event_seq": self.sequence + index + 1,
206
+ },
207
+ }
208
+ )
209
+ self.sequence += len(payloads)
210
+ position = 0
211
+ attempts = 0
212
+ try:
213
+ while position < len(payloads):
214
+ try:
215
+ if self.socket is None or self._disconnected.is_set():
216
+ await self._disconnect()
217
+ await self._join_once()
218
+ batch = (
219
+ payloads[position:]
220
+ if self.supports_batch
221
+ else payloads[position : position + 1]
222
+ )
223
+ await self._push(
224
+ "events" if self.supports_batch else "event",
225
+ {"events": batch} if self.supports_batch else batch[0],
226
+ )
227
+ position += len(batch)
228
+ attempts = 0
229
+ except DeliveryRejected:
230
+ raise
231
+ except (ConnectionError, TimeoutError, OSError, WebSocketException):
232
+ attempts += 1
233
+ await self._disconnect()
234
+ if attempts >= self.config.max_delivery_attempts:
235
+ raise ConnectionError("Event delivery retry budget exhausted")
236
+ await asyncio.sleep(min(0.1 * 2 ** (attempts - 1), 2))
237
+ except BaseException:
238
+ self.failed = True
239
+ raise
240
+
241
+ async def keepalive(self) -> None:
242
+ """Reconnect idle channels and maintain Phoenix heartbeat frames without rerunning agents."""
243
+ while not self._closed:
244
+ try:
245
+ await asyncio.wait_for(self._disconnected.wait(), timeout=15)
246
+ except TimeoutError:
247
+ pass
248
+ async with self.lock:
249
+ if self._closed:
250
+ return
251
+ if self._disconnected.is_set():
252
+ await self._disconnect()
253
+ await self.join()
254
+ else:
255
+ try:
256
+ await self._push("heartbeat", {}, "phoenix")
257
+ except (ConnectionError, TimeoutError, OSError, WebSocketException):
258
+ await self._disconnect()
259
+ await self.join()
260
+
261
+ async def _disconnect(self) -> None:
262
+ """Close one transport and settle its receiver before replacing it."""
263
+ socket, reader = self.socket, self._reader
264
+ self.socket = None
265
+ self._reader = None
266
+ if reader:
267
+ reader.cancel()
268
+ await asyncio.gather(reader, return_exceptions=True)
269
+ if socket:
270
+ await socket.close()
271
+
272
+ async def aclose(self) -> None:
273
+ """Close only after delivery completes, or after a terminal failure/shutdown."""
274
+ self._closed = True
275
+ for task in self._deliveries:
276
+ task.cancel()
277
+ await self._disconnect()
278
+
279
+ def abort(self) -> None:
280
+ """Force-close transport at the host shutdown deadline; no ACK is claimed."""
281
+ self._closed = self.failed = True
282
+ for task in self._deliveries:
283
+ task.cancel()
284
+ if self._reader:
285
+ self._reader.cancel()
286
+ if self.socket:
287
+ self.socket.transport.abort()
@@ -0,0 +1,299 @@
1
+ """MCP Apps middleware with native authenticated Streamable HTTP sessions."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ from collections.abc import AsyncIterator
7
+ from dataclasses import dataclass, field
8
+ from datetime import timedelta
9
+ from urllib.parse import urlsplit
10
+ from uuid import uuid4
11
+
12
+ from mcp import ClientSession, types
13
+ from mcp.client.streamable_http import streamablehttp_client
14
+ from mcp.shared.message import SessionMessage
15
+
16
+ from .models import Json
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class MCPServer:
21
+ """Server-owned endpoint/authentication. Browser input cannot alter this configuration."""
22
+
23
+ url: str
24
+ server_id: str | None = None
25
+ agent_id: str | None = None
26
+ headers: dict[str, str] = field(default_factory=dict, repr=False)
27
+
28
+ def __post_init__(self) -> None:
29
+ """Reject non-HTTP endpoints and credentials embedded in URLs."""
30
+ parsed = urlsplit(self.url)
31
+ if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username:
32
+ raise ValueError("MCP server requires an HTTP URL without embedded credentials")
33
+
34
+ @property
35
+ def server_hash(self) -> str:
36
+ """Match the opaque reference used by the TypeScript MCP Apps renderer."""
37
+ serialized = json.dumps({"type": "http", "url": self.url}, separators=(",", ":"))
38
+ return hashlib.md5(serialized.encode(), usedforsecurity=False).hexdigest()
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class MCPAppsConfig:
43
+ """Bound MCP request duration, discovery pages, and buffered arguments."""
44
+
45
+ servers: tuple[MCPServer, ...] = ()
46
+ timeout: float = 30
47
+ max_tool_pages: int = 20
48
+ max_argument_bytes: int = 2 * 1024 * 1024
49
+
50
+
51
+ class MCPAppsMiddleware:
52
+ """Expose UI-enabled tools and execute iframe requests against configured servers."""
53
+
54
+ def __init__(self, config: MCPAppsConfig) -> None:
55
+ self.config = config
56
+
57
+ def _servers(self, agent_id: str) -> tuple[MCPServer, ...]:
58
+ """Select the allowed MCP servers for one agent."""
59
+ return tuple(
60
+ server for server in self.config.servers if server.agent_id in (None, agent_id)
61
+ )
62
+
63
+ async def _request(self, server: MCPServer, method: str, params: Json | None = None) -> Json:
64
+ """Negotiate an MCP session and close its transport after one bounded operation."""
65
+ async with asyncio.timeout(self.config.timeout):
66
+ async with streamablehttp_client(
67
+ server.url,
68
+ headers=dict(server.headers),
69
+ timeout=self.config.timeout,
70
+ sse_read_timeout=self.config.timeout,
71
+ ) as (read, write, _):
72
+ async with ClientSession(
73
+ read, write, read_timeout_seconds=timedelta(seconds=self.config.timeout)
74
+ ) as session:
75
+ # Public protocol calls allow the UI extension without an SDK subclass.
76
+ initialized = await session.send_request(
77
+ types.ClientRequest(
78
+ types.InitializeRequest(
79
+ params=types.InitializeRequestParams(
80
+ protocolVersion="2025-06-18",
81
+ capabilities=types.ClientCapabilities.model_validate(
82
+ {
83
+ "extensions": {
84
+ "io.modelcontextprotocol/ui": {
85
+ "mimeTypes": ["text/html;profile=mcp-app"]
86
+ }
87
+ }
88
+ }
89
+ ),
90
+ clientInfo=types.Implementation(
91
+ name="copilotkit-runtime-python", version="0.1.0"
92
+ ),
93
+ )
94
+ )
95
+ ),
96
+ types.InitializeResult,
97
+ )
98
+ if initialized.protocolVersion not in (
99
+ "2024-11-05",
100
+ "2025-03-26",
101
+ "2025-06-18",
102
+ "2025-11-25",
103
+ ):
104
+ raise ValueError("Unsupported MCP protocol version")
105
+ await session.send_notification(
106
+ types.ClientNotification(types.InitializedNotification())
107
+ )
108
+ if method == "tools/list":
109
+ tools: list[Json] = []
110
+ cursor = None
111
+ cursors: set[str] = set()
112
+ for _page in range(self.config.max_tool_pages):
113
+ page = await session.list_tools(cursor=cursor)
114
+ tools.extend(
115
+ tool.model_dump(mode="json", by_alias=True, exclude_none=True)
116
+ for tool in page.tools
117
+ )
118
+ cursor = page.nextCursor
119
+ if not cursor:
120
+ return {"tools": tools}
121
+ if cursor in cursors:
122
+ raise ValueError("MCP discovery repeated its cursor")
123
+ cursors.add(cursor)
124
+ raise ValueError("MCP discovery page limit exceeded")
125
+ if method == "tools/call":
126
+ result = await session.send_request(
127
+ types.ClientRequest(
128
+ types.CallToolRequest(
129
+ params=types.CallToolRequestParams.model_validate(params or {})
130
+ )
131
+ ),
132
+ types.CallToolResult,
133
+ )
134
+ return result.model_dump(mode="json", by_alias=True, exclude_none=True)
135
+ if method == "resources/read":
136
+ resource = await session.read_resource((params or {})["uri"])
137
+ return resource.model_dump(mode="json", by_alias=True, exclude_none=True)
138
+ if method == "ping":
139
+ pong = await session.send_ping()
140
+ return pong.model_dump(mode="json", by_alias=True, exclude_none=True)
141
+ if method == "notifications/message":
142
+ await write.send(
143
+ SessionMessage(
144
+ types.JSONRPCMessage(
145
+ types.JSONRPCNotification(
146
+ jsonrpc="2.0", method=method, params=params
147
+ )
148
+ )
149
+ )
150
+ )
151
+ return {"success": True}
152
+ raise ValueError("MCP method not allowed")
153
+
154
+ async def discover(
155
+ self, input: Json, agent_id: str
156
+ ) -> tuple[Json, dict[str, tuple[MCPServer, str]]]:
157
+ """Inject tools with UI resources and reject ambiguous server tool names."""
158
+ tools = list(input.get("tools", []))
159
+ by_name: dict[str, tuple[MCPServer, str]] = {}
160
+ existing = {tool.get("name") for tool in tools}
161
+ for server in self._servers(agent_id):
162
+ result = await self._request(server, "tools/list")
163
+ for tool in result["tools"]:
164
+ meta = tool.get("_meta")
165
+ meta = meta if isinstance(meta, dict) else {}
166
+ ui = meta.get("ui")
167
+ if isinstance(ui, dict) and "visibility" in ui:
168
+ visibility = ui["visibility"]
169
+ if not isinstance(visibility, list) or "model" not in visibility:
170
+ continue
171
+ uri = ui.get("resourceUri") if isinstance(ui, dict) else None
172
+ if not isinstance(uri, str):
173
+ uri = meta.get("ui/resourceUri")
174
+ if not isinstance(uri, str):
175
+ continue
176
+ name = tool["name"]
177
+ if name in existing:
178
+ raise ValueError("MCP tool name collision")
179
+ existing.add(name)
180
+ by_name[name] = (server, uri)
181
+ tools.append(
182
+ {
183
+ "name": name,
184
+ "description": tool.get("description", "") + f"\n[UI Resource: {uri}]",
185
+ "parameters": tool.get("inputSchema", {"type": "object", "properties": {}}),
186
+ }
187
+ )
188
+ return {**input, "tools": tools}, by_name
189
+
190
+ async def proxy(self, request: Json, agent_id: str) -> AsyncIterator[Json]:
191
+ """Bypass the agent for allowlisted browser iframe requests."""
192
+ yield {"type": "RUN_STARTED"}
193
+ servers = self._servers(agent_id)
194
+ server_id = request.get("serverId")
195
+ matches = [
196
+ item
197
+ for item in servers
198
+ if (
199
+ item.server_id == server_id
200
+ if server_id
201
+ else item.server_hash == request.get("serverHash")
202
+ )
203
+ ]
204
+ server = matches[0] if len(matches) == 1 else None
205
+ if server is None:
206
+ result: Json = {"error": "Unknown MCP server"}
207
+ elif request.get("method") not in (
208
+ "tools/call",
209
+ "resources/read",
210
+ "notifications/message",
211
+ "ping",
212
+ ):
213
+ result = {"error": "MCP method not allowed for UI proxy"}
214
+ elif "params" in request and not isinstance(request["params"], dict):
215
+ result = {"error": "Invalid MCP params"}
216
+ else:
217
+ try:
218
+ result = await self._request(server, request["method"], request.get("params"))
219
+ except Exception:
220
+ result = {"error": "MCP request failed"}
221
+ yield {"type": "RUN_FINISHED", "result": result}
222
+
223
+ async def transform(
224
+ self, source: AsyncIterator[Json], input: Json, tools: dict[str, tuple[MCPServer, str]]
225
+ ) -> AsyncIterator[Json]:
226
+ """Execute unresolved UI calls before the final run event and emit their activities."""
227
+ calls: dict[str, Json] = {}
228
+ resolved: set[str] = set()
229
+ for message in input.get("messages", []):
230
+ if message.get("role") == "assistant":
231
+ for call in message.get("toolCalls", []):
232
+ calls[call["id"]] = {
233
+ "name": call["function"]["name"],
234
+ "args": call["function"].get("arguments", ""),
235
+ }
236
+ elif message.get("role") == "tool":
237
+ resolved.add(message["toolCallId"])
238
+ terminal = None
239
+ async for event in source:
240
+ kind, call_id = event.get("type"), event.get("toolCallId", "")
241
+ if kind == "TOOL_CALL_START":
242
+ calls[call_id] = {"name": event["toolCallName"], "args": ""}
243
+ elif kind == "TOOL_CALL_ARGS" and call_id in calls:
244
+ calls[call_id]["args"] += event.get("delta", "")
245
+ if len(calls[call_id]["args"].encode()) > self.config.max_argument_bytes:
246
+ raise ValueError("MCP arguments exceed configured limit")
247
+ elif kind == "TOOL_CALL_RESULT":
248
+ resolved.add(call_id)
249
+ if kind == "RUN_FINISHED":
250
+ terminal = event
251
+ else:
252
+ yield event
253
+ if terminal:
254
+ for call_id, call in calls.items():
255
+ if call_id in resolved or call["name"] not in tools:
256
+ continue
257
+ server, resource_uri = tools[call["name"]]
258
+ try:
259
+ arguments = json.loads(call["args"] or "{}")
260
+ if not isinstance(arguments, dict):
261
+ raise ValueError("MCP tool arguments must be an object")
262
+ result = await self._request(
263
+ server, "tools/call", {"name": call["name"], "arguments": arguments}
264
+ )
265
+ content = result.get("content", [])
266
+ text = "\n".join(
267
+ item["text"]
268
+ for item in content
269
+ if item.get("type") == "text" and isinstance(item.get("text"), str)
270
+ )
271
+ yield {
272
+ "type": "TOOL_CALL_RESULT",
273
+ "toolCallId": call_id,
274
+ "messageId": str(uuid4()),
275
+ "content": text or json.dumps(content),
276
+ }
277
+ activity: Json = {
278
+ "result": result,
279
+ "resourceUri": resource_uri,
280
+ "serverHash": server.server_hash,
281
+ "toolInput": arguments,
282
+ }
283
+ if server.server_id:
284
+ activity["serverId"] = server.server_id
285
+ yield {
286
+ "type": "ACTIVITY_SNAPSHOT",
287
+ "messageId": str(uuid4()),
288
+ "activityType": "mcp-apps",
289
+ "content": activity,
290
+ "replace": True,
291
+ }
292
+ except Exception:
293
+ yield {
294
+ "type": "TOOL_CALL_RESULT",
295
+ "toolCallId": call_id,
296
+ "messageId": str(uuid4()),
297
+ "content": json.dumps({"error": "MCP tool execution failed"}),
298
+ }
299
+ yield terminal