pi-codemcp 1.3.1 → 1.3.3
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.
- package/package.json +1 -1
- package/sidecar/executor.py +21 -5
- package/sidecar/gateway.py +1 -0
- package/sidecar/mcp_config.py +253 -5
- package/sidecar/sandbox_api.py +1 -1
- package/sidecar/stats.py +3 -0
- package/sidecar/tool_catalog.py +2 -1
- package/src/execution-rendering.ts +6 -0
package/package.json
CHANGED
package/sidecar/executor.py
CHANGED
|
@@ -50,9 +50,10 @@ INSPECT_BYTE_LIMIT = 8 * 1024
|
|
|
50
50
|
CHAIN_INPUT_EXTERNAL = "__codemcp_saved_chain_input"
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
type FailureStage = Literal["preflight", "runtime", "timeout", "cancelled", "result"]
|
|
53
|
+
type FailureStage = Literal["preflight", "arguments", "runtime", "timeout", "cancelled", "result"]
|
|
54
54
|
type FailureKind = Literal[
|
|
55
55
|
"preflight",
|
|
56
|
+
"argument_validation",
|
|
56
57
|
"result",
|
|
57
58
|
"result_reference",
|
|
58
59
|
"sandbox_runtime",
|
|
@@ -487,7 +488,18 @@ class MontyExecutor:
|
|
|
487
488
|
raise RuntimeError(
|
|
488
489
|
f"Call limit exceeded: maximum {context.settings.max_calls} total calls"
|
|
489
490
|
)
|
|
490
|
-
|
|
491
|
+
try:
|
|
492
|
+
validated = catalog.validate_arguments(name, arguments)
|
|
493
|
+
except (TypeError, ValidationError, ValueError) as error:
|
|
494
|
+
message = f"{spec.call}: invalid arguments: {error}"
|
|
495
|
+
context.failure = ExecutionFailureInfo(
|
|
496
|
+
kind="argument_validation",
|
|
497
|
+
server=spec.server,
|
|
498
|
+
tool=spec.backend_name,
|
|
499
|
+
retryable=False,
|
|
500
|
+
message=message,
|
|
501
|
+
)
|
|
502
|
+
raise ValueError(message) from error
|
|
491
503
|
if spec.kind == "saved_chain":
|
|
492
504
|
context.chain_calls += 1
|
|
493
505
|
return await context.call_tool(name, validated, context)
|
|
@@ -624,8 +636,10 @@ class MontyExecutor:
|
|
|
624
636
|
context.metrics.runtime_ms += _elapsed_ms(runtime_started)
|
|
625
637
|
message = error.display("type-msg").strip()
|
|
626
638
|
lowered = message.lower()
|
|
627
|
-
stage: Literal["runtime", "timeout"] = (
|
|
628
|
-
"
|
|
639
|
+
stage: Literal["arguments", "runtime", "timeout"] = (
|
|
640
|
+
"arguments"
|
|
641
|
+
if context.failure is not None and context.failure.kind == "argument_validation"
|
|
642
|
+
else "timeout"
|
|
629
643
|
if (context.failure is not None and context.failure.kind == "upstream_timeout")
|
|
630
644
|
or "duration" in lowered
|
|
631
645
|
or "timed out" in lowered
|
|
@@ -728,7 +742,7 @@ class MontyExecutor:
|
|
|
728
742
|
@staticmethod
|
|
729
743
|
def _failure(
|
|
730
744
|
context: ExecutionContext,
|
|
731
|
-
stage:
|
|
745
|
+
stage: FailureStage,
|
|
732
746
|
error: str,
|
|
733
747
|
) -> ExecutionResponse:
|
|
734
748
|
failure = context.failure or _execution_failure_info(stage, error)
|
|
@@ -749,6 +763,8 @@ def _execution_failure_info(
|
|
|
749
763
|
) -> ExecutionFailureInfo:
|
|
750
764
|
if stage == "preflight":
|
|
751
765
|
kind: FailureKind = "preflight"
|
|
766
|
+
elif stage == "arguments":
|
|
767
|
+
kind = "argument_validation"
|
|
752
768
|
elif stage == "result":
|
|
753
769
|
kind = "result"
|
|
754
770
|
elif stage == "timeout":
|
package/sidecar/gateway.py
CHANGED
|
@@ -1511,6 +1511,7 @@ def _execution_failure_subtype(response: ExecutionResponse) -> str:
|
|
|
1511
1511
|
failure = response.failure
|
|
1512
1512
|
if failure is not None and failure.kind in {
|
|
1513
1513
|
"result_reference",
|
|
1514
|
+
"argument_validation",
|
|
1514
1515
|
"sandbox_timeout",
|
|
1515
1516
|
"upstream",
|
|
1516
1517
|
"upstream_transport",
|
package/sidecar/mcp_config.py
CHANGED
|
@@ -4,21 +4,34 @@ import hashlib
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
|
-
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from typing import TYPE_CHECKING, Any, override
|
|
8
10
|
from urllib.parse import urlsplit
|
|
9
11
|
|
|
12
|
+
import httpx
|
|
10
13
|
from fastmcp.client.auth import OAuth
|
|
14
|
+
from fastmcp.client.auth.oauth import TokenStorageAdapter
|
|
11
15
|
from fastmcp.mcp_config import (
|
|
12
16
|
MCPConfig,
|
|
13
17
|
RemoteMCPServer,
|
|
14
18
|
StdioMCPServer,
|
|
15
19
|
infer_transport_type_from_url,
|
|
16
20
|
)
|
|
21
|
+
from key_value.aio.adapters.pydantic import PydanticAdapter
|
|
17
22
|
from key_value.aio.stores.filetree import FileTreeStore
|
|
18
23
|
from key_value.aio.stores.filetree.store import (
|
|
19
24
|
FileTreeV1CollectionSanitizationStrategy,
|
|
20
25
|
FileTreeV1KeySanitizationStrategy,
|
|
21
26
|
)
|
|
27
|
+
from mcp.client.auth.utils import (
|
|
28
|
+
build_oauth_authorization_server_metadata_discovery_urls,
|
|
29
|
+
build_protected_resource_metadata_discovery_urls,
|
|
30
|
+
create_oauth_metadata_request,
|
|
31
|
+
handle_auth_metadata_response,
|
|
32
|
+
handle_protected_resource_response,
|
|
33
|
+
)
|
|
34
|
+
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
|
|
22
35
|
from pydantic import BaseModel, ConfigDict
|
|
23
36
|
|
|
24
37
|
from .json_types import JSON_VALUE_ADAPTER, JsonObject, JsonValue
|
|
@@ -27,7 +40,9 @@ from .models import NormalizedServerInfo, ServerAuth
|
|
|
27
40
|
if TYPE_CHECKING:
|
|
28
41
|
from pathlib import Path
|
|
29
42
|
|
|
30
|
-
import
|
|
43
|
+
from key_value.aio.protocols import AsyncKeyValue
|
|
44
|
+
from mcp.shared.auth import OAuthClientInformationFull
|
|
45
|
+
from pydantic import AnyUrl
|
|
31
46
|
|
|
32
47
|
PI_ONLY_FIELDS = {"directTools", "lifecycle", "idleTimeout", "disabled", "enabled"}
|
|
33
48
|
REMOTE_TRANSPORTS = {"http", "streamable-http", "sse"}
|
|
@@ -59,16 +74,165 @@ class NormalizedConfig(BaseModel):
|
|
|
59
74
|
servers: list[NormalizedServerInfo]
|
|
60
75
|
|
|
61
76
|
|
|
77
|
+
class CodemcpTokenStorage(TokenStorageAdapter):
|
|
78
|
+
"""Token storage that keeps OAuth state usable across sidecar restarts.
|
|
79
|
+
|
|
80
|
+
The upstream adapter evicts the client registration once the server-announced
|
|
81
|
+
client_secret_expires_at passes (Linear announces 24 hours), which silently
|
|
82
|
+
forces a full browser re-login after the next token expiry. A genuinely dead
|
|
83
|
+
secret still surfaces as invalid_client and re-registers, so persisting the
|
|
84
|
+
registration is strictly better. This adapter also persists the discovered
|
|
85
|
+
authorization-server metadata so token refresh hits the real token endpoint
|
|
86
|
+
instead of the SDK's "<origin>/token" fallback (a 404 for e.g. Outline).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, async_key_value: AsyncKeyValue, server_url: str) -> None:
|
|
90
|
+
super().__init__(async_key_value, server_url)
|
|
91
|
+
self._storage_oauth_metadata = PydanticAdapter[OAuthMetadata](
|
|
92
|
+
default_collection="mcp-oauth-metadata",
|
|
93
|
+
key_value=async_key_value,
|
|
94
|
+
pydantic_model=OAuthMetadata,
|
|
95
|
+
raise_on_validation_error=True,
|
|
96
|
+
)
|
|
97
|
+
self._storage_protected_resource = PydanticAdapter[ProtectedResourceMetadata](
|
|
98
|
+
default_collection="mcp-oauth-protected-resource",
|
|
99
|
+
key_value=async_key_value,
|
|
100
|
+
pydantic_model=ProtectedResourceMetadata,
|
|
101
|
+
raise_on_validation_error=True,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@override
|
|
105
|
+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
|
106
|
+
await self._storage_client_info.put(
|
|
107
|
+
key=self._get_client_info_cache_key(),
|
|
108
|
+
value=client_info,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@override
|
|
112
|
+
async def clear(self) -> None:
|
|
113
|
+
await super().clear()
|
|
114
|
+
await self._storage_oauth_metadata.delete(key=self._oauth_metadata_cache_key())
|
|
115
|
+
await self._storage_protected_resource.delete(key=self._protected_resource_cache_key())
|
|
116
|
+
|
|
117
|
+
async def get_oauth_metadata(self) -> OAuthMetadata | None:
|
|
118
|
+
result: OAuthMetadata | None = await self._storage_oauth_metadata.get(
|
|
119
|
+
key=self._oauth_metadata_cache_key()
|
|
120
|
+
)
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
async def set_oauth_metadata(self, metadata: OAuthMetadata) -> None:
|
|
124
|
+
await self._storage_oauth_metadata.put(
|
|
125
|
+
key=self._oauth_metadata_cache_key(),
|
|
126
|
+
value=metadata,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def get_protected_resource_metadata(self) -> ProtectedResourceMetadata | None:
|
|
130
|
+
result: ProtectedResourceMetadata | None = await self._storage_protected_resource.get(
|
|
131
|
+
key=self._protected_resource_cache_key()
|
|
132
|
+
)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
async def set_protected_resource_metadata(self, metadata: ProtectedResourceMetadata) -> None:
|
|
136
|
+
await self._storage_protected_resource.put(
|
|
137
|
+
key=self._protected_resource_cache_key(),
|
|
138
|
+
value=metadata,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _oauth_metadata_cache_key(self) -> str:
|
|
142
|
+
return f"{self._server_url}/oauth_metadata"
|
|
143
|
+
|
|
144
|
+
def _protected_resource_cache_key(self) -> str:
|
|
145
|
+
return f"{self._server_url}/protected_resource"
|
|
146
|
+
|
|
147
|
+
|
|
62
148
|
class PersistentCallbackOAuth(OAuth):
|
|
63
|
-
"""
|
|
149
|
+
"""OAuth provider hardened for long-lived shared file token storage.
|
|
150
|
+
|
|
151
|
+
On top of reusing the callback registered with a persisted dynamic client:
|
|
152
|
+
- discovered authorization-server metadata is persisted and restored so token
|
|
153
|
+
refresh works in fresh sidecar processes (the SDK otherwise falls back to
|
|
154
|
+
"<origin>/token", which 404s for servers like Outline and turns every
|
|
155
|
+
access-token expiry into a forced interactive re-login);
|
|
156
|
+
- a refresh response without refresh_token keeps the previous one (RFC 6749
|
|
157
|
+
section 6 allows omission when the refresh token does not rotate);
|
|
158
|
+
- a failed refresh adopts fresher tokens another sidecar process may have
|
|
159
|
+
stored instead of dropping straight into the browser flow.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
mcp_url: str,
|
|
166
|
+
client_name: str,
|
|
167
|
+
token_storage: AsyncKeyValue,
|
|
168
|
+
additional_client_metadata: dict[str, Any] | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
self._codemcp_token_store = token_storage
|
|
171
|
+
self._persisted_oauth_metadata: OAuthMetadata | None = None
|
|
172
|
+
self._persisted_protected_resource: ProtectedResourceMetadata | None = None
|
|
173
|
+
super().__init__(
|
|
174
|
+
mcp_url=mcp_url,
|
|
175
|
+
client_name=client_name,
|
|
176
|
+
token_storage=token_storage,
|
|
177
|
+
additional_client_metadata=additional_client_metadata,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@override
|
|
181
|
+
def _bind(self, mcp_url: str) -> None:
|
|
182
|
+
super()._bind(mcp_url)
|
|
183
|
+
if isinstance(self.token_storage_adapter, CodemcpTokenStorage):
|
|
184
|
+
return
|
|
185
|
+
storage = CodemcpTokenStorage(self._codemcp_token_store, self.mcp_url)
|
|
186
|
+
self.token_storage_adapter = storage
|
|
187
|
+
self.context.storage = storage
|
|
64
188
|
|
|
65
189
|
@override
|
|
66
190
|
async def _initialize(self) -> None:
|
|
67
191
|
await super()._initialize()
|
|
68
192
|
client_info = self.context.client_info
|
|
69
|
-
if client_info is None
|
|
193
|
+
if client_info is not None and client_info.redirect_uris:
|
|
194
|
+
self._reuse_registered_callback(client_info.redirect_uris[0])
|
|
195
|
+
storage = self.token_storage_adapter
|
|
196
|
+
if not isinstance(storage, CodemcpTokenStorage):
|
|
70
197
|
return
|
|
71
|
-
|
|
198
|
+
if self.context.oauth_metadata is None:
|
|
199
|
+
self.context.oauth_metadata = await storage.get_oauth_metadata()
|
|
200
|
+
self._persisted_oauth_metadata = self.context.oauth_metadata
|
|
201
|
+
if self.context.protected_resource_metadata is None:
|
|
202
|
+
self.context.protected_resource_metadata = (
|
|
203
|
+
await storage.get_protected_resource_metadata()
|
|
204
|
+
)
|
|
205
|
+
self._persisted_protected_resource = self.context.protected_resource_metadata
|
|
206
|
+
tokens = self.context.current_tokens
|
|
207
|
+
if tokens is not None and tokens.expires_in and await storage.get_token_expiry() is None:
|
|
208
|
+
# Without the absolute expiry record the upstream fallback re-applies the
|
|
209
|
+
# stale relative expires_in from now; the expired access token then looks
|
|
210
|
+
# valid, gets rejected with a 401, and the flow goes interactive.
|
|
211
|
+
self.context.token_expiry_time = time.time() - 1
|
|
212
|
+
|
|
213
|
+
@override
|
|
214
|
+
async def _refresh_token(self) -> httpx.Request:
|
|
215
|
+
if self.context.oauth_metadata is None:
|
|
216
|
+
await self._discover_server_metadata()
|
|
217
|
+
return await super()._refresh_token()
|
|
218
|
+
|
|
219
|
+
@override
|
|
220
|
+
async def _handle_token_response(self, response: httpx.Response) -> None:
|
|
221
|
+
await super()._handle_token_response(response)
|
|
222
|
+
await self._persist_discovered_metadata()
|
|
223
|
+
|
|
224
|
+
@override
|
|
225
|
+
async def _handle_refresh_response(self, response: httpx.Response) -> bool:
|
|
226
|
+
previous_tokens = self.context.current_tokens
|
|
227
|
+
previous_access_token = previous_tokens.access_token if previous_tokens else None
|
|
228
|
+
previous_refresh_token = previous_tokens.refresh_token if previous_tokens else None
|
|
229
|
+
if await super()._handle_refresh_response(response):
|
|
230
|
+
await self._restore_unrotated_refresh_token(previous_refresh_token)
|
|
231
|
+
await self._persist_discovered_metadata()
|
|
232
|
+
return True
|
|
233
|
+
return await self._adopt_tokens_refreshed_elsewhere(previous_access_token)
|
|
234
|
+
|
|
235
|
+
def _reuse_registered_callback(self, redirect_uri: AnyUrl) -> None:
|
|
72
236
|
parsed = urlsplit(str(redirect_uri))
|
|
73
237
|
if (
|
|
74
238
|
parsed.scheme != "http"
|
|
@@ -82,6 +246,90 @@ class PersistentCallbackOAuth(OAuth):
|
|
|
82
246
|
self._callback_host = parsed.hostname
|
|
83
247
|
self.context.client_metadata.redirect_uris = [redirect_uri]
|
|
84
248
|
|
|
249
|
+
async def _discover_server_metadata(self) -> None:
|
|
250
|
+
"""Best-effort OAuth discovery so refresh uses the real token endpoint."""
|
|
251
|
+
with suppress(httpx.HTTPError, ValueError):
|
|
252
|
+
async with self.httpx_client_factory() as client:
|
|
253
|
+
await self._discover_protected_resource(client)
|
|
254
|
+
await self._discover_authorization_server(client)
|
|
255
|
+
await self._persist_discovered_metadata()
|
|
256
|
+
|
|
257
|
+
async def _discover_protected_resource(self, client: httpx.AsyncClient) -> None:
|
|
258
|
+
if self.context.protected_resource_metadata is not None:
|
|
259
|
+
return
|
|
260
|
+
for url in build_protected_resource_metadata_discovery_urls(
|
|
261
|
+
None,
|
|
262
|
+
self.context.server_url,
|
|
263
|
+
):
|
|
264
|
+
response = await client.send(create_oauth_metadata_request(url))
|
|
265
|
+
prm = await handle_protected_resource_response(response)
|
|
266
|
+
if prm is not None:
|
|
267
|
+
self.context.protected_resource_metadata = prm
|
|
268
|
+
self.context.auth_server_url = str(prm.authorization_servers[0])
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
async def _discover_authorization_server(self, client: httpx.AsyncClient) -> None:
|
|
272
|
+
if self.context.oauth_metadata is not None:
|
|
273
|
+
return
|
|
274
|
+
for url in build_oauth_authorization_server_metadata_discovery_urls(
|
|
275
|
+
self.context.auth_server_url,
|
|
276
|
+
self.context.server_url,
|
|
277
|
+
):
|
|
278
|
+
response = await client.send(create_oauth_metadata_request(url))
|
|
279
|
+
ok, metadata = await handle_auth_metadata_response(response)
|
|
280
|
+
if not ok:
|
|
281
|
+
return
|
|
282
|
+
if metadata is not None:
|
|
283
|
+
self.context.oauth_metadata = metadata
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
async def _persist_discovered_metadata(self) -> None:
|
|
287
|
+
storage = self.token_storage_adapter
|
|
288
|
+
if not isinstance(storage, CodemcpTokenStorage):
|
|
289
|
+
return
|
|
290
|
+
metadata = self.context.oauth_metadata
|
|
291
|
+
if metadata is not None and metadata != self._persisted_oauth_metadata:
|
|
292
|
+
await storage.set_oauth_metadata(metadata)
|
|
293
|
+
self._persisted_oauth_metadata = metadata
|
|
294
|
+
resource = self.context.protected_resource_metadata
|
|
295
|
+
if resource is not None and resource != self._persisted_protected_resource:
|
|
296
|
+
await storage.set_protected_resource_metadata(resource)
|
|
297
|
+
self._persisted_protected_resource = resource
|
|
298
|
+
|
|
299
|
+
async def _restore_unrotated_refresh_token(self, previous_refresh_token: str | None) -> None:
|
|
300
|
+
tokens = self.context.current_tokens
|
|
301
|
+
if tokens is None or tokens.refresh_token is not None or previous_refresh_token is None:
|
|
302
|
+
return
|
|
303
|
+
# RFC 6749 section 6: the server may omit refresh_token when it does not
|
|
304
|
+
# rotate; the SDK overwrites the stored token set and would lose it.
|
|
305
|
+
tokens.refresh_token = previous_refresh_token
|
|
306
|
+
await self.context.storage.set_tokens(tokens)
|
|
307
|
+
|
|
308
|
+
async def _adopt_tokens_refreshed_elsewhere(self, previous_access_token: str | None) -> bool:
|
|
309
|
+
storage = self.token_storage_adapter
|
|
310
|
+
if not isinstance(storage, CodemcpTokenStorage):
|
|
311
|
+
return False
|
|
312
|
+
stored = await storage.get_tokens()
|
|
313
|
+
if (
|
|
314
|
+
stored is None
|
|
315
|
+
or not stored.access_token
|
|
316
|
+
or stored.access_token == previous_access_token
|
|
317
|
+
):
|
|
318
|
+
return False
|
|
319
|
+
expiry = await storage.get_token_expiry()
|
|
320
|
+
if expiry is not None and time.time() > expiry:
|
|
321
|
+
return False
|
|
322
|
+
# Another sidecar process rotated the refresh token first and stored the
|
|
323
|
+
# result; adopt it instead of dropping into the interactive flow.
|
|
324
|
+
self.context.current_tokens = stored
|
|
325
|
+
if expiry is not None:
|
|
326
|
+
self.context.token_expiry_time = expiry
|
|
327
|
+
elif stored.expires_in is not None:
|
|
328
|
+
self.context.token_expiry_time = time.time() + stored.expires_in
|
|
329
|
+
else:
|
|
330
|
+
self.context.token_expiry_time = None
|
|
331
|
+
return True
|
|
332
|
+
|
|
85
333
|
|
|
86
334
|
def load_mcp_json(path: Path) -> JsonObject:
|
|
87
335
|
if not path.exists():
|
package/sidecar/sandbox_api.py
CHANGED
|
@@ -14,7 +14,7 @@ SANDBOX_FUNCTION_EXTERNALS = {
|
|
|
14
14
|
EXPECT_INTEGER_NAME: "__codemcp_expect_integer",
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
STUB_IMPORTS = "from typing import Literal, Never, NotRequired, TypeAlias, TypedDict"
|
|
17
|
+
STUB_IMPORTS = "from typing import Literal, Mapping, Never, NotRequired, TypeAlias, TypedDict"
|
|
18
18
|
JSON_TYPE_STUBS = (
|
|
19
19
|
"JsonScalar: TypeAlias = bool | int | float | str | None",
|
|
20
20
|
'JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]',
|
package/sidecar/stats.py
CHANGED
|
@@ -98,6 +98,7 @@ DISTINCT_NAME_QUERIES = {
|
|
|
98
98
|
FailureOutcome = Literal[
|
|
99
99
|
"success",
|
|
100
100
|
"preflight_rejection",
|
|
101
|
+
"argument_rejection",
|
|
101
102
|
"result_refinement",
|
|
102
103
|
"upstream_failure",
|
|
103
104
|
"transport_failure",
|
|
@@ -996,6 +997,8 @@ def _operation_outcome(
|
|
|
996
997
|
return "internal_error"
|
|
997
998
|
if failure.stage == "preflight":
|
|
998
999
|
return "preflight_rejection"
|
|
1000
|
+
if failure.stage == "arguments":
|
|
1001
|
+
return "argument_rejection"
|
|
999
1002
|
if failure.stage == "result":
|
|
1000
1003
|
return "result_refinement"
|
|
1001
1004
|
if failure.stage == "cancelled":
|
package/sidecar/tool_catalog.py
CHANGED
|
@@ -426,7 +426,8 @@ class ToolCatalog(BaseModel):
|
|
|
426
426
|
for spec in specs:
|
|
427
427
|
definitions.extend(spec.stub.split("\n\n") if spec.stub else [])
|
|
428
428
|
facade_methods.setdefault(spec.namespace, []).append(
|
|
429
|
-
f" async def {spec.method}(self, arguments:
|
|
429
|
+
f" async def {spec.method}(self, arguments: "
|
|
430
|
+
f"{spec.input_type_name} | Mapping[str, JsonValue]) "
|
|
430
431
|
f"-> {spec.output_type_name}: ..."
|
|
431
432
|
)
|
|
432
433
|
facades: list[str] = []
|
|
@@ -114,6 +114,9 @@ function renderCompactFailure(
|
|
|
114
114
|
if (stage === "preflight") {
|
|
115
115
|
return theme.fg("warning", `✗ Preflight · code not run · ${summary}`);
|
|
116
116
|
}
|
|
117
|
+
if (stage === "arguments") {
|
|
118
|
+
return theme.fg("warning", `✗ Argument validation · code run · ${summary}`);
|
|
119
|
+
}
|
|
117
120
|
if (stage === "timeout") {
|
|
118
121
|
return theme.fg("error", `✗ Timeout · stopped after ${summary}`);
|
|
119
122
|
}
|
|
@@ -131,6 +134,9 @@ function failureHeading(stage: string, calls: number, chainCalls: number, theme:
|
|
|
131
134
|
if (stage === "preflight") {
|
|
132
135
|
return `${theme.fg("warning", theme.bold("Preflight failed"))}\n${theme.fg("muted", "Code was not executed; no upstream side effects")}`;
|
|
133
136
|
}
|
|
137
|
+
if (stage === "arguments") {
|
|
138
|
+
return `${theme.fg("warning", theme.bold("Argument validation failed"))}\n${theme.fg("muted", "Code ran; no invalid MCP call was sent")}`;
|
|
139
|
+
}
|
|
134
140
|
if (stage === "timeout") {
|
|
135
141
|
return `${theme.fg("error", theme.bold("Execution timed out"))}\n${theme.fg("muted", `Stopped after ${summary}`)}`;
|
|
136
142
|
}
|