pi-codemcp 1.0.0 → 1.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.
@@ -1,16 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import ast
3
4
  import hashlib
4
5
  import json
5
6
  import keyword
7
+ import math
6
8
  import re
9
+ import textwrap
10
+ from collections import Counter
7
11
  from typing import TYPE_CHECKING, Literal
8
12
 
9
13
  from fastmcp.utilities.json_schema_type import json_schema_to_type
10
14
  from mcp import types as mcp_types
11
15
  from pydantic import BaseModel, ConfigDict, TypeAdapter
12
16
  from pydantic_core import to_jsonable_python
13
- from rapidfuzz import fuzz, process, utils
17
+ from rapidfuzz import fuzz
14
18
 
15
19
  from .json_types import (
16
20
  JSON_OBJECT_ADAPTER,
@@ -19,20 +23,24 @@ from .json_types import (
19
23
  JsonSchema,
20
24
  JsonValue,
21
25
  )
22
- from .models import ToolSchemaView
26
+ from .models import SearchDetail, ToolSchemaView
23
27
 
24
28
  if TYPE_CHECKING:
25
29
  from collections.abc import Iterable
26
30
 
27
31
  from .chains import SavedChainManifest
28
32
 
29
- # A 50-point partial match is generic half-string overlap; require evidence above it.
30
- SEARCH_SCORE_CUTOFF = 51
33
+ SEARCH_SCORE_CUTOFF = 20.0
34
+ MIN_PLURAL_TOKEN_LENGTH = 4
31
35
  STUB_IMPORTS = "from typing import Literal, Never, NotRequired, TypeAlias, TypedDict"
32
36
  JSON_TYPE_STUBS = (
33
37
  "JsonScalar: TypeAlias = bool | int | float | str | None",
34
38
  'JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]',
35
39
  )
40
+ INSPECT_JSON_STUB = (
41
+ "def inspect_json(value: JsonValue, *, samples: int = 2, max_depth: int = 3) -> JsonValue: ..."
42
+ )
43
+ STUB_PRELUDE = "\n\n".join([STUB_IMPORTS, *JSON_TYPE_STUBS, INSPECT_JSON_STUB])
36
44
 
37
45
 
38
46
  class ToolSpec(BaseModel):
@@ -54,6 +62,7 @@ class ToolSpec(BaseModel):
54
62
  signature: str
55
63
  stub: str
56
64
  search_blob: str
65
+ search_fields: dict[str, str]
57
66
  input_adapter: TypeAdapter[object]
58
67
  kind: Literal["mcp_tool", "saved_chain"] = "mcp_tool"
59
68
  chain_id: str | None = None
@@ -69,6 +78,7 @@ class ToolCatalog(BaseModel):
69
78
  fingerprint: str
70
79
  tools: dict[str, ToolSpec]
71
80
  type_stubs: str
81
+ stub_prelude: str
72
82
  servers: tuple[str, ...]
73
83
  server_aliases: dict[str, str]
74
84
  facade_calls: dict[tuple[str, str], str]
@@ -240,7 +250,7 @@ class ToolCatalog(BaseModel):
240
250
  input_type_name=input_type,
241
251
  output_type_name=output_type,
242
252
  signature=signature,
243
- stub="\n\n".join([*JSON_TYPE_STUBS, *tool_definitions]),
253
+ stub="\n\n".join(tool_definitions),
244
254
  search_blob=_build_search_blob(
245
255
  public_name,
246
256
  call,
@@ -249,6 +259,14 @@ class ToolCatalog(BaseModel):
249
259
  tool.description,
250
260
  input_schema,
251
261
  ),
262
+ search_fields=_build_search_fields(
263
+ public_name,
264
+ call,
265
+ server,
266
+ tool.name,
267
+ tool.description,
268
+ input_schema,
269
+ ),
252
270
  input_adapter=_schema_adapter(input_schema),
253
271
  kind=kind,
254
272
  chain_id=chain_id,
@@ -284,8 +302,7 @@ class ToolCatalog(BaseModel):
284
302
  ))
