immich-api 0.2.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.
Files changed (63) hide show
  1. immich_api/__init__.py +54 -0
  2. immich_api/__main__.py +4 -0
  3. immich_api/album_metadata.py +119 -0
  4. immich_api/api/__init__.py +3 -0
  5. immich_api/api/api_client_system.py +7 -0
  6. immich_api/auth.py +94 -0
  7. immich_api/connector_manifest.yml +212 -0
  8. immich_api/connectors/__init__.py +0 -0
  9. immich_api/connectors/mcp_source_presets.json +3 -0
  10. immich_api/connectors/tool_schema_fingerprints.json +1 -0
  11. immich_api/error_authority.py +248 -0
  12. immich_api/error_rendering.py +105 -0
  13. immich_api/immich_input_models.py +12 -0
  14. immich_api/immich_response_models.py +15 -0
  15. immich_api/kg_ingest.py +75 -0
  16. immich_api/mcp/__init__.py +21 -0
  17. immich_api/mcp/album_sample.py +38 -0
  18. immich_api/mcp_config.json +3 -0
  19. immich_api/mcp_server.py +50 -0
  20. immich_api/ontology/__init__.py +6 -0
  21. immich_api/ontology/immich.ttl +119 -0
  22. immich_api/ontology/schema-provenance.json +483 -0
  23. immich_api/ontology/shapes/immich.ttl +69 -0
  24. immich_api/openapi_auth_scheme.py +77 -0
  25. immich_api/openapi_contract.json +80122 -0
  26. immich_api/openapi_execution.py +30 -0
  27. immich_api/openapi_inventory.json +16154 -0
  28. immich_api/openapi_request.py +119 -0
  29. immich_api/openapi_response.py +76 -0
  30. immich_api/openapi_runtime.py +203 -0
  31. immich_api/parity/__init__.py +17 -0
  32. immich_api/parity/check_openapi_parity.py +173 -0
  33. immich_api/parity/openapi_discovery.py +95 -0
  34. immich_api/parity/openapi_parity.py +239 -0
  35. immich_api/parity/openapi_provider_evidence.py +143 -0
  36. immich_api/parity/openapi_provider_overlay.py +239 -0
  37. immich_api/parity/openapi_provider_responses.py +132 -0
  38. immich_api/parity/openapi_schema.py +206 -0
  39. immich_api/parity/openapi_schema_budget.py +104 -0
  40. immich_api/parity/openapi_schema_inputs.py +89 -0
  41. immich_api/parity/openapi_schema_types.py +163 -0
  42. immich_api/parity/openapi_security.py +157 -0
  43. immich_api/parity/openapi_source_parity.py +98 -0
  44. immich_api/parity/openapi_transport_parity.py +169 -0
  45. immich_api/parity/unicode_patterns.py +105 -0
  46. immich_api/parity/unicode_properties.json +4719 -0
  47. immich_api/prompts/__init__.py +0 -0
  48. immich_api/prompts/immich_album_review.json +18 -0
  49. immich_api/prompts/immich_asset_metadata.json +18 -0
  50. immich_api/prompts/immich_person_record.json +18 -0
  51. immich_api/prompts/immich_specialist.json +17 -0
  52. immich_api/prompts/main_agent.json +24 -0
  53. immich_api/skills/__init__.py +0 -0
  54. immich_api/skills/immich-album-review/SKILL.md +17 -0
  55. immich_api/skills/immich-asset-metadata/SKILL.md +17 -0
  56. immich_api/skills/immich-health/SKILL.md +17 -0
  57. immich_api/skills/immich-person-record/SKILL.md +17 -0
  58. immich_api-0.2.0.dist-info/METADATA +242 -0
  59. immich_api-0.2.0.dist-info/RECORD +63 -0
  60. immich_api-0.2.0.dist-info/WHEEL +5 -0
  61. immich_api-0.2.0.dist-info/entry_points.txt +14 -0
  62. immich_api-0.2.0.dist-info/licenses/LICENSE +21 -0
  63. immich_api-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,119 @@
