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.
- copilotkit_intelligence/__init__.py +62 -0
- copilotkit_intelligence/client.py +805 -0
- copilotkit_intelligence/entitlements.py +142 -0
- copilotkit_intelligence/inspector.py +182 -0
- copilotkit_intelligence/learned_skills.py +98 -0
- copilotkit_intelligence/py.typed +0 -0
- copilotkit_intelligence/resources.py +134 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/METADATA +403 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/RECORD +22 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/WHEEL +4 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
- copilotkit_runtime/__init__.py +27 -0
- copilotkit_runtime/a2ui.py +559 -0
- copilotkit_runtime/agents.py +64 -0
- copilotkit_runtime/finalizer.py +75 -0
- copilotkit_runtime/gateway.py +287 -0
- copilotkit_runtime/mcp_apps.py +299 -0
- copilotkit_runtime/models.py +77 -0
- copilotkit_runtime/platform.py +67 -0
- copilotkit_runtime/py.typed +0 -0
- copilotkit_runtime/runtime.py +878 -0
- copilotkit_runtime/telemetry.py +263 -0
|
@@ -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
|