285
303
 
286
304
  type_stubs = "\n\n".join([
287
- STUB_IMPORTS,
288
- *JSON_TYPE_STUBS,
305
+ STUB_PRELUDE,
289
306
  *_dedupe(definitions),
290
307
  *facade_stubs,
291
308
  ])
@@ -293,6 +310,7 @@ class ToolCatalog(BaseModel):
293
310
  fingerprint=fingerprint,
294
311
  tools=specs,
295
312
  type_stubs=type_stubs,
313
+ stub_prelude=STUB_PRELUDE,
296
314
  servers=server_names,
297
315
  server_aliases=server_aliases,
298
316
  facade_calls=facade_calls,
@@ -304,32 +322,129 @@ class ToolCatalog(BaseModel):
304
322
  limit: int = 5,
305
323
  *,
306
324
  server: str | None = None,
325
+ detail: SearchDetail = "signatures",
326
+ offset: int = 0,
307
327
  ) -> list[ToolSchemaView]:
308
- candidates = {
309
- spec.name: spec.search_blob
328
+ ranked = self._ranked(query, server=server)
329
+ return [
330
+ _tool_view(spec, detail=detail, score=score, matched_fields=matched_fields)
331
+ for spec, score, matched_fields in ranked[offset : offset + limit]
332
+ ]
333
+
334
+ def _ranked(
335
+ self,
336
+ query: str,
337
+ *,
338
+ server: str | None = None,
339
+ ) -> list[tuple[ToolSpec, float, list[str]]]:
340
+ candidates = [
341
+ spec
310
342
  for spec in sorted(self.tools.values(), key=lambda item: item.name)
311
343
  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,
344
+ ]
345
+ if not candidates:
346
+ return []
347
+ query_tokens = _search_tokens(query)
348
+ normalized_query = " ".join(query_tokens)
349
+ document_tokens = [_search_tokens(spec.search_blob) for spec in candidates]
350
+ document_frequency = Counter(token for tokens in document_tokens for token in set(tokens))
351
+ average_length = sum(map(len, document_tokens)) / max(1, len(document_tokens))
352
+ scored: list[tuple[ToolSpec, float, list[str]]] = []
353
+ for spec, tokens in zip(candidates, document_tokens, strict=True):
354
+ exact = _normalized_identifier(query) in {
355
+ _normalized_identifier(spec.name),
356
+ _normalized_identifier(spec.call),
357
+ _normalized_identifier(spec.short_name),
358
+ }
359
+ bm25 = _bm25_score(
360
+ query_tokens,
361
+ tokens,
362
+ document_frequency,
363
+ len(candidates),
364
+ average_length,
330
365
  )
331
- for _, _, name in ranked
366
+ coverage = (
367
+ len(set(query_tokens) & set(tokens)) / len(set(query_tokens))
368
+ if query_tokens
369
+ else 0.0
370
+ )
371
+ fuzzy_score = fuzz.token_set_ratio(normalized_query, spec.search_blob) / 100
372
+ server_match = bool(set(query_tokens) & set(_search_tokens(spec.server)))
373
+ score = (
374
+ 100.0
375
+ if exact
376
+ else min(
377
+ 99.0,
378
+ bm25 * 12 + coverage * 45 + fuzzy_score * 20 + (20 if server_match else 0),
379
+ )
380
+ )
381
+ if score < SEARCH_SCORE_CUTOFF:
382
+ continue
383
+ matched_fields = [
384
+ field
385
+ for field, value in spec.search_fields.items()
386
+ if set(query_tokens) & set(_search_tokens(value))
387
+ ]
388
+ scored.append((spec, score, matched_fields))
389
+ return sorted(scored, key=lambda item: (-item[1], item[0].name))
390
+
391
+ def inventory(
392
+ self,
393
+ *,
394
+ server: str | None = None,
395
+ detail: SearchDetail = "names",
396
+ offset: int = 0,
397
+ limit: int = 20,
398
+ ) -> list[ToolSchemaView]:
399
+ candidates = [
400
+ spec
401
+ for spec in sorted(self.tools.values(), key=lambda item: item.call)
402
+ if server is None or spec.server == server
332
403
  ]
404
+ return [_tool_view(spec, detail=detail) for spec in candidates[offset : offset + limit]]
405
+
406
+ def inspect(self, calls: list[str]) -> list[ToolSchemaView]:
407
+ by_identifier = {
408
+ identifier: spec
409
+ for spec in self.tools.values()
410
+ for identifier in (spec.name, spec.call)
411
+ }
412
+ unknown = [call for call in calls if call not in by_identifier]
413
+ if unknown:
414
+ suggestions = {
415
+ call: [
416
+ match[0]
417
+ for match in sorted(
418
+ ((spec.call, fuzz.ratio(call, spec.call)) for spec in self.tools.values()),
419
+ key=lambda item: (-item[1], item[0]),
420
+ )[:3]
421
+ ]
422
+ for call in unknown
423
+ }
424
+ raise ValueError(f"Unknown MCP calls: {unknown}; suggestions: {suggestions}")
425
+ return [_tool_view(by_identifier[call], detail="full") for call in dict.fromkeys(calls)]
426
+
427
+ def type_stubs_for(self, public_names: set[str], *, include: str | None = None) -> str:
428
+ names = set(public_names)
429
+ if include is not None:
430
+ names.add(include)
431
+ specs = [self.tools[name] for name in sorted(names) if name in self.tools]
432
+ facade_methods: dict[str, list[str]] = {}
433
+ definitions: list[str] = []
434
+ for spec in specs:
435
+ definitions.extend(spec.stub.split("\n\n") if spec.stub else [])
436
+ facade_methods.setdefault(spec.namespace, []).append(
437
+ f" async def {spec.method}(self, arguments: {spec.input_type_name}) "
438
+ f"-> {spec.output_type_name}: ..."
439
+ )
440
+ facades: list[str] = []
441
+ for namespace in sorted(facade_methods):
442
+ class_name = f"_{_pascal_case(namespace)}Sdk"
443
+ facades.extend((
444
+ "\n".join([f"class {class_name}:", *facade_methods[namespace]]),
445
+ f"{namespace}: {class_name}",
446
+ ))
447
+ return "\n\n".join([STUB_PRELUDE, *_dedupe(definitions), *facades])
333
448
 
334
449
  def counts_by_server(self) -> dict[str, int]:
335
450
  counts = dict.fromkeys(self.servers, 0)
@@ -389,13 +504,15 @@ class ToolCatalog(BaseModel):
389
504
  if wrap_from_meta and not spec.output_wrap_result:
390
505
  adapter = spec.wrapped_output_adapter
391
506
  if adapter is None:
392
- return _normalize_json_value(
393
- JSON_VALUE_ADAPTER.validate_python(to_jsonable_python(structured))
507
+ normalized = JSON_VALUE_ADAPTER.validate_python(to_jsonable_python(structured))
508
+ else:
509
+ validated = adapter.validate_python(structured)
510
+ normalized = JSON_VALUE_ADAPTER.validate_python(
511
+ adapter.dump_python(validated, mode="json")
394
512
  )
395
- validated = adapter.validate_python(structured)
396
- return _normalize_json_value(
397
- JSON_VALUE_ADAPTER.validate_python(adapter.dump_python(validated, mode="json"))
398
- )
513
+ if isinstance(normalized, str) and (spec.output_wrap_result or wrap_from_meta):
514
+ return _normalize_text_result(normalized)
515
+ return normalized
399
516
 
400
517
  if result.structuredContent is not None:
401
518
  return _normalize_json_value(
@@ -497,11 +614,13 @@ class StubBuilder:
497
614
  output_schema = _as_schema(_object_property(self.output_schema, "result"))
498
615
  if (
499
616
  self.normalize_json_string_output
617
+ and self.output_schema
618
+ and self.output_schema.get("x-fastmcp-wrap-result")
500
619
  and isinstance(output_schema, dict)
501
620
  and output_schema.get("type") == "string"
502
621
  ):
503
- # Runtime normalization promotes JSON object/array text to native values.
504
- # A plain `str` annotation would therefore be a false guarantee.
622
+ # FastMCP-wrapped tools often expose JSON payloads as a `result` string.
623
+ # The runtime intentionally unwraps and parses that top-level string.
505
624
  output_schema = True
506
625
  output_type = self._ensure_named_type(
507
626
  f"{_pascal_case(self.tool_name)}Result",
@@ -644,7 +763,90 @@ class StubBuilder:
644
763
  self._active_refs.remove(ref)
645
764
 
646
765
 
647
- def _short_description(description: str | None, limit: int = 240) -> str | None:
766
+ def schema_path_summary(schema: JsonObject, *, limit: int = 20) -> list[str]:
767
+ paths: list[str] = []
768
+
769
+ def visit(value: JsonSchema, path: str, *, required: bool) -> None:
770
+ if len(paths) >= limit:
771
+ return
772
+ label = _schema_type_label(value)
773
+ paths.append(f"{path}: {label}{' (required)' if required else ' (optional)'}")
774
+ if not isinstance(value, dict):
775
+ return
776
+ raw_properties = value.get("properties")
777
+ properties = raw_properties if isinstance(raw_properties, dict) else {}
778
+ raw_required = value.get("required")
779
+ required_names = (
780
+ {item for item in raw_required if isinstance(item, str)}
781
+ if isinstance(raw_required, list)
782
+ else set()
783
+ )
784
+ for name, child in properties.items():
785
+ if isinstance(child, (dict, bool)):
786
+ visit(child, f"{path}.{name}", required=name in required_names)
787
+ items = value.get("items")
788
+ if isinstance(items, (dict, bool)):
789
+ visit(items, f"{path}[]", required=True)
790
+
791
+ visit(schema, "$", required=True)
792
+ if len(paths) >= limit:
793
+ paths.append(f"<limited to {limit} paths>")
794
+ return paths
795
+
796
+
797
+ def referenced_calls(code: str, facade_calls: dict[tuple[str, str], str]) -> set[str]:
798
+ normalized = textwrap.dedent(code).strip("\n")
799
+ wrapped = f"async def __codemcp_main():\n{textwrap.indent(normalized, ' ')}\n"
800
+ try:
801
+ tree = ast.parse(wrapped, mode="exec")
802
+ except SyntaxError:
803
+ return set()
804
+ referenced: set[str] = set()
805
+ for node in ast.walk(tree):
806
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
807
+ continue
808
+ if not isinstance(node.func.value, ast.Name):
809
+ continue
810
+ public_name = facade_calls.get((node.func.value.id, node.func.attr))
811
+ if public_name is not None:
812
+ referenced.add(public_name)
813
+ return referenced
814
+
815
+
816
+ def _tool_view(
817
+ spec: ToolSpec,
818
+ *,
819
+ detail: SearchDetail,
820
+ score: float | None = None,
821
+ matched_fields: list[str] | None = None,
822
+ ) -> ToolSchemaView:
823
+ return ToolSchemaView(
824
+ name=spec.name,
825
+ call=spec.call,
826
+ source=spec.kind,
827
+ server=spec.server,
828
+ description=_short_description(spec.description),
829
+ signature=spec.signature if detail in {"signatures", "full"} else None,
830
+ stub=spec.stub if detail == "full" else None,
831
+ score=score,
832
+ matched_fields=matched_fields or [],
833
+ )
834
+
835
+
836
+ def _schema_type_label(schema: JsonSchema) -> str:
837
+ if isinstance(schema, bool):
838
+ return "any JSON value" if schema else "never"
839
+ raw_type = schema.get("type")
840
+ if isinstance(raw_type, str):
841
+ return raw_type
842
+ if isinstance(raw_type, list):
843
+ return " | ".join(str(item) for item in raw_type)
844
+ if "oneOf" in schema or "anyOf" in schema:
845
+ return "union"
846
+ return "JSON value"
847
+
848
+
849
+ def _short_description(description: str | None, limit: int = 72) -> str | None:
648
850
  if description is None:
649
851
  return None
650
852
  first_paragraph = description.split("\n\n", 1)[0]
@@ -685,6 +887,22 @@ def _field_comment(schema: JsonSchema) -> str | None:
685
887
  return "; ".join(notes) or None
686
888
 
687
889
 
890
+ def _build_search_fields(
891
+ public_name: str,
892
+ call: str,
893
+ server: str,
894
+ short_name: str,
895
+ description: str | None,
896
+ input_schema: JsonObject,
897
+ ) -> dict[str, str]:
898
+ return {
899
+ "name": f"{public_name} {call} {short_name}",
900
+ "server": server,
901
+ "description": description or "",
902
+ "parameters": " ".join(_collect_property_names(input_schema)),
903
+ }
904
+
905
+
688
906
  def _build_search_blob(
689
907
  public_name: str,
690
908
  call: str,
@@ -693,14 +911,59 @@ def _build_search_blob(
693
911
  description: str | None,
694
912
  input_schema: JsonObject,
695
913
  ) -> str:
696
- return " ".join([
697
- public_name,
698
- call,
699
- server,
700
- short_name,
701
- description or "",
702
- *_collect_property_names(input_schema),
703
- ])
914
+ return " ".join(
915
+ _build_search_fields(
916
+ public_name,
917
+ call,
918
+ server,
919
+ short_name,
920
+ description,
921
+ input_schema,
922
+ ).values()
923
+ )
924
+
925
+
926
+ def _search_tokens(value: str) -> list[str]:
927
+ expanded = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", value)
928
+ tokens = [
929
+ token
930
+ for token in re.findall(r"[a-z0-9]+", expanded.lower().replace("_", " "))
931
+ if len(token) > 1
932
+ ]
933
+ return [
934
+ token[:-1] if token.endswith("s") and len(token) >= MIN_PLURAL_TOKEN_LENGTH else token
935
+ for token in tokens
936
+ ]
937
+
938
+
939
+ def _normalized_identifier(value: str) -> str:
940
+ return "".join(_search_tokens(value))
941
+
942
+
943
+ def _bm25_score(
944
+ query_tokens: list[str],
945
+ document_tokens: list[str],
946
+ document_frequency: Counter[str],
947
+ document_count: int,
948
+ average_length: float,
949
+ ) -> float:
950
+ if not query_tokens or not document_tokens:
951
+ return 0.0
952
+ frequencies = Counter(document_tokens)
953
+ score = 0.0
954
+ k1 = 1.2
955
+ b = 0.75
956
+ for token in set(query_tokens):
957
+ frequency = frequencies[token]
958
+ if frequency == 0:
959
+ continue
960
+ frequency_in_documents = document_frequency[token]
961
+ inverse_document_frequency = math.log(
962
+ 1 + (document_count - frequency_in_documents + 0.5) / (frequency_in_documents + 0.5)
963
+ )
964
+ denominator = frequency + k1 * (1 - b + b * len(document_tokens) / max(1.0, average_length))
965
+ score += inverse_document_frequency * frequency * (k1 + 1) / denominator
966
+ return score
704
967
 
705
968
 
706
969
  def _collect_property_names(schema: JsonSchema) -> list[str]:
@@ -816,7 +1079,9 @@ def _merge_all_of(
816
1079
  if isinstance(required, str) and required not in merged_required:
817
1080
  merged_required.append(required)
818
1081
  if "additionalProperties" in resolved:
819
- additional_properties = resolved["additionalProperties"]
1082
+ additional_properties = JSON_VALUE_ADAPTER.validate_python(
1083
+ resolved["additionalProperties"]
1084
+ )
820
1085
  has_additional_properties = True
821
1086
  merged: JsonObject = {
822
1087
  "type": "object",