1
+ """Serialize only parameters and body types declared by an OpenAPI operation."""
2
+
3
+ import base64
4
+ import json
5
+ from urllib.parse import quote
6
+
7
+
8
+ def _value(value):
9
+ if isinstance(value, bool):
10
+ return str(value).lower()
11
+ if isinstance(value, (str, int, float)):
12
+ return str(value)
13
+ raise ValueError("Unsupported parameter value")
14
+
15
+
16
+ def _path_parameter(path, name, value):
17
+ rendered = _value(value)
18
+ if rendered in {".", ".."} or any(c in rendered for c in "/\\%\r\n\x00"):
19
+ raise ValueError("Unsafe path parameter")
20
+ return path.replace("{" + name + "}", quote(rendered, safe=""))
21
+
22
+
23
+ def _query_parameter(parameter, value):
24
+ name = parameter["name"]
25
+ if value is None:
26
+ return []
27
+ if not isinstance(value, list):
28
+ return [(name, _value(value))]
29
+ rendered = [_value(item) for item in value]
30
+ if parameter.get("explode", True):
31
+ return [(name, item) for item in rendered]
32
+ return [(name, ",".join(rendered))]
33
+
34
+
35
+ def _header_parameter(name, value, credential_headers):
36
+ if name.casefold() in credential_headers:
37
+ raise ValueError("Authentication headers are controlled by credentials")
38
+ rendered = _value(value)
39
+ if any(c in rendered for c in "\r\n\x00"):
40
+ raise ValueError("Invalid header parameter")
41
+ return rendered
42
+
43
+
44
+ def parameters(operation, arguments, *, credential_headers):
45
+ path, query, headers = operation["path"], [], {}
46
+ for parameter in operation["parameters"]:
47
+ name, location = parameter["name"], parameter["in"]
48
+ values = arguments.get(location, {})
49
+ if name not in values:
50
+ continue
51
+ value = values[name]
52
+ if location == "path":
53
+ path = _path_parameter(path, name, value)
54
+ elif location == "query":
55
+ query.extend(_query_parameter(parameter, value))
56
+ elif location == "header":
57
+ headers[name] = _header_parameter(name, value, credential_headers)
58
+ if "{" in path or "}" in path:
59
+ raise ValueError("Missing path parameter")
60
+ return path, query, headers
61
+
62
+
63
+ def _multipart(body, body_schema, *, definitions, max_bytes):
64
+ while "$ref" in body_schema:
65
+ body_schema = definitions[body_schema["$ref"].rsplit("/", 1)[1]]
66
+ files, data = {}, {}
67
+ for key, value in body.items():
68
+ field = body_schema.get("properties", {}).get(key, {})
69
+ if field.get("contentEncoding") == "base64":
70
+ decoded = _decode_upload(value, max_bytes)
71
+ files[key] = ("upload", decoded, "application/octet-stream")
72
+ else:
73
+ data[key] = (
74
+ json.dumps(value, separators=(",", ":"))
75
+ if isinstance(value, (dict, list))
76
+ else _value(value)
77
+ )
78
+ return _multipart_options(files, data)
79
+
80
+
81
+ def _multipart_options(files, data):
82
+ # HTTPX selects form encoding when files is empty. Optional file fields
83
+ # must not silently change the operation's declared multipart media type.
84
+ if files:
85
+ return {"files": files, "data": data}
86
+ if data:
87
+ return {"files": {key: (None, value) for key, value in data.items()}}
88
+ return {
89
+ "content": b"--mcp-empty-body--\r\n",
90
+ "headers": {"Content-Type": "multipart/form-data; boundary=mcp-empty-body"},
91
+ }
92
+
93
+
94
+ def body_options(operation, body, *, definitions, max_bytes):
95
+ if len(json.dumps(body).encode()) > max_bytes:
96
+ raise ValueError("Request size limit exceeded")
97
+ media = operation["content_type"]
98
+ if media == "application/json":
99
+ return {"json": body}
100
+ if media == "application/x-www-form-urlencoded":
101
+ return {"data": body}
102
+ if media == "multipart/form-data":
103
+ return _multipart(
104
+ body,
105
+ operation["input"]["properties"]["body"],
106
+ definitions=definitions,
107
+ max_bytes=max_bytes,
108
+ )
109
+ raise ValueError("Unsupported operation request media type")
110
+
111
+
112
+ def _decode_upload(value, max_bytes):
113
+ try:
114
+ decoded = base64.b64decode(value, validate=True)
115
+ except (ValueError, TypeError) as exc:
116
+ raise ValueError("Invalid base64 upload") from exc
117
+ if len(decoded) > max_bytes:
118
+ raise ValueError("Upload size limit exceeded")
119
+ return decoded
@@ -0,0 +1,76 @@
1
+ """Bound response bytes and decode only declared success representations."""
2
+
3
+ import base64
4
+ import json
5
+ import time
6
+
7
+ from rfc3986_validator import validate_rfc3986
8
+
9
+
10
+ def require_response_status(operation, status):
11
+ if 200 <= status < 300 or str(status) in operation.get("redirect_responses", {}):
12
+ return
13
+ raise RuntimeError(f"Provider returned HTTP {status}; no automatic retry")
14
+
15
+
16
+ def _redirect_data(response):
17
+ values = response.headers.get_list("location")
18
+ if len(values) != 1:
19
+ raise ValueError("Redirect requires exactly one Location header")
20
+ location = values[0]
21
+ if not location.isprintable() or not validate_rfc3986(location, rule="URI"):
22
+ raise ValueError(
23
+ "Redirect Location must be an absolute URI without unsafe characters"
24
+ )
25
+ return "", {"location": location}
26
+
27
+
28
+ def read_body(response, max_bytes, *, deadline):
29
+ body = bytearray()
30
+ for chunk in response.iter_bytes():
31
+ if time.monotonic() > deadline or len(body) + len(chunk) > max_bytes:
32
+ raise RuntimeError("Provider response limit exceeded")
33
+ body.extend(chunk)
34
+ return body
35
+
36
+
37
+ def _decode_json(body):
38
+ try:
39
+ return json.loads(body)
40
+ except (ValueError, UnicodeDecodeError) as exc:
41
+ raise ValueError("Provider response is not JSON") from exc
42
+
43
+
44
+ def _decode_text(body, encoding):
45
+ try:
46
+ return body.decode(encoding or "utf-8")
47
+ except (UnicodeDecodeError, LookupError) as exc:
48
+ raise ValueError("Provider text response cannot be decoded") from exc
49
+
50
+
51
+ def decode_body(operation, response, body):
52
+ status = str(response.status_code)
53
+ if status in operation.get("redirect_responses", {}):
54
+ return _redirect_data(response)
55
+ return _declared_body(operation, response, body)
56
+
57
+
58
+ def _declared_body(operation, response, body):
59
+ status = str(response.status_code)
60
+ expected = operation["responses"].get(status)
61
+ if expected is None:
62
+ raise ValueError("Undeclared provider success status")
63
+ if not expected:
64
+ if body:
65
+ raise ValueError("Unexpected provider response body")
66
+ return "", None
67
+ content_type = response.headers.get("content-type", "").split(";", 1)[0].strip()
68
+ if content_type not in expected:
69
+ raise ValueError("Undeclared provider response media type")
70
+ mode = operation["response_modes"][status][content_type]
71
+ decoders = {
72
+ "json": lambda: _decode_json(body),
73
+ "text": lambda: _decode_text(body, response.charset_encoding),
74
+ }
75
+ data = decoders.get(mode, lambda: base64.b64encode(body).decode())()
76
+ return content_type, data
@@ -0,0 +1,203 @@
1
+ """Generated operation-bound vendor client and SDK MCP tool registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import regex
11
+ from agent_connector_sdk.config import setting
12
+ from agent_connector_sdk.http.client import create_http_client
13
+ from agent_connector_sdk.http.options import HttpClientOptions
14
+ from agent_connector_sdk.http.retry import RetryPolicy
15
+ from agent_connector_sdk.mcp.concurrency import run_blocking
16
+ from fastmcp.tools import Tool, ToolResult
17
+ from jsonschema import Draft202012Validator, FormatChecker
18
+ from jsonschema.exceptions import ValidationError
19
+ from jsonschema.validators import extend
20
+ from mcp.types import InputRequiredResult, ToolAnnotations
21
+ from pydantic import Field
22
+
23
+ from .openapi_auth_scheme import credential_headers
24
+ from .openapi_execution import confirm_write, require_origin
25
+ from .openapi_request import body_options, parameters
26
+ from .openapi_response import decode_body, read_body, require_response_status
27
+ from .parity.openapi_discovery import configure_discovery
28
+
29
+ CONTRACT = json.loads(Path(__file__).with_name("openapi_contract.json").read_text())
30
+ MAX_BYTES = 16 * 1024 * 1024
31
+
32
+
33
+ def _pattern(*arguments):
34
+ """Adapt the four-argument jsonschema keyword callback."""
35
+ _validator, pattern, instance, _schema = arguments
36
+ if (
37
+ isinstance(instance, str)
38
+ and regex.search(pattern, instance, timeout=0.1) is None
39
+ ):
40
+ yield ValidationError("String does not satisfy its pattern")
41
+
42
+
43
+ def _format(*arguments):
44
+ """Require actual format assertions; retain OpenAPI representation annotations."""
45
+ validator, name, _instance, _schema = arguments
46
+ if name in {"int32", "int64", "float", "double", "binary", "byte", "password"}:
47
+ return
48
+ checker = validator.format_checker
49
+ if checker is None or name not in checker.checkers:
50
+ raise RuntimeError(f"Required OpenAPI format checker is unavailable: {name}")
51
+ yield from Draft202012Validator.VALIDATORS["format"](*arguments)
52
+
53
+
54
+ SchemaValidator = extend(Draft202012Validator, {"pattern": _pattern, "format": _format})
55
+
56
+
57
+ def validate(value, definition):
58
+ """Validate without disclosing caller input or provider response values."""
59
+ validator = SchemaValidator(definition, format_checker=FormatChecker())
60
+ if next(validator.iter_errors(value), None) is not None:
61
+ raise ValueError("Value does not satisfy the declared OpenAPI schema")
62
+
63
+
64
+ class OpenApiClient:
65
+ """Dispatch only catalogued actions; perform each HTTP request exactly once."""
66
+
67
+ def __init__(
68
+ self,
69
+ base_url,
70
+ auth,
71
+ tls_profile=None,
72
+ *,
73
+ credential_scheme=None,
74
+ write_mode="read-only",
75
+ allow_plaintext=False,
76
+ transport=None,
77
+ ):
78
+ if write_mode not in {"read-only", "read-write"}:
79
+ raise ValueError("Unsupported OpenAPI write mode")
80
+ require_origin(
81
+ base_url,
82
+ credential_scheme=credential_scheme,
83
+ allow_plaintext=allow_plaintext,
84
+ )
85
+ self.session = create_http_client(
86
+ HttpClientOptions(
87
+ base_url=base_url,
88
+ auth=auth,
89
+ tls=tls_profile,
90
+ retry=RetryPolicy(max_attempts=1),
91
+ allow_plaintext=allow_plaintext,
92
+ timeout=30.0,
93
+ ),
94
+ transport=transport,
95
+ )
96
+ self.credential_scheme = credential_scheme
97
+ self.allow_writes = write_mode == "read-write"
98
+
99
+ def close(self):
100
+ self.session.close()
101
+
102
+ def execute(self, domain: str, arguments: dict[str, Any]):
103
+ if domain not in CONTRACT["domains"]:
104
+ raise ValueError("Unknown domain")
105
+ validate(arguments, CONTRACT["schemas"][domain][0])
106
+ operation = CONTRACT["operations"][arguments["action"]]
107
+ if operation["domain"] != domain:
108
+ raise ValueError("Action is outside this domain")
109
+ if not operation["read_only"] and not self.allow_writes:
110
+ raise PermissionError("Writes require explicit operator enablement")
111
+ self._require_credentials(operation["security"])
112
+ path, query, headers = parameters(
113
+ operation,
114
+ arguments,
115
+ credential_headers=credential_headers(CONTRACT["security_schemes"]),
116
+ )
117
+ target = self.session.base_url.join(path)
118
+ origin = self.session.base_url
119
+ if (target.scheme, target.host, target.port) != (
120
+ origin.scheme,
121
+ origin.host,
122
+ origin.port,
123
+ ):
124
+ raise ValueError(
125
+ "Operation must stay within the configured provider origin"
126
+ )
127
+ options = {"params": query, "headers": headers, "follow_redirects": False}
128
+ if "body" in arguments:
129
+ body = body_options(
130
+ operation,
131
+ arguments["body"],
132
+ definitions=CONTRACT["definitions"],
133
+ max_bytes=MAX_BYTES,
134
+ )
135
+ options["headers"].update(body.pop("headers", {}))
136
+ options.update(body)
137
+ return self._send(operation, path, options)
138
+
139
+ def _require_credentials(self, requirements):
140
+ if requirements and not any(
141
+ not choice or set(choice) == {self.credential_scheme}
142
+ for choice in requirements
143
+ ):
144
+ raise PermissionError("Action requires a supported credential reference")
145
+
146
+ def _send(self, operation, path, options):
147
+ deadline = time.monotonic() + 60
148
+ with self.session.stream(operation["method"], path, **options) as response:
149
+ require_response_status(operation, response.status_code)
150
+ body = read_body(response, MAX_BYTES, deadline=deadline)
151
+ content_type, data = decode_body(operation, response, body)
152
+ result = {
153
+ "action": operation["action"],
154
+ "status": response.status_code,
155
+ "content_type": content_type,
156
+ "data": data,
157
+ }
158
+ validate(result, CONTRACT["schemas"][operation["domain"]][1])
159
+ return result
160
+
161
+
162
+ class OpenApiTool(Tool):
163
+ """One domain, discriminated action inputs, and declared response variants."""
164
+
165
+ domain: str = Field(exclude=True)
166
+ client_factory: Any = Field(exclude=True)
167
+
168
+ async def run(self, arguments):
169
+ validate(arguments, self.parameters)
170
+ operation = CONTRACT["operations"][arguments["action"]]
171
+ client = self.client_factory()
172
+ confirmation = await confirm_write(operation, arguments, client)
173
+ if isinstance(confirmation, InputRequiredResult):
174
+ return confirmation
175
+ result = await run_blocking(client.execute, self.domain, arguments)
176
+ return ToolResult(structured_content=result)
177
+
178
+
179
+ def register_domain(mcp, domain, client_factory):
180
+ configure_discovery(mcp, setting("IMMICH_API_MCP_CLIENT_BUDGET"))
181
+ operations = [
182
+ CONTRACT["operations"][action] for action in CONTRACT["domains"][domain]
183
+ ]
184
+ read_only = all(operation["read_only"] for operation in operations)
185
+ inputs, outputs = CONTRACT["schemas"][domain]
186
+ mcp.add_tool(
187
+ OpenApiTool(
188
+ name=f"{domain}_operations",
189
+ domain=domain,
190
+ client_factory=client_factory,
191
+ description="Bound OpenAPI operations: "
192
+ + ", ".join(CONTRACT["domains"][domain]),
193
+ parameters=inputs,
194
+ output_schema=outputs,
195
+ tags={domain},
196
+ annotations=ToolAnnotations(
197
+ readOnlyHint=read_only,
198
+ destructiveHint=not read_only,
199
+ idempotentHint=False,
200
+ openWorldHint=True,
201
+ ),
202
+ )
203
+ )
@@ -0,0 +1,17 @@
1
+ """Self-contained generated API/MCP parity checks; no sibling builder required."""
2
+
3
+ from .openapi_parity import (
4
+ operation_inventory,
5
+ require_complete_coverage,
6
+ verify_contract,
7
+ verify_mcp,
8
+ )
9
+ from .openapi_transport_parity import verify_transport
10
+
11
+ __all__ = [
12
+ "operation_inventory",
13
+ "require_complete_coverage",
14
+ "verify_contract",
15
+ "verify_mcp",
16
+ "verify_transport",
17
+ ]
@@ -0,0 +1,173 @@
1
+ """Verify a pinned source, generated inventory and API/native MCP contract parity.
2
+
3
+ This invokes native listing and schema search only. It does not prove transport execution or call a
4
+ provider; exhaustive mocked dispatch and live endpoint checks are separate.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import hashlib
12
+ import importlib
13
+ import json
14
+ import sys
15
+ from contextlib import contextmanager
16
+ from pathlib import Path
17
+
18
+ if __package__:
19
+ from .openapi_discovery import inspect_discovery
20
+ from .openapi_parity import (
21
+ require_declared_authorization,
22
+ verify_contract,
23
+ verify_mcp,
24
+ )
25
+ from .openapi_schema_budget import client_budget as validate_budget
26
+ else:
27
+ from openapi_discovery import inspect_discovery
28
+ from openapi_parity import (
29
+ require_declared_authorization,
30
+ verify_contract,
31
+ verify_mcp,
32
+ )
33
+ from openapi_schema_budget import client_budget as validate_budget
34
+
35
+
36
+ def package_modules(package_name):
37
+ return [
38
+ name
39
+ for name in sys.modules
40
+ if name == package_name or name.startswith(package_name + ".")
41
+ ]
42
+
43
+
44
+ @contextmanager
45
+ def project_imports(package):
46
+ """Check the requested tree even when another package has the same name."""
47
+ previous = {name: sys.modules[name] for name in package_modules(package.name)}
48
+ search_path = list(sys.path)
49
+ for name in previous:
50
+ del sys.modules[name]
51
+ sys.path.insert(0, str(package.parent))
52
+ try:
53
+ yield
54
+ finally:
55
+ for name in package_modules(package.name):
56
+ del sys.modules[name]
57
+ sys.modules.update(previous)
58
+ sys.path[:] = search_path
59
+
60
+
61
+ async def native_tools(package, contract, budget=None, *, ready=False):
62
+ try:
63
+ from fastmcp import Client
64
+ except ImportError as exc:
65
+ raise RuntimeError(
66
+ "Native parity requires the connector's existing environment"
67
+ ) from exc
68
+ with project_imports(package):
69
+ server = importlib.import_module(f"{package.name}.mcp_server")
70
+ instance = server.build_server(command_args=["--transport", "stdio"])
71
+ if ready and getattr(
72
+ instance, "_openapi_schema_budget", None
73
+ ) != validate_budget(budget):
74
+ raise ValueError(
75
+ "Ready requires the matching client budget to be active in the normal runtime configuration"
76
+ )
77
+ if budget is not None:
78
+ return await inspect_discovery(
79
+ instance, [f"{name}_operations" for name in contract["domains"]], budget
80
+ )
81
+ async with Client(instance) as client:
82
+ return await asyncio.wait_for(client.list_tools(), timeout=45), None
83
+
84
+
85
+ def check(
86
+ spec,
87
+ package,
88
+ *,
89
+ native=False,
90
+ mode="ready",
91
+ client_budget=None,
92
+ overlay_path=None,
93
+ expected_overlay_sha256=None,
94
+ evidence_root=None,
95
+ ):
96
+ if mode not in ("ready", "inspect"):
97
+ raise ValueError("Unknown readiness mode")
98
+ ready = mode == "ready"
99
+ source = spec.read_bytes()
100
+ contract = json.loads((package / "openapi_contract.json").read_text())
101
+ inventory = verify_contract(
102
+ json.loads(source),
103
+ contract,
104
+ hashlib.sha256(source).hexdigest(),
105
+ source_bytes=source,
106
+ overlay_bytes=overlay_path.read_bytes() if overlay_path else None,
107
+ expected_overlay_sha256=expected_overlay_sha256,
108
+ evidence_root=evidence_root,
109
+ )
110
+ saved = json.loads((package / "openapi_inventory.json").read_text())
111
+ if saved != inventory:
112
+ raise ValueError("Stale or incomplete operation inventory")
113
+ if ready:
114
+ require_declared_authorization(inventory)
115
+ if not native or client_budget is None:
116
+ raise ValueError(
117
+ "Ready requires native validation and an explicit named client budget"
118
+ )
119
+ result = {
120
+ "source_sha256": inventory["source_sha256"],
121
+ "path_count": inventory["path_count"],
122
+ "api_contract_operations": inventory["operation_count"],
123
+ "mocked_transport_verified": False,
124
+ "live_endpoint_verified": False,
125
+ "ready": False,
126
+ "readiness_scope": "Source, authorization evidence and budgeted native schemas; excludes transport/live acceptance",
127
+ }
128
+ if native:
129
+ tools, disclosure = asyncio.run(
130
+ native_tools(package, contract, client_budget, ready=ready)
131
+ )
132
+ result["native_mcp"] = verify_mcp(contract, tools)
133
+ result["disclosure"] = disclosure
134
+ result["ready"] = ready
135
+ else:
136
+ result["native_mcp"] = "not checked"
137
+ return result
138
+
139
+
140
+ def main():
141
+ parser = argparse.ArgumentParser(description=__doc__)
142
+ parser.add_argument("spec", type=Path)
143
+ parser.add_argument("package", type=Path)
144
+ parser.add_argument("--native", action="store_true")
145
+ parser.add_argument(
146
+ "--inspect", action="store_true", help="Inspect without claiming readiness"
147
+ )
148
+ parser.add_argument("--client-budget", type=Path)
149
+ parser.add_argument("--provider-overlay", type=Path)
150
+ parser.add_argument("--expected-overlay-sha256")
151
+ parser.add_argument("--evidence-root", type=Path)
152
+ args = parser.parse_args()
153
+ print(
154
+ json.dumps(
155
+ check(
156
+ args.spec.resolve(),
157
+ args.package.resolve(),
158
+ native=args.native,
159
+ mode="inspect" if args.inspect else "ready",
160
+ client_budget=args.client_budget,
161
+ overlay_path=args.provider_overlay,
162
+ expected_overlay_sha256=args.expected_overlay_sha256,
163
+ evidence_root=args.evidence_root,
164
+ ),
165
+ indent=2,
166
+ sort_keys=True,
167
+ ),
168
+ file=sys.stderr,
169
+ )
170
+
171
+
172
+ if __name__ == "__main__":
173
+ main()
@@ -0,0 +1,95 @@
1
+ """Budgeted disclosure using FastMCP's existing search transform and call path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+
8
+ if __package__:
9
+ from .openapi_schema_budget import client_budget, require_schema_budget, wire_tool
10
+ else:
11
+ from openapi_schema_budget import client_budget, require_schema_budget, wire_tool
12
+
13
+
14
+ def enforce_disclosure(tools, budget, *, kind="listing"):
15
+ """Keep a budget failure explicit on the native JSON-RPC error path."""
16
+ from mcp.shared.exceptions import MCPError
17
+ from mcp.types import INTERNAL_ERROR
18
+
19
+ try:
20
+ return require_schema_budget(tools, budget, kind=kind)
21
+ except ValueError as exc:
22
+ raise MCPError(INTERNAL_ERROR, str(exc)) from exc
23
+
24
+
25
+ def configure_discovery(server, budget=None):
26
+ """Retain all tools and policies; bound listing and exact search responses."""
27
+ if budget is None:
28
+ return
29
+ budget = client_budget(budget)
30
+ previous = getattr(server, "_openapi_schema_budget", None)
31
+ if previous is not None:
32
+ if previous != budget:
33
+ raise ValueError("Cannot replace a server's configured client budget")
34
+ return
35
+ from fastmcp.server.transforms import Transform
36
+ from fastmcp.server.transforms.search import RegexSearchTransform
37
+
38
+ def serialize(tools):
39
+ values = [wire_tool(tool) for tool in tools]
40
+ enforce_disclosure(values, budget, kind="search_result")
41
+ return values
42
+
43
+ class Search(RegexSearchTransform):
44
+ async def transform_tools(self, tools):
45
+ result = await super().transform_tools(tools)
46
+ enforce_disclosure(result, budget)
47
+ return result
48
+
49
+ class Eager(Transform):
50
+ async def list_tools(self, tools):
51
+ enforce_disclosure(tools, budget)
52
+ return tools
53
+
54
+ transform = (
55
+ Search(max_results=1, search_result_serializer=serialize)
56
+ if budget["discovery"] == "regex-search"
57
+ else Eager()
58
+ )
59
+ server.add_transform(transform)
60
+ server._openapi_schema_budget = budget
61
+
62
+
63
+ async def inspect_discovery(server, names, budget):
64
+ """Use native listing/search to measure and retrieve every expected schema."""
65
+ from fastmcp import Client
66
+
67
+ budget = client_budget(budget)
68
+ configure_discovery(server, budget)
69
+ async with Client(server) as client:
70
+ listed = await asyncio.wait_for(client.list_tools(), timeout=45)
71
+ receipt = {"initial": require_schema_budget(listed, budget), "retrievals": {}}
72
+ if budget["discovery"] == "eager":
73
+ return listed, receipt
74
+ tools = []
75
+ for name in sorted(names):
76
+ result = await asyncio.wait_for(
77
+ client.call_tool(
78
+ "search_tools", {"pattern": "^" + re.escape(name) + r"(?:\s|$)"}
79
+ ),
80
+ timeout=15,
81
+ )
82
+ rows = result.data
83
+ if isinstance(rows, dict):
84
+ rows = rows.get("result")
85
+ if (
86
+ not isinstance(rows, list)
87
+ or len(rows) != 1
88
+ or rows[0].get("name") != name
89
+ ):
90
+ raise ValueError(f"Exact schema retrieval failed: {name}")
91
+ receipt["retrievals"][name] = require_schema_budget(
92
+ rows, budget, kind="search_result"
93
+ )
94
+ tools.extend(rows)
95
+ return tools, receipt