codespar 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.
codespar/__init__.py ADDED
@@ -0,0 +1,107 @@
1
+ """
2
+ CodeSpar Python SDK — commerce infrastructure for AI agents in Latin America.
3
+
4
+ Two import surfaces:
5
+
6
+ * ``CodeSpar`` — sync client. Use from scripts, Jupyter, sync web
7
+ frameworks (Flask, Django views).
8
+ * ``AsyncCodeSpar`` — async client. Use from FastAPI, LangChain,
9
+ anything already running on asyncio.
10
+
11
+ Both wrap the same backend (``api.codespar.dev``) and expose the same
12
+ session API, so you can start with sync and upgrade to async without
13
+ changing the surrounding code.
14
+
15
+ Quick start::
16
+
17
+ from codespar import CodeSpar
18
+
19
+ cs = CodeSpar(api_key="csk_live_...")
20
+ session = cs.create("user_123", preset="brazilian")
21
+ result = session.send("Charge R$500 via Pix to +5511999887766")
22
+ print(result.message)
23
+ session.close()
24
+ cs.close()
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from ._async_client import AsyncCodeSpar
30
+ from ._async_session import AsyncSession
31
+ from ._sync_client import CodeSpar, Session
32
+ from .errors import (
33
+ ApiError,
34
+ CodeSparError,
35
+ ConfigError,
36
+ NotConnectedError,
37
+ StreamError,
38
+ )
39
+ from .types import (
40
+ AssistantTextEvent,
41
+ AuthConfig,
42
+ AuthResult,
43
+ DoneEvent,
44
+ ErrorEvent,
45
+ HttpMethod,
46
+ ManageConnections,
47
+ Preset,
48
+ ProxyRequest,
49
+ ProxyResult,
50
+ SendResult,
51
+ ServerConnection,
52
+ SessionConfig,
53
+ SessionInfo,
54
+ SessionStatus,
55
+ StreamEvent,
56
+ Tool,
57
+ ToolCallRecord,
58
+ ToolResult,
59
+ ToolResultEvent,
60
+ ToolUseEvent,
61
+ UserMessageEvent,
62
+ )
63
+
64
+ __version__ = "0.1.0"
65
+
66
+ __all__ = [
67
+ "ApiError",
68
+ "AssistantTextEvent",
69
+ "AsyncCodeSpar",
70
+ "AsyncSession",
71
+ # Connect Links
72
+ "AuthConfig",
73
+ "AuthResult",
74
+ # Clients
75
+ "CodeSpar",
76
+ # Errors
77
+ "CodeSparError",
78
+ "ConfigError",
79
+ "DoneEvent",
80
+ "ErrorEvent",
81
+ "HttpMethod",
82
+ "ManageConnections",
83
+ "NotConnectedError",
84
+ "Preset",
85
+ # Proxy
86
+ "ProxyRequest",
87
+ "ProxyResult",
88
+ "SendResult",
89
+ "ServerConnection",
90
+ "Session",
91
+ # Configuration
92
+ "SessionConfig",
93
+ # Session output
94
+ "SessionInfo",
95
+ "SessionStatus",
96
+ "StreamError",
97
+ # Streaming events
98
+ "StreamEvent",
99
+ "Tool",
100
+ "ToolCallRecord",
101
+ "ToolResult",
102
+ "ToolResultEvent",
103
+ "ToolUseEvent",
104
+ "UserMessageEvent",
105
+ # Version
106
+ "__version__",
107
+ ]
@@ -0,0 +1,178 @@
1
+ """
2
+ ``AsyncCodeSpar`` — the canonical client class.
3
+
4
+ Holds an httpx.AsyncClient, exposes ``create(user_id, ...)`` to start a
5
+ session, and mirrors the TS ``CodeSpar`` constructor 1:1. The sync
6
+ ``CodeSpar`` in ``_sync_client.py`` wraps every call through
7
+ ``asyncio.run`` so the lightweight use-case works without the caller
8
+ having to write ``async def``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from types import TracebackType
14
+
15
+ import httpx
16
+
17
+ from ._async_session import (
18
+ AsyncSession,
19
+ build_session_info,
20
+ wait_for_connections,
21
+ )
22
+ from ._http import DEFAULT_BASE_URL, request_json
23
+ from ._presets import preset_to_servers
24
+ from .errors import ApiError, ConfigError
25
+ from .types import SessionConfig
26
+
27
+
28
+ class AsyncCodeSpar:
29
+ """
30
+ Async CodeSpar client. Pass an API key, create sessions, run them,
31
+ close them. One client can spawn many sessions in parallel.
32
+
33
+ Example::
34
+
35
+ async with AsyncCodeSpar(api_key="csk_live_...") as cs:
36
+ session = await cs.create("user_123", preset="brazilian")
37
+ result = await session.send("charge R$500 via Pix")
38
+ print(result.message)
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ api_key: str,
45
+ base_url: str = DEFAULT_BASE_URL,
46
+ project_id: str | None = None,
47
+ timeout: float = 60.0,
48
+ client: httpx.AsyncClient | None = None,
49
+ ) -> None:
50
+ if not api_key or not api_key.startswith("csk_"):
51
+ raise ConfigError(
52
+ "api_key is required and must start with 'csk_'. "
53
+ "Get one from https://dashboard.codespar.dev."
54
+ )
55
+ self._api_key = api_key
56
+ self._base_url = base_url.rstrip("/")
57
+ self._project_id = project_id
58
+ # Share one transport across every session spawned by this
59
+ # client. Closing the client closes every in-flight request.
60
+ self._owns_client = client is None
61
+ self._client = client or httpx.AsyncClient(
62
+ base_url=self._base_url,
63
+ timeout=timeout,
64
+ )
65
+
66
+ @property
67
+ def base_url(self) -> str:
68
+ return self._base_url
69
+
70
+ @property
71
+ def project_id(self) -> str | None:
72
+ return self._project_id
73
+
74
+ async def create(
75
+ self,
76
+ user_id: str,
77
+ config: SessionConfig | None = None,
78
+ /,
79
+ **kwargs: object,
80
+ ) -> AsyncSession:
81
+ """
82
+ Start a session scoped to ``user_id``.
83
+
84
+ ``config`` can be passed as a ``SessionConfig`` dataclass or as
85
+ keyword arguments — both shapes work::
86
+
87
+ await cs.create("user_123", preset="brazilian")
88
+ await cs.create("user_123", SessionConfig(preset="brazilian"))
89
+ """
90
+ resolved = self._resolve_config(config, kwargs)
91
+ servers = resolved.servers or preset_to_servers(resolved.preset)
92
+ project_id = resolved.project_id or self._project_id
93
+
94
+ body: dict[str, object] = {"servers": servers, "user_id": user_id}
95
+ if resolved.metadata:
96
+ body["metadata"] = resolved.metadata
97
+
98
+ data = await request_json(
99
+ self._client,
100
+ "POST",
101
+ "/v1/sessions",
102
+ api_key=self._api_key,
103
+ project_id=project_id,
104
+ body=body,
105
+ )
106
+ if not isinstance(data, dict):
107
+ raise ApiError("create: malformed response", status=0, body=data)
108
+
109
+ info = build_session_info(
110
+ data,
111
+ base_url=self._base_url,
112
+ api_key=self._api_key,
113
+ project_id=project_id,
114
+ )
115
+ session = AsyncSession(
116
+ info=info,
117
+ client=self._client,
118
+ api_key=self._api_key,
119
+ project_id=project_id,
120
+ base_url=self._base_url,
121
+ )
122
+
123
+ if resolved.manage_connections and resolved.manage_connections.wait_for_connections:
124
+ await wait_for_connections(
125
+ session,
126
+ timeout_ms=resolved.manage_connections.timeout,
127
+ )
128
+
129
+ return session
130
+
131
+ # ── lifecycle ───────────────────────────────────────────────────────
132
+
133
+ async def aclose(self) -> None:
134
+ """Close the underlying httpx transport."""
135
+ if self._owns_client:
136
+ await self._client.aclose()
137
+
138
+ async def __aenter__(self) -> AsyncCodeSpar:
139
+ return self
140
+
141
+ async def __aexit__(
142
+ self,
143
+ exc_type: type[BaseException] | None,
144
+ exc: BaseException | None,
145
+ tb: TracebackType | None,
146
+ ) -> None:
147
+ await self.aclose()
148
+
149
+ # ── internals ───────────────────────────────────────────────────────
150
+
151
+ def _resolve_config(
152
+ self,
153
+ config: SessionConfig | None,
154
+ kwargs: dict[str, object],
155
+ ) -> SessionConfig:
156
+ """Accept either a SessionConfig dataclass or kwargs, never both."""
157
+ if config is not None and kwargs:
158
+ raise ConfigError(
159
+ "Pass SessionConfig or keyword arguments, not both."
160
+ )
161
+ if config is not None:
162
+ return config
163
+ if not kwargs:
164
+ return SessionConfig()
165
+
166
+ allowed = {
167
+ "servers",
168
+ "preset",
169
+ "manage_connections",
170
+ "metadata",
171
+ "project_id",
172
+ }
173
+ unknown = set(kwargs) - allowed
174
+ if unknown:
175
+ raise ConfigError(
176
+ f"create(): unknown keyword argument(s): {', '.join(sorted(unknown))}"
177
+ )
178
+ return SessionConfig(**kwargs) # type: ignore[arg-type]
@@ -0,0 +1,420 @@
1
+ """
2
+ Async Session implementation.
3
+
4
+ Every public method maps 1:1 to the TypeScript ``Session`` in
5
+ ``@codespar/sdk``. The shared httpx client is owned by the parent
6
+ ``AsyncCodeSpar`` so closing the CodeSpar instance closes the
7
+ session's transport too.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ from collections.abc import AsyncIterator
15
+ from datetime import datetime
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ from ._http import request_json, stream_sse
21
+ from .errors import ApiError, ConfigError
22
+ from .types import (
23
+ AssistantTextEvent,
24
+ AuthConfig,
25
+ AuthResult,
26
+ DoneEvent,
27
+ ErrorEvent,
28
+ ProxyRequest,
29
+ ProxyResult,
30
+ SendResult,
31
+ ServerConnection,
32
+ SessionInfo,
33
+ StreamEvent,
34
+ Tool,
35
+ ToolCallRecord,
36
+ ToolResult,
37
+ ToolResultEvent,
38
+ ToolUseEvent,
39
+ UserMessageEvent,
40
+ )
41
+
42
+
43
+ def _parse_tool_call_record(raw: dict[str, Any]) -> ToolCallRecord:
44
+ return ToolCallRecord(
45
+ id=str(raw.get("id", "")),
46
+ tool_name=str(raw.get("tool_name", "")),
47
+ server_id=str(raw.get("server_id", "")),
48
+ status=raw.get("status", "success"),
49
+ duration_ms=int(raw.get("duration_ms", 0) or 0),
50
+ input=raw.get("input"),
51
+ output=raw.get("output"),
52
+ error_code=raw.get("error_code"),
53
+ )
54
+
55
+
56
+ def _parse_send_result(raw: dict[str, Any]) -> SendResult:
57
+ tool_calls_raw = raw.get("tool_calls") or []
58
+ return SendResult(
59
+ message=str(raw.get("message", "")),
60
+ tool_calls=[_parse_tool_call_record(tc) for tc in tool_calls_raw],
61
+ iterations=int(raw.get("iterations", 0) or 0),
62
+ )
63
+
64
+
65
+ def _parse_stream_event(raw: dict[str, Any]) -> StreamEvent | None:
66
+ """
67
+ Normalize a raw SSE payload into a typed ``StreamEvent``.
68
+
69
+ Returns ``None`` for unknown event types so a future backend event
70
+ doesn't crash an SDK that predates it — forwards compatibility
71
+ matters more than strict parsing at this layer.
72
+ """
73
+ event_type = raw.get("type")
74
+ match event_type:
75
+ case "user_message":
76
+ return UserMessageEvent(content=str(raw.get("content", "")))
77
+ case "assistant_text":
78
+ return AssistantTextEvent(
79
+ content=str(raw.get("content", "")),
80
+ iteration=int(raw.get("iteration", 0) or 0),
81
+ )
82
+ case "tool_use":
83
+ return ToolUseEvent(
84
+ id=str(raw.get("id", "")),
85
+ name=str(raw.get("name", "")),
86
+ input=raw.get("input") or {},
87
+ )
88
+ case "tool_result":
89
+ tc = raw.get("toolCall") or raw.get("tool_call")
90
+ if not isinstance(tc, dict):
91
+ return None
92
+ return ToolResultEvent(tool_call=_parse_tool_call_record(tc))
93
+ case "done":
94
+ result = raw.get("result")
95
+ payload = _parse_send_result(result if isinstance(result, dict) else {})
96
+ return DoneEvent(result=payload)
97
+ case "error":
98
+ return ErrorEvent(
99
+ error=str(raw.get("error", "error")),
100
+ message=raw.get("message") if isinstance(raw.get("message"), str) else None,
101
+ )
102
+ case _:
103
+ return None
104
+
105
+
106
+ class AsyncSession:
107
+ """
108
+ A live CodeSpar session — async interface.
109
+
110
+ Instances are created via ``AsyncCodeSpar.create(user_id, ...)`` and
111
+ hold a reference to the parent client's shared httpx transport.
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ *,
117
+ info: SessionInfo,
118
+ client: httpx.AsyncClient,
119
+ api_key: str,
120
+ project_id: str | None,
121
+ base_url: str,
122
+ ) -> None:
123
+ self.info = info
124
+ self._client = client
125
+ self._api_key = api_key
126
+ self._project_id = project_id
127
+ self._base_url = base_url
128
+ self._cached_tools: list[Tool] | None = None
129
+ self._cached_connections: list[ServerConnection] | None = None
130
+
131
+ # ── identity passthroughs ───────────────────────────────────────────
132
+
133
+ @property
134
+ def id(self) -> str:
135
+ return self.info.id
136
+
137
+ @property
138
+ def user_id(self) -> str:
139
+ return self.info.user_id
140
+
141
+ @property
142
+ def servers(self) -> list[str]:
143
+ return list(self.info.servers)
144
+
145
+ @property
146
+ def mcp(self) -> dict[str, Any]:
147
+ """Config for MCP-compatible clients (Claude Desktop, Cursor)."""
148
+ return {
149
+ "url": self.info.mcp_url,
150
+ "headers": dict(self.info.mcp_headers),
151
+ }
152
+
153
+ # ── tools ───────────────────────────────────────────────────────────
154
+
155
+ async def tools(self) -> list[Tool]:
156
+ """Return the tools available in this session. Cached after first call."""
157
+ if self._cached_tools is not None:
158
+ return list(self._cached_tools)
159
+ await self.connections()
160
+ return list(self._cached_tools or [])
161
+
162
+ async def find_tools(self, intent: str) -> list[Tool]:
163
+ """Substring match on tool name + description. Case-insensitive."""
164
+ all_tools = await self.tools()
165
+ q = intent.lower()
166
+ return [t for t in all_tools if q in t.name.lower() or q in t.description.lower()]
167
+
168
+ # ── execution ───────────────────────────────────────────────────────
169
+
170
+ async def execute(self, tool_name: str, params: dict[str, Any]) -> ToolResult:
171
+ """Call a specific tool by name. Always returns a ToolResult, even on error."""
172
+ start = _now_ms()
173
+ try:
174
+ data = await request_json(
175
+ self._client,
176
+ "POST",
177
+ f"/v1/sessions/{self.id}/execute",
178
+ api_key=self._api_key,
179
+ project_id=self._project_id,
180
+ body={"tool": tool_name, "input": params},
181
+ )
182
+ except ApiError as exc:
183
+ return ToolResult(
184
+ success=False,
185
+ data=None,
186
+ error=f"{exc.status}: {exc.body or exc}",
187
+ duration=_now_ms() - start,
188
+ server="",
189
+ tool=tool_name,
190
+ )
191
+ if not isinstance(data, dict):
192
+ return ToolResult(
193
+ success=False,
194
+ data=None,
195
+ error="malformed response",
196
+ duration=_now_ms() - start,
197
+ server="",
198
+ tool=tool_name,
199
+ )
200
+ return ToolResult(
201
+ success=bool(data.get("success", False)),
202
+ data=data.get("data"),
203
+ error=data.get("error"),
204
+ duration=int(data.get("duration", 0) or 0),
205
+ server=str(data.get("server", "")),
206
+ tool=str(data.get("tool", tool_name)),
207
+ tool_call_id=data.get("tool_call_id"),
208
+ called_at=data.get("called_at"),
209
+ )
210
+
211
+ # ── proxy ───────────────────────────────────────────────────────────
212
+
213
+ async def proxy_execute(self, request: ProxyRequest) -> ProxyResult:
214
+ """
215
+ Raw HTTP proxy to a connected server's upstream API. Auth is
216
+ injected by the backend — never send provider keys here.
217
+ """
218
+ data = await request_json(
219
+ self._client,
220
+ "POST",
221
+ f"/v1/sessions/{self.id}/proxy_execute",
222
+ api_key=self._api_key,
223
+ project_id=self._project_id,
224
+ body={
225
+ "server": request.server,
226
+ "endpoint": request.endpoint,
227
+ "method": request.method,
228
+ "body": request.body,
229
+ "params": request.params,
230
+ "headers": request.headers,
231
+ },
232
+ )
233
+ if not isinstance(data, dict):
234
+ raise ApiError("proxy_execute: malformed response", status=0, body=data)
235
+ return ProxyResult(
236
+ status=int(data.get("status", 0) or 0),
237
+ data=data.get("data"),
238
+ headers=dict(data.get("headers") or {}),
239
+ duration=int(data.get("duration", 0) or 0),
240
+ proxy_call_id=data.get("proxy_call_id"),
241
+ )
242
+
243
+ # ── natural-language ────────────────────────────────────────────────
244
+
245
+ async def send(self, message: str) -> SendResult:
246
+ """Send a natural-language message. Blocks until the agent loop finishes."""
247
+ data = await request_json(
248
+ self._client,
249
+ "POST",
250
+ f"/v1/sessions/{self.id}/send",
251
+ api_key=self._api_key,
252
+ project_id=self._project_id,
253
+ body={"message": message},
254
+ )
255
+ if not isinstance(data, dict):
256
+ raise ApiError("send: malformed response", status=0, body=data)
257
+ return _parse_send_result(data)
258
+
259
+ async def send_stream(self, message: str) -> AsyncIterator[StreamEvent]:
260
+ """
261
+ Stream a natural-language turn. Yields events as they arrive.
262
+
263
+ Usage::
264
+
265
+ async for event in session.send_stream("process this order"):
266
+ match event.type:
267
+ case "assistant_text":
268
+ print(event.content, end="")
269
+ case "tool_use":
270
+ print(f"[tool] {event.name}")
271
+ """
272
+ async for raw in stream_sse(
273
+ self._client,
274
+ f"/v1/sessions/{self.id}/send",
275
+ api_key=self._api_key,
276
+ project_id=self._project_id,
277
+ body={"message": message},
278
+ ):
279
+ event = _parse_stream_event(raw)
280
+ if event is not None:
281
+ yield event
282
+
283
+ # ── Connect Links ───────────────────────────────────────────────────
284
+
285
+ async def authorize(self, server_id: str, config: AuthConfig) -> AuthResult:
286
+ """
287
+ Start a Connect Link OAuth flow. Returns the URL your UI should
288
+ open for the end user; CodeSpar's callback stores tokens and
289
+ forwards the user to ``config.redirect_uri``.
290
+ """
291
+ data = await request_json(
292
+ self._client,
293
+ "POST",
294
+ "/v1/connect/start",
295
+ api_key=self._api_key,
296
+ project_id=self._project_id,
297
+ body={
298
+ "server_id": server_id,
299
+ "user_id": self.user_id,
300
+ "redirect_uri": config.redirect_uri,
301
+ "scopes": config.scopes,
302
+ },
303
+ )
304
+ if not isinstance(data, dict):
305
+ raise ApiError("authorize: malformed response", status=0, body=data)
306
+ return AuthResult(
307
+ link_token=str(data.get("link_token", "")),
308
+ authorize_url=str(data.get("authorize_url", "")),
309
+ expires_at=str(data.get("expires_at", "")),
310
+ )
311
+
312
+ # ── connections ─────────────────────────────────────────────────────
313
+
314
+ async def connections(self) -> list[ServerConnection]:
315
+ """List server connections and refresh the internal tools cache."""
316
+ try:
317
+ data = await request_json(
318
+ self._client,
319
+ "GET",
320
+ f"/v1/sessions/{self.id}/connections",
321
+ api_key=self._api_key,
322
+ project_id=self._project_id,
323
+ )
324
+ except ApiError:
325
+ return list(self._cached_connections or [])
326
+ if not isinstance(data, dict):
327
+ return list(self._cached_connections or [])
328
+
329
+ raw_servers = data.get("servers") or []
330
+ raw_tools = data.get("tools") or []
331
+ servers = [
332
+ ServerConnection(
333
+ id=str(s.get("id", "")),
334
+ name=str(s.get("name", "")),
335
+ category=str(s.get("category", "")),
336
+ country=str(s.get("country", "")),
337
+ auth_type=s.get("auth_type", "none"),
338
+ connected=bool(s.get("connected", False)),
339
+ )
340
+ for s in raw_servers
341
+ if isinstance(s, dict)
342
+ ]
343
+ tools = [
344
+ Tool(
345
+ name=str(t.get("name", "")),
346
+ description=str(t.get("description", "")),
347
+ input_schema=dict(t.get("input_schema") or {}),
348
+ server=str(t.get("server", "")),
349
+ )
350
+ for t in raw_tools
351
+ if isinstance(t, dict)
352
+ ]
353
+ self._cached_connections = servers
354
+ self._cached_tools = tools
355
+ return list(servers)
356
+
357
+ # ── lifecycle ───────────────────────────────────────────────────────
358
+
359
+ async def close(self) -> None:
360
+ """Close the session on the backend. Safe to call multiple times.
361
+ Best-effort — a 4xx/5xx here shouldn't crash the caller. The
362
+ backend cleans up stale sessions on a timer anyway."""
363
+ with contextlib.suppress(ApiError):
364
+ await request_json(
365
+ self._client,
366
+ "DELETE",
367
+ f"/v1/sessions/{self.id}",
368
+ api_key=self._api_key,
369
+ project_id=self._project_id,
370
+ )
371
+
372
+
373
+ def _now_ms() -> int:
374
+ return int(asyncio.get_event_loop().time() * 1000)
375
+
376
+
377
+ async def wait_for_connections(session: AsyncSession, timeout_ms: int) -> None:
378
+ """
379
+ Poll ``session.connections()`` until every server reports connected,
380
+ or until ``timeout_ms`` elapses. Matches TS ``manageConnections``.
381
+ """
382
+ if timeout_ms <= 0:
383
+ raise ConfigError("wait_for_connections: timeout_ms must be positive")
384
+ deadline = _now_ms() + timeout_ms
385
+ while True:
386
+ conns = await session.connections()
387
+ if conns and all(c.connected for c in conns):
388
+ return
389
+ if _now_ms() >= deadline:
390
+ return
391
+ await asyncio.sleep(1.0)
392
+
393
+
394
+ def build_session_info(
395
+ raw: dict[str, Any],
396
+ *,
397
+ base_url: str,
398
+ api_key: str,
399
+ project_id: str | None,
400
+ ) -> SessionInfo:
401
+ """Map backend POST /v1/sessions response into a ``SessionInfo``."""
402
+ created_raw = raw.get("created_at", "")
403
+ try:
404
+ created = datetime.fromisoformat(str(created_raw).replace("Z", "+00:00"))
405
+ except ValueError:
406
+ created = datetime.now()
407
+
408
+ mcp_headers: dict[str, str] = {"Authorization": f"Bearer {api_key}"}
409
+ if project_id:
410
+ mcp_headers["x-codespar-project"] = project_id
411
+
412
+ return SessionInfo(
413
+ id=str(raw.get("id", "")),
414
+ user_id=str(raw.get("user_id", "")),
415
+ servers=list(raw.get("servers") or []),
416
+ created_at=created,
417
+ status=raw.get("status", "active"),
418
+ mcp_url=f"{base_url}/v1/sessions/{raw.get('id', '')}/mcp",
419
+ mcp_headers=mcp_headers,
420
+ )