annotide 0.1.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.
annotide/errors.py ADDED
@@ -0,0 +1,58 @@
1
+ """Exceptions raised by the SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from annotide.models import JobRead
9
+
10
+
11
+ class AnnotationError(Exception):
12
+ """Base class of every error the SDK raises."""
13
+
14
+
15
+ class ApiError(AnnotationError):
16
+ """The API answered with an error: an RFC 9457 problem (CONTRACTS.md, API-2)."""
17
+
18
+ def __init__(
19
+ self,
20
+ status: int,
21
+ title: str,
22
+ detail: str | None = None,
23
+ *,
24
+ type: str = "about:blank", # noqa: A002 - the problem-details field name
25
+ method: str = "",
26
+ path: str = "",
27
+ ) -> None:
28
+ self.status = status
29
+ self.title = title
30
+ self.detail = detail
31
+ self.type = type
32
+ self.method = method
33
+ self.path = path
34
+ where = f"{method} {path}: " if method else ""
35
+ super().__init__(f"{where}{status} {detail or title}")
36
+
37
+
38
+ class JobFailedError(AnnotationError):
39
+ """A job ended `failed` or `cancelled`. `job` is its final state."""
40
+
41
+ def __init__(self, job: JobRead) -> None:
42
+ self.job = job
43
+ reason = f": {job['error']}" if job.get("error") else ""
44
+ super().__init__(f"job {_describe(job)} ended {job.get('status')}{reason}")
45
+
46
+
47
+ class JobTimeoutError(AnnotationError):
48
+ """A job did not finish in time. `job` is the last state seen."""
49
+
50
+ def __init__(self, job: JobRead, timeout: float) -> None:
51
+ self.job = job
52
+ super().__init__(f"job {_describe(job)} still {job.get('status')} after {timeout:.0f}s")
53
+
54
+
55
+ def _describe(job: JobRead) -> str:
56
+ # Tolerant of a partial body: an error message must never raise itself.
57
+ kind = job.get("type")
58
+ return f"{job.get('id')} ({kind})" if kind else str(job.get("id"))
annotide/mcp_server.py ADDED
@@ -0,0 +1,213 @@
1
+ """MCP server for AI agents (API-8): `annotide mcp`.
2
+
3
+ Serves the Model Context Protocol over stdio and calls the platform's REST
4
+ API through `Client`, with an API key. Use a service account's key: the
5
+ agent can then do exactly what that account's project memberships allow,
6
+ and nothing in this module widens it.
7
+
8
+ The tools cover what an agent pre-labelling a project needs: find projects
9
+ and their label schema, look at items (an image comes back as an image),
10
+ read annotations, claim and release tasks, and post pre-labels as an
11
+ external producer (`POST /items/{id}/prelabels`). An agent cannot submit,
12
+ review, delete or export.
13
+
14
+ Needs the `mcp` extra: `pip install "annotide[mcp]"`.
15
+
16
+ Configuration (environment):
17
+
18
+ - `ANNOTIDE_URL`, `ANNOTIDE_API_KEY` — as for every SDK client.
19
+ - `ANNOTIDE_MODEL_VERSION_ID` — the model version `create_prelabel` writes
20
+ under unless the call names one. Register an endpoint-less model for the
21
+ agent (Models page, empty endpoint) and add a version to it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from collections.abc import Callable
28
+ from pathlib import PurePosixPath
29
+ from typing import Any
30
+
31
+ import anyio
32
+ from mcp.server.mcpserver import Image, MCPServer
33
+ from mcp.server.mcpserver.exceptions import ToolError
34
+ from mcp.types import ToolAnnotations
35
+
36
+ from annotide.client import Client
37
+ from annotide.errors import AnnotationError
38
+
39
+ __all__ = ["MAX_MEDIA_BYTES", "MODEL_VERSION_ENV", "build_server"]
40
+
41
+ MODEL_VERSION_ENV = "ANNOTIDE_MODEL_VERSION_ID"
42
+ #: `view_item` refuses larger media; a model's context is not a file share.
43
+ MAX_MEDIA_BYTES = 5 * 1024 * 1024
44
+ #: The fields an agent needs from an item; signed URLs and paging noise stay out.
45
+ _ITEM_FIELDS = ("id", "path", "media_type", "status", "width", "height", "meta")
46
+ _IMAGE_FORMATS = {".jpg": "jpeg", ".jpeg": "jpeg", ".png": "png", ".webp": "webp", ".gif": "gif"}
47
+ _TEXT_TYPES = {"text"}
48
+
49
+ INSTRUCTIONS = """\
50
+ Tools for the Annotide. Typical loop for pre-labelling:
51
+ 1. list_projects, then get_label_schema for the project: shapes must use its
52
+ class names and the tools each class allows.
53
+ 2. claim_task (or list_items with status "new") to pick an item.
54
+ 3. view_item to see it, get_annotations to see what is already there.
55
+ 4. create_prelabel with an Annotation result JSON
56
+ ({"schema_version": 1, "media_type": ..., "classification": {...},
57
+ "shapes": [...]}); a bbox is {"id", "type": "bbox", "class", "bbox":
58
+ [x_min, y_min, x_max, y_max]} in image pixels; every shape `id` is a
59
+ UUID you generate (uuid4).
60
+ 5. release_task when done; a person reviews and submits the pre-label.
61
+ """
62
+
63
+ _READ = ToolAnnotations(read_only_hint=True, open_world_hint=False)
64
+ _WRITE = ToolAnnotations(read_only_hint=False, destructive_hint=False, open_world_hint=False)
65
+
66
+
67
+ def _item_summary(item: Any) -> dict[str, Any]:
68
+ return {key: item.get(key) for key in _ITEM_FIELDS if key in item}
69
+
70
+
71
+ def build_server(client_factory: Callable[[], Client] | None = None) -> MCPServer:
72
+ """The MCP server; `client_factory` lets tests hand in a client."""
73
+ factory = client_factory or Client
74
+ holder: dict[str, Client] = {}
75
+
76
+ def client() -> Client:
77
+ # Created on first use, so `annotide mcp` starts (and lists its tools)
78
+ # even before the environment is complete; the first call says why not.
79
+ if "client" not in holder:
80
+ holder["client"] = factory()
81
+ return holder["client"]
82
+
83
+ async def call(fn: Callable[[], Any]) -> Any:
84
+ # The SDK is synchronous; keep the stdio loop responsive. Its errors
85
+ # (a 409, a missing key) are answers the agent should read, not crashes.
86
+ try:
87
+ return await anyio.to_thread.run_sync(fn)
88
+ except AnnotationError as exc:
89
+ raise ToolError(str(exc)) from exc
90
+
91
+ server = MCPServer(name="annotide", instructions=INSTRUCTIONS)
92
+
93
+ @server.tool(annotations=_READ)
94
+ async def list_projects() -> list[dict[str, Any]]:
95
+ """Projects the API key's account is a member of."""
96
+ projects = await call(lambda: list(client().list_projects()))
97
+ return [
98
+ {"id": p["id"], "name": p["name"], "description": p.get("description")}
99
+ for p in projects
100
+ ]
101
+
102
+ @server.tool(annotations=_READ)
103
+ async def get_label_schema(project_id: str) -> dict[str, Any]:
104
+ """The project's current label schema: classes, their tools and attributes."""
105
+ versions = await call(lambda: client().list_schema_versions(project_id))
106
+ if not versions:
107
+ raise AnnotationError(f"project {project_id} has no label schema")
108
+ latest = versions[0]
109
+ return {
110
+ "label_schema_version_id": latest["id"],
111
+ "version": latest["version"],
112
+ "definition": latest["definition"],
113
+ }
114
+
115
+ @server.tool(annotations=_READ)
116
+ async def list_items(
117
+ project_id: str, status: str | None = None, limit: int = 20
118
+ ) -> list[dict[str, Any]]:
119
+ """A project's items, oldest first; `status` filters (new, prelabeled, annotating, …)."""
120
+ limit = max(1, min(limit, 200))
121
+
122
+ def fetch() -> list[dict[str, Any]]:
123
+ items = client().list_items(project_id, status=status)
124
+ out: list[dict[str, Any]] = []
125
+ for item in items:
126
+ out.append(_item_summary(item))
127
+ if len(out) >= limit:
128
+ break
129
+ return out
130
+
131
+ result: list[dict[str, Any]] = await call(fetch)
132
+ return result
133
+
134
+ @server.tool(annotations=_READ)
135
+ async def get_item(item_id: str) -> dict[str, Any]:
136
+ """One item's metadata and a short-lived signed `media_url`."""
137
+ item = await call(lambda: client().get_item(item_id))
138
+ return {**_item_summary(item), "media_url": item.get("media_url")}
139
+
140
+ @server.tool(annotations=_READ)
141
+ async def view_item(item_id: str) -> Image | str:
142
+ """The item's media itself: an image as an image, a text item as its text."""
143
+
144
+ def fetch() -> Image | str:
145
+ item = client().get_item(item_id)
146
+ media_type = str(item.get("media_type"))
147
+ if media_type == "image":
148
+ data = client().download_media(item, max_bytes=MAX_MEDIA_BYTES)
149
+ suffix = PurePosixPath(str(item.get("path", ""))).suffix.lower()
150
+ return Image(data=data, format=_IMAGE_FORMATS.get(suffix, "png"))
151
+ if media_type in _TEXT_TYPES:
152
+ return (
153
+ client()
154
+ .download_media(item, max_bytes=MAX_MEDIA_BYTES)
155
+ .decode("utf-8", errors="replace")
156
+ )
157
+ raise AnnotationError(
158
+ f"view_item shows images and text; this item is {media_type}. "
159
+ "Use get_item for its signed URL."
160
+ )
161
+
162
+ result: Image | str = await call(fetch)
163
+ return result
164
+
165
+ @server.tool(annotations=_READ)
166
+ async def get_annotations(item_id: str, all_versions: bool = False) -> dict[str, Any]:
167
+ """The item's latest primary annotation version, or its whole history."""
168
+ versions = await call(lambda: client().list_annotations(item_id))
169
+ primary = [v for v in versions if v.get("kind", "primary") == "primary"]
170
+ if all_versions:
171
+ return {"versions": versions}
172
+ return {"latest": primary[0] if primary else None, "version_count": len(versions)}
173
+
174
+ @server.tool(annotations=_WRITE)
175
+ async def claim_task(project_id: str, task_type: str = "annotate") -> dict[str, Any]:
176
+ """Claim the next open task in the project (it is locked to you until released)."""
177
+ task = await call(lambda: client().claim_task(project_id, task_type=task_type))
178
+ if task is None:
179
+ return {"task": None, "message": "No open task right now."}
180
+ return {"task": task}
181
+
182
+ @server.tool(annotations=_WRITE)
183
+ async def release_task(task_id: str) -> dict[str, Any]:
184
+ """Give a claimed task back to the queue."""
185
+ task = await call(lambda: client().release_task(task_id))
186
+ return {"task": task}
187
+
188
+ @server.tool(annotations=_WRITE)
189
+ async def create_prelabel(
190
+ item_id: str,
191
+ result: dict[str, Any],
192
+ model_version_id: str | None = None,
193
+ label_schema_version_id: str | None = None,
194
+ ) -> dict[str, Any]:
195
+ """Post a pre-label: a draft a person will review. Never replaces human work."""
196
+ version = model_version_id or os.environ.get(MODEL_VERSION_ENV)
197
+ if not version:
198
+ raise ToolError(f"no model version: pass model_version_id or set {MODEL_VERSION_ENV}")
199
+ annotation = await call(
200
+ lambda: client().create_prelabel(
201
+ item_id,
202
+ model_version_id=version,
203
+ result=result,
204
+ label_schema_version_id=label_schema_version_id,
205
+ )
206
+ )
207
+ return {
208
+ "annotation_id": annotation["id"],
209
+ "version": annotation["version"],
210
+ "status": annotation["status"],
211
+ }
212
+
213
+ return server