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,248 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""One representation authority for agent-readable HTTP errors.
|
|
3
|
+
|
|
4
|
+
The generated module is deliberately framework-neutral. HTTP, MCP, and A2A
|
|
5
|
+
adapters construct one :class:`ProblemDetails` value and negotiate only its
|
|
6
|
+
representation; they do not independently translate exceptions or retry
|
|
7
|
+
metadata. Browser HTML remains available, but agent clients receive RFC 9457
|
|
8
|
+
JSON or structured Markdown when they ask for it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import html
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from agent_connector_sdk.privacy import sanitize_for_persistence
|
|
21
|
+
|
|
22
|
+
from .error_rendering import (
|
|
23
|
+
AGENT_ERROR_ACCEPT,
|
|
24
|
+
BROWSER_HTML_MEDIA_TYPE,
|
|
25
|
+
PROBLEM_JSON_MEDIA_TYPE,
|
|
26
|
+
STRUCTURED_MARKDOWN_MEDIA_TYPE,
|
|
27
|
+
RenderedError,
|
|
28
|
+
negotiate_media_type,
|
|
29
|
+
render_error,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
MAX_DETAIL_CHARS = 2048
|
|
33
|
+
MAX_INSTANCE_CHARS = 512
|
|
34
|
+
MAX_TYPE_CHARS = 256
|
|
35
|
+
MAX_RETRY_AFTER_S = 3600.0
|
|
36
|
+
|
|
37
|
+
_CODE_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,63}$")
|
|
38
|
+
_DENIAL_CODES = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"access_denied",
|
|
41
|
+
"authentication_required",
|
|
42
|
+
"forbidden",
|
|
43
|
+
"not_authorized",
|
|
44
|
+
"permission_denied",
|
|
45
|
+
"policy_denied",
|
|
46
|
+
"unauthorized",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
_DEFAULT_TITLES = {
|
|
50
|
+
"dependency_unavailable": "Dependency unavailable",
|
|
51
|
+
"engine_degraded": "Service temporarily unavailable",
|
|
52
|
+
"invalid_request": "Invalid request",
|
|
53
|
+
"operation_failed": "Operation failed",
|
|
54
|
+
"permission_denied": "Permission denied",
|
|
55
|
+
}
|
|
56
|
+
_METADATA_FIELDS = (
|
|
57
|
+
"status",
|
|
58
|
+
"code",
|
|
59
|
+
"type",
|
|
60
|
+
"instance",
|
|
61
|
+
"retryable",
|
|
62
|
+
"retry_after_s",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _safe_text(value: Any, *, limit: int, fallback: str) -> str:
|
|
67
|
+
"""Sanitize and bound text before it crosses a public response boundary."""
|
|
68
|
+
candidate = "" if isinstance(value, BaseException) else str(value or "")
|
|
69
|
+
sanitized, _ = sanitize_for_persistence(candidate)
|
|
70
|
+
text = str(sanitized).replace("\x00", "").strip()
|
|
71
|
+
return text[:limit] or fallback
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _safe_code(value: Any) -> str:
|
|
75
|
+
candidate = str(value or "operation_failed").strip().lower()
|
|
76
|
+
return candidate if _CODE_RE.fullmatch(candidate) else "operation_failed"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _bounded_retry_after(value: Any) -> float | None:
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
retry_after = float(value)
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
return None
|
|
86
|
+
if not math.isfinite(retry_after) or retry_after < 0:
|
|
87
|
+
return None
|
|
88
|
+
return min(retry_after, MAX_RETRY_AFTER_S)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class ProblemDetails:
|
|
93
|
+
"""Canonical RFC 9457 problem details plus agent retry metadata."""
|
|
94
|
+
|
|
95
|
+
status: int
|
|
96
|
+
code: str
|
|
97
|
+
instance: str
|
|
98
|
+
detail: str
|
|
99
|
+
type_uri: str = ""
|
|
100
|
+
title: str = ""
|
|
101
|
+
retryable: bool = False
|
|
102
|
+
retry_after_s: float | None = None
|
|
103
|
+
|
|
104
|
+
def __post_init__(self) -> None:
|
|
105
|
+
status = int(self.status)
|
|
106
|
+
if not 100 <= status <= 599:
|
|
107
|
+
raise ValueError("problem status must be between 100 and 599")
|
|
108
|
+
|
|
109
|
+
code = _safe_code(self.code)
|
|
110
|
+
denied = status in {401, 403} or code in _DENIAL_CODES
|
|
111
|
+
type_uri = _safe_text(
|
|
112
|
+
self.type_uri,
|
|
113
|
+
limit=MAX_TYPE_CHARS,
|
|
114
|
+
fallback=f"urn:agent-error:{code}",
|
|
115
|
+
)
|
|
116
|
+
instance = _safe_text(
|
|
117
|
+
self.instance,
|
|
118
|
+
limit=MAX_INSTANCE_CHARS,
|
|
119
|
+
fallback="urn:agent-error-instance:unknown",
|
|
120
|
+
)
|
|
121
|
+
title = _safe_text(
|
|
122
|
+
self.title,
|
|
123
|
+
limit=256,
|
|
124
|
+
fallback=_DEFAULT_TITLES.get(code, "Operation failed"),
|
|
125
|
+
)
|
|
126
|
+
detail = _safe_text(
|
|
127
|
+
self.detail,
|
|
128
|
+
limit=MAX_DETAIL_CHARS,
|
|
129
|
+
fallback="The request could not be completed.",
|
|
130
|
+
)
|
|
131
|
+
retryable = bool(self.retryable) and not denied
|
|
132
|
+
retry_after_s = _bounded_retry_after(self.retry_after_s) if retryable else None
|
|
133
|
+
|
|
134
|
+
object.__setattr__(self, "status", status)
|
|
135
|
+
object.__setattr__(self, "code", code)
|
|
136
|
+
object.__setattr__(self, "type_uri", type_uri)
|
|
137
|
+
object.__setattr__(self, "instance", instance)
|
|
138
|
+
object.__setattr__(self, "title", title)
|
|
139
|
+
object.__setattr__(self, "detail", detail)
|
|
140
|
+
object.__setattr__(self, "retryable", retryable)
|
|
141
|
+
object.__setattr__(self, "retry_after_s", retry_after_s)
|
|
142
|
+
|
|
143
|
+
def metadata(self) -> dict[str, Any]:
|
|
144
|
+
"""Return the representation-invariant agent metadata."""
|
|
145
|
+
return {
|
|
146
|
+
"status": self.status,
|
|
147
|
+
"code": self.code,
|
|
148
|
+
"type": self.type_uri,
|
|
149
|
+
"instance": self.instance,
|
|
150
|
+
"retryable": self.retryable,
|
|
151
|
+
"retry_after_s": self.retry_after_s,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def as_dict(self) -> dict[str, Any]:
|
|
155
|
+
"""Return an RFC 9457-compatible object with stable extension fields."""
|
|
156
|
+
return {
|
|
157
|
+
"type": self.type_uri,
|
|
158
|
+
"title": self.title,
|
|
159
|
+
"status": self.status,
|
|
160
|
+
"detail": self.detail,
|
|
161
|
+
"instance": self.instance,
|
|
162
|
+
"code": self.code,
|
|
163
|
+
"retryable": self.retryable,
|
|
164
|
+
"retry_after_s": self.retry_after_s,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
def to_json(self) -> str:
|
|
168
|
+
return json.dumps(
|
|
169
|
+
self.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def to_markdown(self) -> str:
|
|
173
|
+
"""Render structured Markdown from the same metadata object as JSON."""
|
|
174
|
+
metadata = self.metadata()
|
|
175
|
+
lines = ["---"]
|
|
176
|
+
for field in _METADATA_FIELDS:
|
|
177
|
+
rendered = json.dumps(metadata[field], ensure_ascii=False)
|
|
178
|
+
lines.append(f"{field}: {rendered}")
|
|
179
|
+
lines.extend(
|
|
180
|
+
[
|
|
181
|
+
"---",
|
|
182
|
+
f"# {self.title}",
|
|
183
|
+
"",
|
|
184
|
+
"## Detail",
|
|
185
|
+
]
|
|
186
|
+
)
|
|
187
|
+
lines.extend(f"> {line}" if line else ">" for line in self.detail.splitlines())
|
|
188
|
+
return "\n".join(lines) + "\n"
|
|
189
|
+
|
|
190
|
+
def to_html(self) -> str:
|
|
191
|
+
"""Render the retained browser surface with escaped bounded fields."""
|
|
192
|
+
metadata = self.metadata()
|
|
193
|
+
attrs = " ".join(
|
|
194
|
+
'data-error-{}="{}"'.format(
|
|
195
|
+
field.replace("_", "-"), html.escape(str(value), quote=True)
|
|
196
|
+
)
|
|
197
|
+
for field, value in metadata.items()
|
|
198
|
+
)
|
|
199
|
+
detail = html.escape(self.detail, quote=False).replace("\n", "<br>")
|
|
200
|
+
return (
|
|
201
|
+
'<!doctype html>\n<html lang="en"><head>'
|
|
202
|
+
f'<meta name="error-status" content="{self.status}">'
|
|
203
|
+
f"<title>{html.escape(self.title)}</title></head>\n"
|
|
204
|
+
f"<body><main {attrs}><h1>{html.escape(self.title)}</h1>"
|
|
205
|
+
f"<p>{detail}</p></main></body></html>\n"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def error_response(
|
|
210
|
+
*,
|
|
211
|
+
status: int,
|
|
212
|
+
code: str,
|
|
213
|
+
instance: str,
|
|
214
|
+
detail: str,
|
|
215
|
+
type_uri: str = "",
|
|
216
|
+
title: str = "",
|
|
217
|
+
retryable: bool = False,
|
|
218
|
+
retry_after_s: float | None = None,
|
|
219
|
+
accept: str | None = None,
|
|
220
|
+
) -> RenderedError:
|
|
221
|
+
"""Construct and render a problem through the single package authority."""
|
|
222
|
+
return render_error(
|
|
223
|
+
ProblemDetails(
|
|
224
|
+
status=status,
|
|
225
|
+
code=code,
|
|
226
|
+
instance=instance,
|
|
227
|
+
detail=detail,
|
|
228
|
+
type_uri=type_uri,
|
|
229
|
+
title=title,
|
|
230
|
+
retryable=retryable,
|
|
231
|
+
retry_after_s=retry_after_s,
|
|
232
|
+
),
|
|
233
|
+
accept=accept,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = [
|
|
238
|
+
"BROWSER_HTML_MEDIA_TYPE",
|
|
239
|
+
"AGENT_ERROR_ACCEPT",
|
|
240
|
+
"MAX_DETAIL_CHARS",
|
|
241
|
+
"PROBLEM_JSON_MEDIA_TYPE",
|
|
242
|
+
"ProblemDetails",
|
|
243
|
+
"RenderedError",
|
|
244
|
+
"STRUCTURED_MARKDOWN_MEDIA_TYPE",
|
|
245
|
+
"error_response",
|
|
246
|
+
"negotiate_media_type",
|
|
247
|
+
"render_error",
|
|
248
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Negotiate and render canonical problems without changing their metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .error_authority import ProblemDetails
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
PROBLEM_JSON_MEDIA_TYPE = "application/problem+json"
|
|
14
|
+
STRUCTURED_MARKDOWN_MEDIA_TYPE = "text/markdown"
|
|
15
|
+
BROWSER_HTML_MEDIA_TYPE = "text/html"
|
|
16
|
+
AGENT_ERROR_ACCEPT = (
|
|
17
|
+
f"{PROBLEM_JSON_MEDIA_TYPE}, "
|
|
18
|
+
f"{STRUCTURED_MARKDOWN_MEDIA_TYPE};q=0.9, "
|
|
19
|
+
f"{BROWSER_HTML_MEDIA_TYPE};q=0.5"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RenderedError:
|
|
25
|
+
"""A transport-neutral response ready for an HTTP/MCP adapter."""
|
|
26
|
+
|
|
27
|
+
status_code: int
|
|
28
|
+
media_type: str
|
|
29
|
+
body: str
|
|
30
|
+
headers: Mapping[str, str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _quality(parameters: list[str]) -> float | None:
|
|
34
|
+
"""Read one bounded q value; preserve the default for malformed parameters."""
|
|
35
|
+
try:
|
|
36
|
+
quality = next(
|
|
37
|
+
float(part.split("=", 1)[1].strip())
|
|
38
|
+
for part in parameters
|
|
39
|
+
if part.lower().startswith("q=")
|
|
40
|
+
)
|
|
41
|
+
except (StopIteration, ValueError):
|
|
42
|
+
quality = 1.0
|
|
43
|
+
return quality if 0.0 <= quality <= 1.0 else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _specificity(token: str, offered: str) -> int:
|
|
47
|
+
if token == offered:
|
|
48
|
+
return 2
|
|
49
|
+
return 1 if token in {"*/*", offered.split("/", 1)[0] + "/*"} else -1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _accept_quality(accept: str, offered: str) -> tuple[float, int, int]:
|
|
53
|
+
"""Return q, specificity, and source order for one offered media type."""
|
|
54
|
+
best_match = (-1, 0.0, -10_000)
|
|
55
|
+
for order, item in enumerate(accept.split(",")):
|
|
56
|
+
bits = [part.strip() for part in item.split(";")]
|
|
57
|
+
quality = _quality(bits[1:])
|
|
58
|
+
specificity = _specificity(bits[0].lower(), offered)
|
|
59
|
+
if quality is not None and specificity >= 0:
|
|
60
|
+
best_match = max(best_match, (specificity, quality, -order))
|
|
61
|
+
specificity, quality, source_order = best_match
|
|
62
|
+
return quality, specificity, source_order
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def negotiate_media_type(accept: str | None) -> str:
|
|
66
|
+
"""Prefer agent formats, while retaining HTML for browser Accept headers."""
|
|
67
|
+
if not accept or not accept.strip():
|
|
68
|
+
return PROBLEM_JSON_MEDIA_TYPE
|
|
69
|
+
offered = (
|
|
70
|
+
PROBLEM_JSON_MEDIA_TYPE,
|
|
71
|
+
STRUCTURED_MARKDOWN_MEDIA_TYPE,
|
|
72
|
+
BROWSER_HTML_MEDIA_TYPE,
|
|
73
|
+
)
|
|
74
|
+
ranked = [
|
|
75
|
+
(*_accept_quality(accept, media_type), -priority, media_type)
|
|
76
|
+
for priority, media_type in enumerate(offered)
|
|
77
|
+
]
|
|
78
|
+
supported = [candidate for candidate in ranked if candidate[0] > 0]
|
|
79
|
+
return max(supported)[-1] if supported else PROBLEM_JSON_MEDIA_TYPE
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render_error(
|
|
83
|
+
problem: ProblemDetails, *, accept: str | None = None
|
|
84
|
+
) -> RenderedError:
|
|
85
|
+
"""Negotiate one bounded representation without changing its semantics."""
|
|
86
|
+
media_type = negotiate_media_type(accept)
|
|
87
|
+
if media_type == STRUCTURED_MARKDOWN_MEDIA_TYPE:
|
|
88
|
+
body = problem.to_markdown()
|
|
89
|
+
elif media_type == BROWSER_HTML_MEDIA_TYPE:
|
|
90
|
+
body = problem.to_html()
|
|
91
|
+
else:
|
|
92
|
+
media_type = PROBLEM_JSON_MEDIA_TYPE
|
|
93
|
+
body = problem.to_json()
|
|
94
|
+
headers = {
|
|
95
|
+
"Content-Type": f"{media_type}; charset=utf-8",
|
|
96
|
+
"Vary": "Accept",
|
|
97
|
+
}
|
|
98
|
+
if problem.retry_after_s is not None:
|
|
99
|
+
headers["Retry-After"] = str(problem.retry_after_s)
|
|
100
|
+
return RenderedError(
|
|
101
|
+
status_code=problem.status,
|
|
102
|
+
media_type=media_type,
|
|
103
|
+
body=body,
|
|
104
|
+
headers=headers,
|
|
105
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""Pydantic input models for Immich Api API request parameters."""
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SystemStatusInput(BaseModel):
|
|
8
|
+
"""Input model for system status queries."""
|
|
9
|
+
|
|
10
|
+
verbose: bool | None = Field(
|
|
11
|
+
default=False, description="Return extended status details."
|
|
12
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""Pydantic response models for Immich Api API payloads."""
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SystemStatusResponse(BaseModel):
|
|
10
|
+
"""Response model for system status queries."""
|
|
11
|
+
|
|
12
|
+
status: str | None = Field(default=None, description="Service status string.")
|
|
13
|
+
raw: dict[str, Any] | None = Field(
|
|
14
|
+
default=None, description="Raw response payload."
|
|
15
|
+
)
|
immich_api/kg_ingest.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Explicit album metadata submission through the SDK; no implicit ingestion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from agent_connector_sdk.ingest import (
|
|
9
|
+
ChangeSet,
|
|
10
|
+
Entity,
|
|
11
|
+
IngestBinding,
|
|
12
|
+
KnowledgeIngest,
|
|
13
|
+
current_ingest,
|
|
14
|
+
)
|
|
15
|
+
from agent_connector_sdk.ingest.request import build_request
|
|
16
|
+
from epistemic_graph.generated.source_ingestion import SourceIngestionMode
|
|
17
|
+
|
|
18
|
+
from .album_metadata import album_graph_properties, project_album
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def ingest_album_metadata(
|
|
22
|
+
records: list[dict],
|
|
23
|
+
*,
|
|
24
|
+
source_scope: str,
|
|
25
|
+
ingest: KnowledgeIngest | None = None,
|
|
26
|
+
):
|
|
27
|
+
"""Submit at most five metadata records and return the unmodified EG receipt.
|
|
28
|
+
|
|
29
|
+
source_scope is an operator-assigned opaque label unique to the provider
|
|
30
|
+
instance AND account/authorization scope. It is not a URL or credential.
|
|
31
|
+
The caller must separately authorize graph persistence; this is not an MCP
|
|
32
|
+
tool and listing/calling provider tools never invokes it automatically.
|
|
33
|
+
No photos, person observations, links, withdrawals or reconciliation occur.
|
|
34
|
+
"""
|
|
35
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", source_scope):
|
|
36
|
+
raise ValueError("source_scope must be an opaque instance/account label")
|
|
37
|
+
if not 1 <= len(records) <= 5:
|
|
38
|
+
raise ValueError("ingest_album_metadata requires one to five records")
|
|
39
|
+
projected = [project_album(record) for record in records]
|
|
40
|
+
if len({record["id"] for record in projected}) != len(projected):
|
|
41
|
+
raise ValueError("duplicate album identity")
|
|
42
|
+
scope = hashlib.sha256(source_scope.encode()).hexdigest()
|
|
43
|
+
binding = IngestBinding(
|
|
44
|
+
connector="immich-api",
|
|
45
|
+
stream=f"album-metadata:{scope}",
|
|
46
|
+
server="Immich Api",
|
|
47
|
+
tool="album_metadata_sample",
|
|
48
|
+
strict_schema=True,
|
|
49
|
+
)
|
|
50
|
+
changes = _album_changes(projected)
|
|
51
|
+
_validate_privacy(binding, changes)
|
|
52
|
+
service = ingest if ingest is not None else current_ingest()
|
|
53
|
+
return await service.submit(binding, changes)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _album_changes(projected):
|
|
57
|
+
entities = tuple(
|
|
58
|
+
Entity(
|
|
59
|
+
id=record["id"],
|
|
60
|
+
node_type="ImmichAlbum",
|
|
61
|
+
properties=album_graph_properties(record),
|
|
62
|
+
updated_at=record["updatedAt"],
|
|
63
|
+
)
|
|
64
|
+
for record in projected
|
|
65
|
+
)
|
|
66
|
+
return ChangeSet(entities=entities, mode=SourceIngestionMode.DELTA)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_privacy(binding, changes):
|
|
70
|
+
preview = build_request(binding, changes, previous=None)
|
|
71
|
+
if any(
|
|
72
|
+
record.payload.get("immichAlbumId") != record.record_id
|
|
73
|
+
for record in preview.records
|
|
74
|
+
):
|
|
75
|
+
raise ValueError("SDK privacy guard changed album identity; no submission")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Catalog-bound registrars preserving the SDK's per-domain toggles."""
|
|
2
|
+
|
|
3
|
+
from functools import partial
|
|
4
|
+
|
|
5
|
+
from ..auth import get_client
|
|
6
|
+
from ..openapi_runtime import CONTRACT, register_domain
|
|
7
|
+
from .album_sample import register_album_sample_tools as register_album_sample_tools
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _domain_registrar(domain):
|
|
11
|
+
registrar = partial(register_domain, domain=domain, client_factory=get_client)
|
|
12
|
+
registrar.__name__ = f"register_{domain}_tools"
|
|
13
|
+
return registrar
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
globals().update(
|
|
17
|
+
{
|
|
18
|
+
f"register_{domain}_tools": _domain_registrar(domain)
|
|
19
|
+
for domain in sorted(CONTRACT["domains"])
|
|
20
|
+
}
|
|
21
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""One bounded read-only capability built from an allowlisted OpenAPI operation."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from agent_connector_sdk.mcp.concurrency import run_blocking
|
|
6
|
+
from fastmcp import FastMCP
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from ..album_metadata import AlbumSample, album_sample
|
|
10
|
+
from ..auth import get_client
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_album_sample_tools(mcp: FastMCP):
|
|
14
|
+
@mcp.tool(
|
|
15
|
+
tags={"album_sample", "read-only"},
|
|
16
|
+
annotations={
|
|
17
|
+
"readOnlyHint": True,
|
|
18
|
+
"destructiveHint": False,
|
|
19
|
+
"idempotentHint": True,
|
|
20
|
+
"openWorldHint": True,
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
async def album_metadata_sample(
|
|
24
|
+
limit: Annotated[int, Field(ge=1, le=5)] = 1,
|
|
25
|
+
) -> AlbumSample:
|
|
26
|
+
"""Sample up to five owned albums; omit membership, paths and share secrets.
|
|
27
|
+
|
|
28
|
+
Uses getAllAlbums with isOwned=true. The API has no server-side limit;
|
|
29
|
+
its existing response byte/time limits still apply. This read capability
|
|
30
|
+
returns a non-authoritative sample and never ingests or withdraws.
|
|
31
|
+
CONCEPT:IMMICH-001
|
|
32
|
+
"""
|
|
33
|
+
response = await run_blocking(
|
|
34
|
+
get_client().execute,
|
|
35
|
+
"albums",
|
|
36
|
+
{"action": "getAllAlbums", "query": {"isOwned": True}},
|
|
37
|
+
)
|
|
38
|
+
return album_sample(response["data"], limit)
|
immich_api/mcp_server.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from agent_connector_sdk.config import load_config
|
|
5
|
+
from agent_connector_sdk.mcp.content import ConnectorContent
|
|
6
|
+
from agent_connector_sdk.mcp.server import create_mcp_server
|
|
7
|
+
from agent_connector_sdk.mcp.tool_surface import register_tool_surface
|
|
8
|
+
|
|
9
|
+
from . import mcp as tool_modules
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_mcp_instance(*, command_args=None):
|
|
15
|
+
"""Construct one SDK-governed server and its native package content."""
|
|
16
|
+
load_config()
|
|
17
|
+
package_root = Path(__file__).resolve().parent
|
|
18
|
+
args, mcp, middlewares = create_mcp_server(
|
|
19
|
+
name="Immich Api",
|
|
20
|
+
version=__version__,
|
|
21
|
+
instructions="Immich Api API + MCP Server",
|
|
22
|
+
command_args=command_args,
|
|
23
|
+
content=ConnectorContent(
|
|
24
|
+
connector="immich-api",
|
|
25
|
+
package_root=package_root,
|
|
26
|
+
package_version=__version__,
|
|
27
|
+
manifest_path=package_root / "connector_manifest.yml",
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
register_tool_surface(mcp, service="immich-api", tools_module=tool_modules)
|
|
31
|
+
for middleware in middlewares:
|
|
32
|
+
mcp.add_middleware(middleware)
|
|
33
|
+
return mcp, args, middlewares
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_server(*, command_args=None):
|
|
37
|
+
return get_mcp_instance(command_args=command_args)[0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def mcp_server():
|
|
41
|
+
mcp, args, _ = get_mcp_instance()
|
|
42
|
+
print("Starting Immich Api MCP", file=sys.stderr)
|
|
43
|
+
options = (
|
|
44
|
+
{} if args.transport == "stdio" else {"host": args.host, "port": args.port}
|
|
45
|
+
)
|
|
46
|
+
mcp.run(transport=args.transport, **options)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
mcp_server()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Immich Api ontology contribution (CONCEPT:AU-KG.ontology.federation-provider-leg).
|
|
2
|
+
|
|
3
|
+
Data-only subpackage carrying ``immich.ttl`` (the ``owl:Ontology``
|
|
4
|
+
``http://knuckles.team/kg/immich`` module) which the agent-utilities hub
|
|
5
|
+
federates via the ``agent_utilities.ontology_providers`` entry-point.
|
|
6
|
+
"""
|