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.
- immich_api/__init__.py +54 -0
- immich_api/__main__.py +4 -0
- immich_api/album_metadata.py +119 -0
- immich_api/api/__init__.py +3 -0
- immich_api/api/api_client_system.py +7 -0
- immich_api/auth.py +94 -0
- immich_api/connector_manifest.yml +212 -0
- immich_api/connectors/__init__.py +0 -0
- immich_api/connectors/mcp_source_presets.json +3 -0
- immich_api/connectors/tool_schema_fingerprints.json +1 -0
- immich_api/error_authority.py +248 -0
- immich_api/error_rendering.py +105 -0
- immich_api/immich_input_models.py +12 -0
- immich_api/immich_response_models.py +15 -0
- immich_api/kg_ingest.py +75 -0
- immich_api/mcp/__init__.py +21 -0
- immich_api/mcp/album_sample.py +38 -0
- immich_api/mcp_config.json +3 -0
- immich_api/mcp_server.py +50 -0
- immich_api/ontology/__init__.py +6 -0
- immich_api/ontology/immich.ttl +119 -0
- immich_api/ontology/schema-provenance.json +483 -0
- immich_api/ontology/shapes/immich.ttl +69 -0
- immich_api/openapi_auth_scheme.py +77 -0
- immich_api/openapi_contract.json +80122 -0
- immich_api/openapi_execution.py +30 -0
- immich_api/openapi_inventory.json +16154 -0
- immich_api/openapi_request.py +119 -0
- immich_api/openapi_response.py +76 -0
- immich_api/openapi_runtime.py +203 -0
- immich_api/parity/__init__.py +17 -0
- immich_api/parity/check_openapi_parity.py +173 -0
- immich_api/parity/openapi_discovery.py +95 -0
- immich_api/parity/openapi_parity.py +239 -0
- immich_api/parity/openapi_provider_evidence.py +143 -0
- immich_api/parity/openapi_provider_overlay.py +239 -0
- immich_api/parity/openapi_provider_responses.py +132 -0
- immich_api/parity/openapi_schema.py +206 -0
- immich_api/parity/openapi_schema_budget.py +104 -0
- immich_api/parity/openapi_schema_inputs.py +89 -0
- immich_api/parity/openapi_schema_types.py +163 -0
- immich_api/parity/openapi_security.py +157 -0
- immich_api/parity/openapi_source_parity.py +98 -0
- immich_api/parity/openapi_transport_parity.py +169 -0
- immich_api/parity/unicode_patterns.py +105 -0
- immich_api/parity/unicode_properties.json +4719 -0
- immich_api/prompts/__init__.py +0 -0
- immich_api/prompts/immich_album_review.json +18 -0
- immich_api/prompts/immich_asset_metadata.json +18 -0
- immich_api/prompts/immich_person_record.json +18 -0
- immich_api/prompts/immich_specialist.json +17 -0
- immich_api/prompts/main_agent.json +24 -0
- immich_api/skills/__init__.py +0 -0
- immich_api/skills/immich-album-review/SKILL.md +17 -0
- immich_api/skills/immich-asset-metadata/SKILL.md +17 -0
- immich_api/skills/immich-health/SKILL.md +17 -0
- immich_api/skills/immich-person-record/SKILL.md +17 -0
- immich_api-0.2.0.dist-info/METADATA +242 -0
- immich_api-0.2.0.dist-info/RECORD +63 -0
- immich_api-0.2.0.dist-info/WHEEL +5 -0
- immich_api-0.2.0.dist-info/entry_points.txt +14 -0
- immich_api-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|