pi-codemcp 1.2.1 → 1.3.0
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/README.md +6 -3
- package/extensions/index.ts +67 -77
- package/package.json +1 -1
- package/sidecar/chains.py +17 -44
- package/sidecar/cli.py +27 -7
- package/sidecar/executor.py +207 -35
- package/sidecar/gateway.py +505 -141
- package/sidecar/mcp_config.py +27 -2
- package/sidecar/refinement_cache.py +154 -0
- package/sidecar/sandbox_api.py +44 -0
- package/sidecar/stats.py +801 -194
- package/sidecar/tool_catalog.py +1 -9
- package/src/chains.ts +63 -34
- package/src/config.ts +8 -39
- package/src/mcp-client.ts +2 -1
- package/src/modal.ts +87 -248
- package/src/prompts.ts +3 -3
- package/src/tools.ts +37 -17
package/sidecar/mcp_config.py
CHANGED
|
@@ -4,7 +4,8 @@ import hashlib
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
|
-
from typing import TYPE_CHECKING
|
|
7
|
+
from typing import TYPE_CHECKING, override
|
|
8
|
+
from urllib.parse import urlsplit
|
|
8
9
|
|
|
9
10
|
from fastmcp.client.auth import OAuth
|
|
10
11
|
from fastmcp.mcp_config import (
|
|
@@ -58,6 +59,30 @@ class NormalizedConfig(BaseModel):
|
|
|
58
59
|
servers: list[NormalizedServerInfo]
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
class PersistentCallbackOAuth(OAuth):
|
|
63
|
+
"""Reuse the callback registered with a persisted dynamic OAuth client."""
|
|
64
|
+
|
|
65
|
+
@override
|
|
66
|
+
async def _initialize(self) -> None:
|
|
67
|
+
await super()._initialize()
|
|
68
|
+
client_info = self.context.client_info
|
|
69
|
+
if client_info is None or not client_info.redirect_uris:
|
|
70
|
+
return
|
|
71
|
+
redirect_uri = client_info.redirect_uris[0]
|
|
72
|
+
parsed = urlsplit(str(redirect_uri))
|
|
73
|
+
if (
|
|
74
|
+
parsed.scheme != "http"
|
|
75
|
+
or parsed.hostname not in {"localhost", "127.0.0.1", "::1"}
|
|
76
|
+
or parsed.port is None
|
|
77
|
+
):
|
|
78
|
+
return
|
|
79
|
+
if parsed.path != "/callback" or parsed.query or parsed.fragment:
|
|
80
|
+
return
|
|
81
|
+
self.redirect_port = parsed.port
|
|
82
|
+
self._callback_host = parsed.hostname
|
|
83
|
+
self.context.client_metadata.redirect_uris = [redirect_uri]
|
|
84
|
+
|
|
85
|
+
|
|
61
86
|
def load_mcp_json(path: Path) -> JsonObject:
|
|
62
87
|
if not path.exists():
|
|
63
88
|
raise FileNotFoundError(f"MCP config not found: {path}")
|
|
@@ -231,7 +256,7 @@ def normalize_mcp_config(
|
|
|
231
256
|
auth: str | httpx.Auth | None
|
|
232
257
|
auth_kind: ServerAuth | None = None
|
|
233
258
|
if raw_auth == "oauth":
|
|
234
|
-
auth =
|
|
259
|
+
auth = PersistentCallbackOAuth(
|
|
235
260
|
mcp_url=url,
|
|
236
261
|
client_name=oauth_client_name,
|
|
237
262
|
token_storage=oauth_storage,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Literal
|
|
9
|
+
|
|
10
|
+
from .json_types import JSON_VALUE_ADAPTER, JsonValue
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
DEFAULT_REFINEMENT_ENTRY_LIMIT = 8
|
|
16
|
+
DEFAULT_REFINEMENT_ENTRY_BYTES = 1024 * 1024
|
|
17
|
+
DEFAULT_REFINEMENT_TOTAL_BYTES = 4 * 1024 * 1024
|
|
18
|
+
DEFAULT_REFINEMENT_TTL_SECONDS = 300.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
type ReferenceFailureReason = Literal[
|
|
22
|
+
"expired",
|
|
23
|
+
"evicted",
|
|
24
|
+
"cross_sidecar",
|
|
25
|
+
"unknown",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class RetainedResult:
|
|
31
|
+
reference: str
|
|
32
|
+
expires_in_seconds: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class _CacheEntry:
|
|
37
|
+
payload: bytes
|
|
38
|
+
expires_at: float
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ResultReferenceError(ValueError):
|
|
42
|
+
def __init__(self, reason: ReferenceFailureReason, reference: str) -> None:
|
|
43
|
+
self.reason = reason
|
|
44
|
+
self.reference = reference
|
|
45
|
+
messages = {
|
|
46
|
+
"expired": "Result reference has expired",
|
|
47
|
+
"evicted": "Result reference was evicted",
|
|
48
|
+
"cross_sidecar": "Result reference belongs to another sidecar",
|
|
49
|
+
"unknown": "Unknown result reference",
|
|
50
|
+
}
|
|
51
|
+
super().__init__(f"{messages[reason]}: {reference}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RefinementCache:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
entry_limit: int = DEFAULT_REFINEMENT_ENTRY_LIMIT,
|
|
59
|
+
entry_byte_limit: int = DEFAULT_REFINEMENT_ENTRY_BYTES,
|
|
60
|
+
total_byte_limit: int = DEFAULT_REFINEMENT_TOTAL_BYTES,
|
|
61
|
+
ttl_seconds: float = DEFAULT_REFINEMENT_TTL_SECONDS,
|
|
62
|
+
clock: Callable[[], float] = time.monotonic,
|
|
63
|
+
) -> None:
|
|
64
|
+
if entry_limit < 1:
|
|
65
|
+
raise ValueError("entry_limit must be positive")
|
|
66
|
+
if entry_byte_limit < 1:
|
|
67
|
+
raise ValueError("entry_byte_limit must be positive")
|
|
68
|
+
if total_byte_limit < entry_byte_limit:
|
|
69
|
+
raise ValueError("total_byte_limit must be at least entry_byte_limit")
|
|
70
|
+
if ttl_seconds <= 0:
|
|
71
|
+
raise ValueError("ttl_seconds must be positive")
|
|
72
|
+
self.entry_limit = entry_limit
|
|
73
|
+
self.entry_byte_limit = entry_byte_limit
|
|
74
|
+
self.total_byte_limit = total_byte_limit
|
|
75
|
+
self.ttl_seconds = ttl_seconds
|
|
76
|
+
self._clock = clock
|
|
77
|
+
self._instance_id = uuid.uuid4().hex[:12]
|
|
78
|
+
self._prefix = f"result_{self._instance_id}_"
|
|
79
|
+
self._entries: OrderedDict[str, _CacheEntry] = OrderedDict()
|
|
80
|
+
self._invalid: OrderedDict[str, ReferenceFailureReason] = OrderedDict()
|
|
81
|
+
self._total_bytes = 0
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def entry_count(self) -> int:
|
|
85
|
+
return len(self._entries)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def total_bytes(self) -> int:
|
|
89
|
+
return self._total_bytes
|
|
90
|
+
|
|
91
|
+
def retain(self, value: JsonValue) -> RetainedResult | None:
|
|
92
|
+
payload = JSON_VALUE_ADAPTER.dump_json(value)
|
|
93
|
+
payload_bytes = len(payload)
|
|
94
|
+
if payload_bytes > self.entry_byte_limit or payload_bytes > self.total_byte_limit:
|
|
95
|
+
return None
|
|
96
|
+
now = self._clock()
|
|
97
|
+
self._prune_expired(now)
|
|
98
|
+
while (
|
|
99
|
+
len(self._entries) >= self.entry_limit
|
|
100
|
+
or self._total_bytes + payload_bytes > self.total_byte_limit
|
|
101
|
+
):
|
|
102
|
+
self._evict_oldest()
|
|
103
|
+
reference = f"{self._prefix}{uuid.uuid4().hex[:16]}"
|
|
104
|
+
self._entries[reference] = _CacheEntry(
|
|
105
|
+
payload=payload,
|
|
106
|
+
expires_at=now + self.ttl_seconds,
|
|
107
|
+
)
|
|
108
|
+
self._total_bytes += payload_bytes
|
|
109
|
+
return RetainedResult(
|
|
110
|
+
reference=reference,
|
|
111
|
+
expires_in_seconds=math.ceil(self.ttl_seconds),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def resolve(self, reference: str) -> JsonValue:
|
|
115
|
+
now = self._clock()
|
|
116
|
+
self._prune_expired(now)
|
|
117
|
+
entry = self._entries.get(reference)
|
|
118
|
+
if entry is not None:
|
|
119
|
+
self._entries.move_to_end(reference)
|
|
120
|
+
return JSON_VALUE_ADAPTER.validate_json(entry.payload)
|
|
121
|
+
reason = self._invalid.get(reference)
|
|
122
|
+
if reason is not None:
|
|
123
|
+
raise ResultReferenceError(reason, reference)
|
|
124
|
+
if not reference.startswith(self._prefix):
|
|
125
|
+
raise ResultReferenceError("cross_sidecar", reference)
|
|
126
|
+
raise ResultReferenceError("unknown", reference)
|
|
127
|
+
|
|
128
|
+
def clear(self) -> None:
|
|
129
|
+
self._entries.clear()
|
|
130
|
+
self._invalid.clear()
|
|
131
|
+
self._total_bytes = 0
|
|
132
|
+
|
|
133
|
+
def _prune_expired(self, now: float) -> None:
|
|
134
|
+
expired = [
|
|
135
|
+
reference for reference, entry in self._entries.items() if entry.expires_at <= now
|
|
136
|
+
]
|
|
137
|
+
for reference in expired:
|
|
138
|
+
self._remove(reference, "expired")
|
|
139
|
+
|
|
140
|
+
def _evict_oldest(self) -> None:
|
|
141
|
+
reference = next(iter(self._entries))
|
|
142
|
+
self._remove(reference, "evicted")
|
|
143
|
+
|
|
144
|
+
def _remove(
|
|
145
|
+
self,
|
|
146
|
+
reference: str,
|
|
147
|
+
reason: Literal["expired", "evicted"],
|
|
148
|
+
) -> None:
|
|
149
|
+
entry = self._entries.pop(reference)
|
|
150
|
+
self._total_bytes -= len(entry.payload)
|
|
151
|
+
self._invalid[reference] = reason
|
|
152
|
+
tombstone_limit = self.entry_limit * 4
|
|
153
|
+
while len(self._invalid) > tombstone_limit:
|
|
154
|
+
self._invalid.popitem(last=False)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
INSPECT_JSON_NAME = "inspect_json"
|
|
4
|
+
EXPECT_OBJECT_NAME = "expect_object"
|
|
5
|
+
EXPECT_LIST_NAME = "expect_list"
|
|
6
|
+
EXPECT_STRING_NAME = "expect_string"
|
|
7
|
+
EXPECT_INTEGER_NAME = "expect_integer"
|
|
8
|
+
|
|
9
|
+
SANDBOX_FUNCTION_EXTERNALS = {
|
|
10
|
+
INSPECT_JSON_NAME: "__codemcp_inspect_json",
|
|
11
|
+
EXPECT_OBJECT_NAME: "__codemcp_expect_object",
|
|
12
|
+
EXPECT_LIST_NAME: "__codemcp_expect_list",
|
|
13
|
+
EXPECT_STRING_NAME: "__codemcp_expect_string",
|
|
14
|
+
EXPECT_INTEGER_NAME: "__codemcp_expect_integer",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
STUB_IMPORTS = "from typing import Literal, Never, NotRequired, TypeAlias, TypedDict"
|
|
18
|
+
JSON_TYPE_STUBS = (
|
|
19
|
+
"JsonScalar: TypeAlias = bool | int | float | str | None",
|
|
20
|
+
'JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]',
|
|
21
|
+
)
|
|
22
|
+
SANDBOX_CAPABILITY_STUB = (
|
|
23
|
+
"# Prebound SDK/helpers. Supported: import asyncio; asyncio.gather.\n"
|
|
24
|
+
"# Unavailable: collections.Counter, base64, gzip, asyncio.create_task,\n"
|
|
25
|
+
"# __import__. Use dict counts."
|
|
26
|
+
)
|
|
27
|
+
INSPECT_JSON_STUB = (
|
|
28
|
+
"def inspect_json(value: JsonValue, *, "
|
|
29
|
+
"samples: Literal[1, 2, 3] = 2, "
|
|
30
|
+
"max_depth: Literal[1, 2, 3, 4, 5, 6] = 3) -> JsonValue: ..."
|
|
31
|
+
)
|
|
32
|
+
NARROWING_HELPER_STUBS = (
|
|
33
|
+
"def expect_object(value: JsonValue) -> dict[str, JsonValue]: ...",
|
|
34
|
+
"def expect_list(value: JsonValue) -> list[JsonValue]: ...",
|
|
35
|
+
"def expect_string(value: JsonValue) -> str: ...",
|
|
36
|
+
"def expect_integer(value: JsonValue) -> int: ...",
|
|
37
|
+
)
|
|
38
|
+
STUB_PRELUDE = "\n\n".join((
|
|
39
|
+
STUB_IMPORTS,
|
|
40
|
+
*JSON_TYPE_STUBS,
|
|
41
|
+
SANDBOX_CAPABILITY_STUB,
|
|
42
|
+
INSPECT_JSON_STUB,
|
|
43
|
+
*NARROWING_HELPER_STUBS,
|
|
44
|
+
))
|