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,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
+ )
@@ -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)
@@ -0,0 +1,3 @@
1
+ {
2
+ "mcpServers": {}
3
+ }
@@ -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
+ """