pi-codemcp 0.1.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/.python-version +1 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/extensions/index.ts +188 -0
- package/package.json +91 -0
- package/sidecar/__init__.py +1 -0
- package/sidecar/catalog_cache.py +89 -0
- package/sidecar/chains.py +316 -0
- package/sidecar/executor.py +591 -0
- package/sidecar/gateway.py +893 -0
- package/sidecar/json_types.py +11 -0
- package/sidecar/mcp_config.py +278 -0
- package/sidecar/models.py +92 -0
- package/sidecar/pyproject.toml +144 -0
- package/sidecar/settings.py +59 -0
- package/sidecar/tool_catalog.py +838 -0
- package/sidecar/uv.lock +1775 -0
- package/src/chains.ts +452 -0
- package/src/config.ts +58 -0
- package/src/errors.ts +9 -0
- package/src/execution-rendering.ts +183 -0
- package/src/json-file.ts +54 -0
- package/src/lifecycle.ts +59 -0
- package/src/mcp-client.ts +303 -0
- package/src/modal.ts +1233 -0
- package/src/output.ts +52 -0
- package/src/settings.ts +144 -0
- package/src/tools.ts +332 -0
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import keyword
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, Literal
|
|
8
|
+
|
|
9
|
+
from fastmcp.utilities.json_schema_type import json_schema_to_type
|
|
10
|
+
from mcp import types as mcp_types
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
|
12
|
+
from pydantic_core import to_jsonable_python
|
|
13
|
+
from rapidfuzz import fuzz, process, utils
|
|
14
|
+
|
|
15
|
+
from .json_types import (
|
|
16
|
+
JSON_OBJECT_ADAPTER,
|
|
17
|
+
JSON_VALUE_ADAPTER,
|
|
18
|
+
JsonObject,
|
|
19
|
+
JsonSchema,
|
|
20
|
+
JsonValue,
|
|
21
|
+
)
|
|
22
|
+
from .models import ToolSchemaView
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Iterable
|
|
26
|
+
|
|
27
|
+
from .chains import SavedChainManifest
|
|
28
|
+
|
|
29
|
+
# A 50-point partial match is generic half-string overlap; require evidence above it.
|
|
30
|
+
SEARCH_SCORE_CUTOFF = 51
|
|
31
|
+
STUB_IMPORTS = "from typing import Literal, Never, NotRequired, TypeAlias, TypedDict"
|
|
32
|
+
JSON_TYPE_STUBS = (
|
|
33
|
+
"JsonScalar: TypeAlias = bool | int | float | str | None",
|
|
34
|
+
'JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]',
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ToolSpec(BaseModel):
|
|
39
|
+
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid", strict=True)
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
backend_name: str
|
|
43
|
+
server: str
|
|
44
|
+
namespace: str
|
|
45
|
+
method: str
|
|
46
|
+
call: str
|
|
47
|
+
external_name: str
|
|
48
|
+
short_name: str
|
|
49
|
+
description: str | None
|
|
50
|
+
input_schema: JsonObject
|
|
51
|
+
output_schema: JsonObject | None
|
|
52
|
+
input_type_name: str
|
|
53
|
+
output_type_name: str
|
|
54
|
+
signature: str
|
|
55
|
+
stub: str
|
|
56
|
+
search_blob: str
|
|
57
|
+
input_adapter: TypeAdapter[object]
|
|
58
|
+
kind: Literal["mcp_tool", "saved_chain"] = "mcp_tool"
|
|
59
|
+
chain_id: str | None = None
|
|
60
|
+
schema_fingerprint: str = ""
|
|
61
|
+
output_adapter: TypeAdapter[object] | None = None
|
|
62
|
+
wrapped_output_adapter: TypeAdapter[object] | None = None
|
|
63
|
+
output_wrap_result: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ToolCatalog(BaseModel):
|
|
67
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
68
|
+
|
|
69
|
+
fingerprint: str
|
|
70
|
+
tools: dict[str, ToolSpec]
|
|
71
|
+
type_stubs: str
|
|
72
|
+
servers: tuple[str, ...]
|
|
73
|
+
server_aliases: dict[str, str]
|
|
74
|
+
facade_calls: dict[tuple[str, str], str]
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_server_tools(
|
|
78
|
+
cls,
|
|
79
|
+
server_tools: dict[str, list[mcp_types.Tool]],
|
|
80
|
+
server_names: Iterable[str] | None = None,
|
|
81
|
+
saved_chains: Iterable[SavedChainManifest] = (),
|
|
82
|
+
) -> ToolCatalog:
|
|
83
|
+
server_name_list = tuple(server_names or server_tools.keys())
|
|
84
|
+
namespace_aliases = _unique_python_aliases(
|
|
85
|
+
server_name_list,
|
|
86
|
+
reserved={"chains"},
|
|
87
|
+
)
|
|
88
|
+
prepared: list[
|
|
89
|
+
tuple[
|
|
90
|
+
mcp_types.Tool,
|
|
91
|
+
str,
|
|
92
|
+
str,
|
|
93
|
+
str,
|
|
94
|
+
str,
|
|
95
|
+
Literal["mcp_tool", "saved_chain"],
|
|
96
|
+
str | None,
|
|
97
|
+
]
|
|
98
|
+
] = []
|
|
99
|
+
for server in server_name_list:
|
|
100
|
+
tools = sorted(server_tools.get(server, []), key=lambda item: item.name)
|
|
101
|
+
method_aliases = _unique_python_aliases(tool.name for tool in tools)
|
|
102
|
+
for tool in tools:
|
|
103
|
+
public_name = f"{server}_{tool.name}"
|
|
104
|
+
prepared.append((
|
|
105
|
+
tool,
|
|
106
|
+
public_name,
|
|
107
|
+
server,
|
|
108
|
+
namespace_aliases[server],
|
|
109
|
+
method_aliases[tool.name],
|
|
110
|
+
"mcp_tool",
|
|
111
|
+
None,
|
|
112
|
+
))
|
|
113
|
+
|
|
114
|
+
chains = sorted(saved_chains, key=lambda chain: chain.name)
|
|
115
|
+
prepared.extend(
|
|
116
|
+
(
|
|
117
|
+
mcp_types.Tool(
|
|
118
|
+
name=chain.name,
|
|
119
|
+
description=chain.description,
|
|
120
|
+
inputSchema=chain.input_schema,
|
|
121
|
+
outputSchema=chain.output_schema,
|
|
122
|
+
),
|
|
123
|
+
chain.public_name,
|
|
124
|
+
"chains",
|
|
125
|
+
"chains",
|
|
126
|
+
chain.name,
|
|
127
|
+
"saved_chain",
|
|
128
|
+
chain.id,
|
|
129
|
+
)
|
|
130
|
+
for chain in chains
|
|
131
|
+
)
|
|
132
|
+
catalog_servers = server_name_list + (("chains",) if chains else ())
|
|
133
|
+
return cls._from_prepared(prepared, catalog_servers, namespace_aliases)
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_mcp_tools(
|
|
137
|
+
cls,
|
|
138
|
+
tools: Iterable[mcp_types.Tool],
|
|
139
|
+
server_names: Iterable[str],
|
|
140
|
+
) -> ToolCatalog:
|
|
141
|
+
"""Compatibility constructor for already namespaced aggregate catalogs."""
|
|
142
|
+
names = tuple(server_names)
|
|
143
|
+
grouped: dict[str, list[mcp_types.Tool]] = {name: [] for name in names}
|
|
144
|
+
for tool in tools:
|
|
145
|
+
server = _extract_server_name(tool.name, names)
|
|
146
|
+
if server is None:
|
|
147
|
+
if len(names) != 1:
|
|
148
|
+
raise ValueError(f"Cannot determine server for MCP tool {tool.name!r}")
|
|
149
|
+
server = names[0]
|
|
150
|
+
backend_name = tool.name
|
|
151
|
+
else:
|
|
152
|
+
backend_name = tool.name[len(server) + 1 :]
|
|
153
|
+
grouped[server].append(tool.model_copy(update={"name": backend_name}))
|
|
154
|
+
return cls.from_server_tools(grouped, names)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def _from_prepared(
|
|
158
|
+
cls,
|
|
159
|
+
prepared: list[
|
|
160
|
+
tuple[
|
|
161
|
+
mcp_types.Tool,
|
|
162
|
+
str,
|
|
163
|
+
str,
|
|
164
|
+
str,
|
|
165
|
+
str,
|
|
166
|
+
Literal["mcp_tool", "saved_chain"],
|
|
167
|
+
str | None,
|
|
168
|
+
]
|
|
169
|
+
],
|
|
170
|
+
server_names: tuple[str, ...],
|
|
171
|
+
server_aliases: dict[str, str],
|
|
172
|
+
) -> ToolCatalog:
|
|
173
|
+
fingerprint_source = [
|
|
174
|
+
{
|
|
175
|
+
"name": public_name,
|
|
176
|
+
"backendName": tool.name,
|
|
177
|
+
"server": server,
|
|
178
|
+
"call": f"{namespace}.{method}",
|
|
179
|
+
"kind": kind,
|
|
180
|
+
"chainId": chain_id,
|
|
181
|
+
"description": tool.description,
|
|
182
|
+
"inputSchema": tool.inputSchema,
|
|
183
|
+
"outputSchema": tool.outputSchema,
|
|
184
|
+
}
|
|
185
|
+
for tool, public_name, server, namespace, method, kind, chain_id in prepared
|
|
186
|
+
]
|
|
187
|
+
fingerprint = hashlib.sha256(
|
|
188
|
+
json.dumps(fingerprint_source, sort_keys=True, default=str).encode()
|
|
189
|
+
).hexdigest()
|
|
190
|
+
|
|
191
|
+
specs: dict[str, ToolSpec] = {}
|
|
192
|
+
definitions: list[str] = []
|
|
193
|
+
facade_methods: dict[str, list[str]] = {}
|
|
194
|
+
facade_classes: dict[str, str] = {}
|
|
195
|
+
facade_calls: dict[tuple[str, str], str] = {}
|
|
196
|
+
|
|
197
|
+
for tool, public_name, server, namespace, method, kind, chain_id in prepared:
|
|
198
|
+
if public_name in specs:
|
|
199
|
+
raise ValueError(f"Duplicate MCP tool name after namespacing: {public_name}")
|
|
200
|
+
input_schema = JSON_OBJECT_ADAPTER.validate_python(tool.inputSchema)
|
|
201
|
+
output_schema = (
|
|
202
|
+
JSON_OBJECT_ADAPTER.validate_python(tool.outputSchema)
|
|
203
|
+
if tool.outputSchema is not None
|
|
204
|
+
else None
|
|
205
|
+
)
|
|
206
|
+
builder = StubBuilder(
|
|
207
|
+
public_name,
|
|
208
|
+
input_schema,
|
|
209
|
+
output_schema,
|
|
210
|
+
normalize_json_string_output=kind == "mcp_tool",
|
|
211
|
+
)
|
|
212
|
+
input_type, output_type, tool_definitions = builder.build()
|
|
213
|
+
call = f"{namespace}.{method}"
|
|
214
|
+
signature = f"await {call}(arguments: {input_type}) -> {output_type}"
|
|
215
|
+
wrap_output = bool(
|
|
216
|
+
kind == "mcp_tool" and output_schema and output_schema.get("x-fastmcp-wrap-result")
|
|
217
|
+
)
|
|
218
|
+
adapter_schema: JsonSchema | None = output_schema
|
|
219
|
+
wrapped_schema: JsonSchema | None = None
|
|
220
|
+
if output_schema:
|
|
221
|
+
raw_wrapped_schema = _object_property(output_schema, "result")
|
|
222
|
+
if isinstance(raw_wrapped_schema, (dict, bool)):
|
|
223
|
+
wrapped_schema = raw_wrapped_schema
|
|
224
|
+
if wrap_output and output_schema:
|
|
225
|
+
adapter_schema = wrapped_schema or True
|
|
226
|
+
external_name = f"__codemcp_{hashlib.sha256(public_name.encode()).hexdigest()[:16]}"
|
|
227
|
+
|
|
228
|
+
spec = ToolSpec(
|
|
229
|
+
name=public_name,
|
|
230
|
+
backend_name=tool.name,
|
|
231
|
+
server=server,
|
|
232
|
+
namespace=namespace,
|
|
233
|
+
method=method,
|
|
234
|
+
call=call,
|
|
235
|
+
external_name=external_name,
|
|
236
|
+
short_name=tool.name,
|
|
237
|
+
description=tool.description,
|
|
238
|
+
input_schema=input_schema,
|
|
239
|
+
output_schema=output_schema,
|
|
240
|
+
input_type_name=input_type,
|
|
241
|
+
output_type_name=output_type,
|
|
242
|
+
signature=signature,
|
|
243
|
+
stub="\n\n".join([*JSON_TYPE_STUBS, *tool_definitions]),
|
|
244
|
+
search_blob=_build_search_blob(
|
|
245
|
+
public_name,
|
|
246
|
+
call,
|
|
247
|
+
server,
|
|
248
|
+
tool.name,
|
|
249
|
+
tool.description,
|
|
250
|
+
input_schema,
|
|
251
|
+
),
|
|
252
|
+
input_adapter=_schema_adapter(input_schema),
|
|
253
|
+
kind=kind,
|
|
254
|
+
chain_id=chain_id,
|
|
255
|
+
schema_fingerprint=_schema_fingerprint({
|
|
256
|
+
"input_schema": input_schema,
|
|
257
|
+
"output_schema": output_schema,
|
|
258
|
+
}),
|
|
259
|
+
output_adapter=(
|
|
260
|
+
_schema_adapter(adapter_schema) if adapter_schema is not None else None
|
|
261
|
+
),
|
|
262
|
+
wrapped_output_adapter=(
|
|
263
|
+
_schema_adapter(wrapped_schema) if wrapped_schema is not None else None
|
|
264
|
+
),
|
|
265
|
+
output_wrap_result=wrap_output,
|
|
266
|
+
)
|
|
267
|
+
specs[public_name] = spec
|
|
268
|
+
definitions.extend(tool_definitions)
|
|
269
|
+
class_name = facade_classes.setdefault(
|
|
270
|
+
namespace,
|
|
271
|
+
f"_{_pascal_case(namespace)}Sdk",
|
|
272
|
+
)
|
|
273
|
+
facade_methods.setdefault(namespace, []).append(
|
|
274
|
+
f" async def {method}(self, arguments: {input_type}) -> {output_type}: ..."
|
|
275
|
+
)
|
|
276
|
+
facade_calls[namespace, method] = public_name
|
|
277
|
+
|
|
278
|
+
facade_stubs: list[str] = []
|
|
279
|
+
for namespace in sorted(facade_methods):
|
|
280
|
+
class_name = facade_classes[namespace]
|
|
281
|
+
facade_stubs.extend((
|
|
282
|
+
"\n".join([f"class {class_name}:", *facade_methods[namespace]]),
|
|
283
|
+
f"{namespace}: {class_name}",
|
|
284
|
+
))
|
|
285
|
+
|
|
286
|
+
type_stubs = "\n\n".join([
|
|
287
|
+
STUB_IMPORTS,
|
|
288
|
+
*JSON_TYPE_STUBS,
|
|
289
|
+
*_dedupe(definitions),
|
|
290
|
+
*facade_stubs,
|
|
291
|
+
])
|
|
292
|
+
return cls(
|
|
293
|
+
fingerprint=fingerprint,
|
|
294
|
+
tools=specs,
|
|
295
|
+
type_stubs=type_stubs,
|
|
296
|
+
servers=server_names,
|
|
297
|
+
server_aliases=server_aliases,
|
|
298
|
+
facade_calls=facade_calls,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def search(
|
|
302
|
+
self,
|
|
303
|
+
query: str,
|
|
304
|
+
limit: int = 5,
|
|
305
|
+
*,
|
|
306
|
+
server: str | None = None,
|
|
307
|
+
) -> list[ToolSchemaView]:
|
|
308
|
+
candidates = {
|
|
309
|
+
spec.name: spec.search_blob
|
|
310
|
+
for spec in sorted(self.tools.values(), key=lambda item: item.name)
|
|
311
|
+
if server is None or spec.server == server
|
|
312
|
+
}
|
|
313
|
+
ranked = process.extract(
|
|
314
|
+
query,
|
|
315
|
+
candidates,
|
|
316
|
+
scorer=fuzz.partial_ratio,
|
|
317
|
+
processor=utils.default_process,
|
|
318
|
+
score_cutoff=SEARCH_SCORE_CUTOFF,
|
|
319
|
+
limit=limit,
|
|
320
|
+
)
|
|
321
|
+
return [
|
|
322
|
+
ToolSchemaView(
|
|
323
|
+
name=self.tools[name].name,
|
|
324
|
+
call=self.tools[name].call,
|
|
325
|
+
source=self.tools[name].kind,
|
|
326
|
+
server=self.tools[name].server,
|
|
327
|
+
description=_short_description(self.tools[name].description),
|
|
328
|
+
signature=self.tools[name].signature,
|
|
329
|
+
stub=self.tools[name].stub,
|
|
330
|
+
)
|
|
331
|
+
for _, _, name in ranked
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
def counts_by_server(self) -> dict[str, int]:
|
|
335
|
+
counts = dict.fromkeys(self.servers, 0)
|
|
336
|
+
for spec in self.tools.values():
|
|
337
|
+
if spec.server:
|
|
338
|
+
counts[spec.server] = counts.get(spec.server, 0) + 1
|
|
339
|
+
return counts
|
|
340
|
+
|
|
341
|
+
def validate_arguments(self, tool_name: str, arguments: JsonObject) -> JsonObject:
|
|
342
|
+
spec = self.tools[tool_name]
|
|
343
|
+
value = spec.input_adapter.validate_python(arguments)
|
|
344
|
+
dumped = JSON_VALUE_ADAPTER.validate_python(
|
|
345
|
+
spec.input_adapter.dump_python(
|
|
346
|
+
value,
|
|
347
|
+
mode="json",
|
|
348
|
+
exclude_unset=True,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
if not isinstance(dumped, dict):
|
|
352
|
+
raise TypeError(f"{tool_name}: arguments did not validate as an object")
|
|
353
|
+
projected = _project_to_input_shape(dumped, arguments)
|
|
354
|
+
if not isinstance(projected, dict):
|
|
355
|
+
raise TypeError(f"{tool_name}: arguments did not normalize as an object")
|
|
356
|
+
return projected
|
|
357
|
+
|
|
358
|
+
def validate_saved_chain_result(self, tool_name: str, result: JsonValue) -> JsonValue:
|
|
359
|
+
spec = self.tools[tool_name]
|
|
360
|
+
if spec.kind != "saved_chain" or spec.output_adapter is None:
|
|
361
|
+
raise TypeError(f"{tool_name} is not a saved chain with an output contract")
|
|
362
|
+
validated = spec.output_adapter.validate_python(result)
|
|
363
|
+
return JSON_VALUE_ADAPTER.validate_python(
|
|
364
|
+
spec.output_adapter.dump_python(validated, mode="json")
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def normalize_result(self, tool_name: str, result: mcp_types.CallToolResult) -> JsonValue:
|
|
368
|
+
spec = self.tools[tool_name]
|
|
369
|
+
if result.isError:
|
|
370
|
+
message = "Upstream MCP tool returned an error"
|
|
371
|
+
if result.content and isinstance(result.content[0], mcp_types.TextContent):
|
|
372
|
+
message = result.content[0].text
|
|
373
|
+
raise RuntimeError(f"{tool_name}: {message}")
|
|
374
|
+
|
|
375
|
+
if spec.output_schema:
|
|
376
|
+
if result.structuredContent is None:
|
|
377
|
+
raise RuntimeError(
|
|
378
|
+
f"{tool_name}: upstream returned no structuredContent "
|
|
379
|
+
"for its declared output schema"
|
|
380
|
+
)
|
|
381
|
+
structured = JSON_VALUE_ADAPTER.validate_python(result.structuredContent)
|
|
382
|
+
raw_meta: object = (result.meta or {}).get("fastmcp")
|
|
383
|
+
wrap_from_meta = isinstance(raw_meta, dict) and bool(raw_meta.get("wrap_result"))
|
|
384
|
+
if spec.output_wrap_result or wrap_from_meta:
|
|
385
|
+
if not isinstance(structured, dict) or "result" not in structured:
|
|
386
|
+
raise RuntimeError(f"{tool_name}: wrapped output omitted the result field")
|
|
387
|
+
structured = structured["result"]
|
|
388
|
+
adapter = spec.output_adapter
|
|
389
|
+
if wrap_from_meta and not spec.output_wrap_result:
|
|
390
|
+
adapter = spec.wrapped_output_adapter
|
|
391
|
+
if adapter is None:
|
|
392
|
+
return _normalize_json_value(
|
|
393
|
+
JSON_VALUE_ADAPTER.validate_python(to_jsonable_python(structured))
|
|
394
|
+
)
|
|
395
|
+
validated = adapter.validate_python(structured)
|
|
396
|
+
return _normalize_json_value(
|
|
397
|
+
JSON_VALUE_ADAPTER.validate_python(adapter.dump_python(validated, mode="json"))
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if result.structuredContent is not None:
|
|
401
|
+
return _normalize_json_value(
|
|
402
|
+
JSON_VALUE_ADAPTER.validate_python(to_jsonable_python(result.structuredContent))
|
|
403
|
+
)
|
|
404
|
+
if len(result.content) == 1 and isinstance(result.content[0], mcp_types.TextContent):
|
|
405
|
+
return _normalize_text_result(result.content[0].text)
|
|
406
|
+
return [
|
|
407
|
+
JSON_OBJECT_ADAPTER.validate_python(block.model_dump(mode="json", by_alias=True))
|
|
408
|
+
for block in result.content
|
|
409
|
+
]
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _project_to_input_shape(normalized: JsonValue, supplied: JsonValue) -> JsonValue:
|
|
413
|
+
if isinstance(normalized, dict) and isinstance(supplied, dict):
|
|
414
|
+
return {
|
|
415
|
+
key: _project_to_input_shape(normalized[key], supplied_value)
|
|
416
|
+
for key, supplied_value in supplied.items()
|
|
417
|
+
if key in normalized
|
|
418
|
+
}
|
|
419
|
+
if isinstance(normalized, list) and isinstance(supplied, list):
|
|
420
|
+
return [
|
|
421
|
+
_project_to_input_shape(item, supplied[index])
|
|
422
|
+
for index, item in enumerate(normalized)
|
|
423
|
+
if index < len(supplied)
|
|
424
|
+
]
|
|
425
|
+
return normalized
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _normalize_json_value(value: JsonValue) -> JsonValue:
|
|
429
|
+
if isinstance(value, str):
|
|
430
|
+
return _normalize_text_result(value)
|
|
431
|
+
if isinstance(value, dict):
|
|
432
|
+
return {key: _normalize_json_value(item) for key, item in value.items()}
|
|
433
|
+
if isinstance(value, list):
|
|
434
|
+
return [_normalize_json_value(item) for item in value]
|
|
435
|
+
return value
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _normalize_text_result(text: str) -> JsonValue:
|
|
439
|
+
stripped = text.strip()
|
|
440
|
+
if stripped in {"null", "true", "false"}:
|
|
441
|
+
return JSON_VALUE_ADAPTER.validate_json(stripped)
|
|
442
|
+
if not stripped.startswith(("{", "[")):
|
|
443
|
+
return text
|
|
444
|
+
try:
|
|
445
|
+
parsed = json.loads(stripped)
|
|
446
|
+
except json.JSONDecodeError:
|
|
447
|
+
return text
|
|
448
|
+
return (
|
|
449
|
+
_normalize_json_value(JSON_VALUE_ADAPTER.validate_python(parsed))
|
|
450
|
+
if isinstance(parsed, (dict, list))
|
|
451
|
+
else text
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _as_schema(value: JsonValue | None) -> JsonSchema:
|
|
456
|
+
return value if isinstance(value, (dict, bool)) else True
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _object_property(schema: JsonObject, name: str) -> JsonValue | None:
|
|
460
|
+
properties = schema.get("properties")
|
|
461
|
+
return properties.get(name) if isinstance(properties, dict) else None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _schema_adapter(schema: JsonSchema) -> TypeAdapter[object]:
|
|
465
|
+
return TypeAdapter(json_schema_to_type(schema))
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class StubBuilder:
|
|
469
|
+
def __init__(
|
|
470
|
+
self,
|
|
471
|
+
tool_name: str,
|
|
472
|
+
input_schema: JsonObject,
|
|
473
|
+
output_schema: JsonObject | None,
|
|
474
|
+
*,
|
|
475
|
+
normalize_json_string_output: bool = True,
|
|
476
|
+
) -> None:
|
|
477
|
+
self.tool_name = tool_name
|
|
478
|
+
self.input_schema = input_schema
|
|
479
|
+
self.output_schema = output_schema
|
|
480
|
+
self.normalize_json_string_output = normalize_json_string_output
|
|
481
|
+
self._definitions: list[str] = []
|
|
482
|
+
self._seen: dict[tuple[str, str], str] = {}
|
|
483
|
+
self._active_refs: set[str] = set()
|
|
484
|
+
|
|
485
|
+
def build(self) -> tuple[str, str, list[str]]:
|
|
486
|
+
input_type = self._ensure_named_type(
|
|
487
|
+
f"{_pascal_case(self.tool_name)}Args",
|
|
488
|
+
self.input_schema,
|
|
489
|
+
self.input_schema,
|
|
490
|
+
)
|
|
491
|
+
output_schema: JsonSchema = self.output_schema or True
|
|
492
|
+
if (
|
|
493
|
+
self.normalize_json_string_output
|
|
494
|
+
and self.output_schema
|
|
495
|
+
and self.output_schema.get("x-fastmcp-wrap-result")
|
|
496
|
+
):
|
|
497
|
+
output_schema = _as_schema(_object_property(self.output_schema, "result"))
|
|
498
|
+
if (
|
|
499
|
+
self.normalize_json_string_output
|
|
500
|
+
and isinstance(output_schema, dict)
|
|
501
|
+
and output_schema.get("type") == "string"
|
|
502
|
+
):
|
|
503
|
+
# Runtime normalization promotes JSON object/array text to native values.
|
|
504
|
+
# A plain `str` annotation would therefore be a false guarantee.
|
|
505
|
+
output_schema = True
|
|
506
|
+
output_type = self._ensure_named_type(
|
|
507
|
+
f"{_pascal_case(self.tool_name)}Result",
|
|
508
|
+
output_schema,
|
|
509
|
+
self.output_schema or output_schema,
|
|
510
|
+
)
|
|
511
|
+
return input_type, output_type, _dedupe(self._definitions)
|
|
512
|
+
|
|
513
|
+
def _ensure_named_type(
|
|
514
|
+
self,
|
|
515
|
+
name: str,
|
|
516
|
+
schema: JsonSchema,
|
|
517
|
+
root: JsonSchema,
|
|
518
|
+
) -> str:
|
|
519
|
+
expression = self._type_expr(schema, root, name)
|
|
520
|
+
if expression == name:
|
|
521
|
+
return name
|
|
522
|
+
key = (name, _schema_fingerprint(schema))
|
|
523
|
+
if key not in self._seen:
|
|
524
|
+
self._seen[key] = name
|
|
525
|
+
self._definitions.append(f"{name}: TypeAlias = {expression}")
|
|
526
|
+
return name
|
|
527
|
+
|
|
528
|
+
def _type_expr(
|
|
529
|
+
self,
|
|
530
|
+
schema: JsonSchema,
|
|
531
|
+
root: JsonSchema,
|
|
532
|
+
name: str,
|
|
533
|
+
) -> str:
|
|
534
|
+
if isinstance(schema, bool):
|
|
535
|
+
return "JsonValue" if schema else "Never"
|
|
536
|
+
ref = schema.get("$ref")
|
|
537
|
+
if isinstance(ref, str):
|
|
538
|
+
return self._resolved_ref_type(ref, root, name)
|
|
539
|
+
constant = schema.get("const")
|
|
540
|
+
if isinstance(constant, (bool, int, float, str)) or (
|
|
541
|
+
constant is None and "const" in schema
|
|
542
|
+
):
|
|
543
|
+
return f"Literal[{constant!r}]"
|
|
544
|
+
enum_values = schema.get("enum")
|
|
545
|
+
if (
|
|
546
|
+
isinstance(enum_values, list)
|
|
547
|
+
and enum_values
|
|
548
|
+
and all(
|
|
549
|
+
isinstance(value, (bool, int, float, str)) or value is None for value in enum_values
|
|
550
|
+
)
|
|
551
|
+
):
|
|
552
|
+
return f"Literal[{', '.join(repr(value) for value in enum_values)}]"
|
|
553
|
+
alternatives = schema.get("anyOf") or schema.get("oneOf")
|
|
554
|
+
if isinstance(alternatives, list):
|
|
555
|
+
rendered = [
|
|
556
|
+
self._type_expr(member, root, f"{name}Option{index}")
|
|
557
|
+
for index, member in enumerate(alternatives, start=1)
|
|
558
|
+
if isinstance(member, (dict, bool))
|
|
559
|
+
]
|
|
560
|
+
return " | ".join(dict.fromkeys(rendered)) or "JsonValue"
|
|
561
|
+
if "allOf" in schema:
|
|
562
|
+
merged = _merge_all_of(schema, root)
|
|
563
|
+
return "JsonValue" if merged is None else self._type_expr(merged, root, name)
|
|
564
|
+
|
|
565
|
+
raw_type = schema.get("type")
|
|
566
|
+
if isinstance(raw_type, list):
|
|
567
|
+
rendered = [
|
|
568
|
+
self._type_expr({**schema, "type": member}, root, name)
|
|
569
|
+
for member in raw_type
|
|
570
|
+
if isinstance(member, str)
|
|
571
|
+
]
|
|
572
|
+
return " | ".join(dict.fromkeys(rendered)) or "JsonValue"
|
|
573
|
+
if raw_type is None:
|
|
574
|
+
if "properties" in schema or "additionalProperties" in schema:
|
|
575
|
+
raw_type = "object"
|
|
576
|
+
elif "items" in schema:
|
|
577
|
+
raw_type = "array"
|
|
578
|
+
else:
|
|
579
|
+
return "JsonValue"
|
|
580
|
+
|
|
581
|
+
primitives = {
|
|
582
|
+
"string": "str",
|
|
583
|
+
"integer": "int",
|
|
584
|
+
"number": "float",
|
|
585
|
+
"boolean": "bool",
|
|
586
|
+
"null": "None",
|
|
587
|
+
}
|
|
588
|
+
if isinstance(raw_type, str) and raw_type in primitives:
|
|
589
|
+
return primitives[raw_type]
|
|
590
|
+
if raw_type == "array":
|
|
591
|
+
items = schema.get("items", True)
|
|
592
|
+
if isinstance(items, list):
|
|
593
|
+
members = [
|
|
594
|
+
self._type_expr(
|
|
595
|
+
_as_schema(item),
|
|
596
|
+
root,
|
|
597
|
+
f"{name}Item{index}",
|
|
598
|
+
)
|
|
599
|
+
for index, item in enumerate(items, start=1)
|
|
600
|
+
]
|
|
601
|
+
return f"tuple[{', '.join(members)}]"
|
|
602
|
+
return f"list[{self._type_expr(_as_schema(items), root, f'{name}Item')}]"
|
|
603
|
+
if raw_type == "object":
|
|
604
|
+
raw_properties = schema.get("properties")
|
|
605
|
+
properties = raw_properties if isinstance(raw_properties, dict) else {}
|
|
606
|
+
if properties:
|
|
607
|
+
if any(not _valid_identifier(prop) for prop in properties):
|
|
608
|
+
return "dict[str, JsonValue]"
|
|
609
|
+
key = (name, _schema_fingerprint(schema))
|
|
610
|
+
if key in self._seen:
|
|
611
|
+
return self._seen[key]
|
|
612
|
+
self._seen[key] = name
|
|
613
|
+
raw_required = schema.get("required")
|
|
614
|
+
required = (
|
|
615
|
+
{item for item in raw_required if isinstance(item, str)}
|
|
616
|
+
if isinstance(raw_required, list)
|
|
617
|
+
else set()
|
|
618
|
+
)
|
|
619
|
+
lines = [f"class {name}(TypedDict):"]
|
|
620
|
+
for prop, prop_schema in properties.items():
|
|
621
|
+
child_schema = _as_schema(prop_schema)
|
|
622
|
+
prop_type = self._type_expr(
|
|
623
|
+
child_schema,
|
|
624
|
+
root,
|
|
625
|
+
f"{name}{_pascal_case(prop)}",
|
|
626
|
+
)
|
|
627
|
+
wrapper = prop_type if prop in required else f"NotRequired[{prop_type}]"
|
|
628
|
+
comment = _field_comment(child_schema)
|
|
629
|
+
suffix = f" # {comment}" if comment else ""
|
|
630
|
+
lines.append(f" {prop}: {wrapper}{suffix}")
|
|
631
|
+
self._definitions.append("\n".join(lines))
|
|
632
|
+
return name
|
|
633
|
+
additional = _as_schema(schema.get("additionalProperties", True))
|
|
634
|
+
return f"dict[str, {self._type_expr(additional, root, f'{name}Value')}]"
|
|
635
|
+
return "JsonValue"
|
|
636
|
+
|
|
637
|
+
def _resolved_ref_type(self, ref: str, root: JsonSchema, name: str) -> str:
|
|
638
|
+
if ref in self._active_refs:
|
|
639
|
+
return "JsonValue"
|
|
640
|
+
self._active_refs.add(ref)
|
|
641
|
+
try:
|
|
642
|
+
return self._type_expr(_resolve_ref(ref, root), root, name)
|
|
643
|
+
finally:
|
|
644
|
+
self._active_refs.remove(ref)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _short_description(description: str | None, limit: int = 240) -> str | None:
|
|
648
|
+
if description is None:
|
|
649
|
+
return None
|
|
650
|
+
first_paragraph = description.split("\n\n", 1)[0]
|
|
651
|
+
compact = " ".join(first_paragraph.split())
|
|
652
|
+
if len(compact) <= limit:
|
|
653
|
+
return compact
|
|
654
|
+
return f"{compact[: limit - 1].rstrip()}…"
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _field_comment(schema: JsonSchema) -> str | None:
|
|
658
|
+
if isinstance(schema, bool):
|
|
659
|
+
return None
|
|
660
|
+
notes: list[str] = []
|
|
661
|
+
raw_description = schema.get("description")
|
|
662
|
+
description = _short_description(
|
|
663
|
+
raw_description if isinstance(raw_description, str) else None,
|
|
664
|
+
limit=120,
|
|
665
|
+
)
|
|
666
|
+
if description:
|
|
667
|
+
notes.append(description)
|
|
668
|
+
constraints = (
|
|
669
|
+
("minimum", ">="),
|
|
670
|
+
("exclusiveMinimum", ">"),
|
|
671
|
+
("maximum", "<="),
|
|
672
|
+
("exclusiveMaximum", "<"),
|
|
673
|
+
("minLength", "min length"),
|
|
674
|
+
("maxLength", "max length"),
|
|
675
|
+
("minItems", "min items"),
|
|
676
|
+
("maxItems", "max items"),
|
|
677
|
+
("pattern", "pattern"),
|
|
678
|
+
("format", "format"),
|
|
679
|
+
)
|
|
680
|
+
for key, label in constraints:
|
|
681
|
+
if key in schema:
|
|
682
|
+
notes.append(f"{label} {schema[key]}")
|
|
683
|
+
if "default" in schema:
|
|
684
|
+
notes.append(f"default {schema['default']!r}")
|
|
685
|
+
return "; ".join(notes) or None
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _build_search_blob(
|
|
689
|
+
public_name: str,
|
|
690
|
+
call: str,
|
|
691
|
+
server: str,
|
|
692
|
+
short_name: str,
|
|
693
|
+
description: str | None,
|
|
694
|
+
input_schema: JsonObject,
|
|
695
|
+
) -> str:
|
|
696
|
+
return " ".join([
|
|
697
|
+
public_name,
|
|
698
|
+
call,
|
|
699
|
+
server,
|
|
700
|
+
short_name,
|
|
701
|
+
description or "",
|
|
702
|
+
*_collect_property_names(input_schema),
|
|
703
|
+
])
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _collect_property_names(schema: JsonSchema) -> list[str]:
|
|
707
|
+
if isinstance(schema, bool):
|
|
708
|
+
return []
|
|
709
|
+
names: list[str] = []
|
|
710
|
+
raw_properties = schema.get("properties")
|
|
711
|
+
properties = raw_properties if isinstance(raw_properties, dict) else {}
|
|
712
|
+
for prop, child in properties.items():
|
|
713
|
+
names.append(prop)
|
|
714
|
+
if isinstance(child, dict):
|
|
715
|
+
names.extend(_collect_property_names(child))
|
|
716
|
+
items = schema.get("items")
|
|
717
|
+
if isinstance(items, dict):
|
|
718
|
+
names.extend(_collect_property_names(items))
|
|
719
|
+
elif isinstance(items, list):
|
|
720
|
+
for item in items:
|
|
721
|
+
if isinstance(item, dict):
|
|
722
|
+
names.extend(_collect_property_names(item))
|
|
723
|
+
return names
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _extract_server_name(tool_name: str, server_names: Iterable[str]) -> str | None:
|
|
727
|
+
for server in sorted(server_names, key=len, reverse=True):
|
|
728
|
+
if tool_name.startswith(f"{server}_"):
|
|
729
|
+
return server
|
|
730
|
+
return None
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _unique_python_aliases(
|
|
734
|
+
values: Iterable[str],
|
|
735
|
+
*,
|
|
736
|
+
reserved: set[str] | None = None,
|
|
737
|
+
) -> dict[str, str]:
|
|
738
|
+
originals = list(values)
|
|
739
|
+
aliases: dict[str, str] = {}
|
|
740
|
+
used: set[str] = set(reserved or ())
|
|
741
|
+
for original in sorted(originals):
|
|
742
|
+
base = _python_identifier(original)
|
|
743
|
+
alias = base
|
|
744
|
+
if alias in used:
|
|
745
|
+
suffix = hashlib.sha256(original.encode("utf-8")).hexdigest()[:6]
|
|
746
|
+
alias = f"{base}_{suffix}"
|
|
747
|
+
counter = 2
|
|
748
|
+
while alias in used:
|
|
749
|
+
alias = f"{base}_{counter}"
|
|
750
|
+
counter += 1
|
|
751
|
+
aliases[original] = alias
|
|
752
|
+
used.add(alias)
|
|
753
|
+
return aliases
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _python_identifier(value: str) -> str:
|
|
757
|
+
identifier = re.sub(r"[^a-zA-Z0-9_]", "_", value).strip("_").lower()
|
|
758
|
+
identifier = re.sub(r"_+", "_", identifier) or "mcp"
|
|
759
|
+
if identifier[0].isdigit():
|
|
760
|
+
identifier = f"mcp_{identifier}"
|
|
761
|
+
if keyword.iskeyword(identifier):
|
|
762
|
+
identifier = f"{identifier}_"
|
|
763
|
+
return identifier
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _pascal_case(value: str) -> str:
|
|
767
|
+
parts = [part for part in re.split(r"[^a-zA-Z0-9]+", value) if part]
|
|
768
|
+
rendered = "".join(part[:1].upper() + part[1:] for part in parts) or "Anonymous"
|
|
769
|
+
return f"T{rendered}" if rendered[0].isdigit() else rendered
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _valid_identifier(value: str) -> bool:
|
|
773
|
+
return value.isidentifier() and not keyword.iskeyword(value)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _schema_fingerprint(schema: JsonSchema) -> str:
|
|
777
|
+
return hashlib.sha256(json.dumps(schema, sort_keys=True, default=str).encode()).hexdigest()
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _resolve_ref(ref: str, root: JsonSchema) -> JsonObject:
|
|
781
|
+
if isinstance(root, bool) or not ref.startswith("#/"):
|
|
782
|
+
return {}
|
|
783
|
+
current: JsonValue = root
|
|
784
|
+
for raw_part in ref[2:].split("/"):
|
|
785
|
+
part = raw_part.replace("~1", "/").replace("~0", "~")
|
|
786
|
+
if not isinstance(current, dict):
|
|
787
|
+
return {}
|
|
788
|
+
current = current.get(part, {})
|
|
789
|
+
return current if isinstance(current, dict) else {}
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _merge_all_of(
|
|
793
|
+
schema: JsonObject,
|
|
794
|
+
root: JsonSchema,
|
|
795
|
+
) -> JsonObject | None:
|
|
796
|
+
merged_properties: JsonObject = {}
|
|
797
|
+
merged_required: list[JsonValue] = []
|
|
798
|
+
additional_properties: JsonValue = True
|
|
799
|
+
has_additional_properties = False
|
|
800
|
+
raw_members = schema.get("allOf")
|
|
801
|
+
if not isinstance(raw_members, list):
|
|
802
|
+
return None
|
|
803
|
+
for member in raw_members:
|
|
804
|
+
if not isinstance(member, dict):
|
|
805
|
+
return None
|
|
806
|
+
ref = member.get("$ref")
|
|
807
|
+
resolved = _resolve_ref(ref, root) if isinstance(ref, str) else member
|
|
808
|
+
if resolved.get("type") not in {None, "object"}:
|
|
809
|
+
return None
|
|
810
|
+
raw_properties = resolved.get("properties")
|
|
811
|
+
if isinstance(raw_properties, dict):
|
|
812
|
+
merged_properties.update(raw_properties)
|
|
813
|
+
raw_required = resolved.get("required")
|
|
814
|
+
if isinstance(raw_required, list):
|
|
815
|
+
for required in raw_required:
|
|
816
|
+
if isinstance(required, str) and required not in merged_required:
|
|
817
|
+
merged_required.append(required)
|
|
818
|
+
if "additionalProperties" in resolved:
|
|
819
|
+
additional_properties = resolved["additionalProperties"]
|
|
820
|
+
has_additional_properties = True
|
|
821
|
+
merged: JsonObject = {
|
|
822
|
+
"type": "object",
|
|
823
|
+
"properties": merged_properties,
|
|
824
|
+
"required": merged_required,
|
|
825
|
+
}
|
|
826
|
+
if has_additional_properties:
|
|
827
|
+
merged["additionalProperties"] = additional_properties
|
|
828
|
+
return merged
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _dedupe(blocks: Iterable[str]) -> list[str]:
|
|
832
|
+
seen: set[str] = set()
|
|
833
|
+
result: list[str] = []
|
|
834
|
+
for block in blocks:
|
|
835
|
+
if block not in seen:
|
|
836
|
+
seen.add(block)
|
|
837
|
+
result.append(block)
|
|
838
|
+
return result
|