apud-mcp 0.0.1__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .DS_Store
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.5
2
+ Name: apud-mcp
3
+ Version: 0.0.1
4
+ Summary: MCP server exposing apud: IIIF-grounded, citable vision for historical documents
5
+ Author: cadeos
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: apud
9
+ Requires-Dist: mcp>=2.3
10
+ Description-Content-Type: text/markdown
11
+
12
+ # apud-mcp
13
+
14
+ MCP server for [apud](https://github.com/cadeos/apud). Lets an agent list IIIF canvases, view
15
+ regions at full detail, and record transcriptions as W3C annotations anchored to canvas
16
+ coordinates, with a IIIF Content State link for each citation.
17
+
18
+ ```bash
19
+ uvx apud-mcp
20
+ ```
@@ -0,0 +1,9 @@
1
+ # apud-mcp
2
+
3
+ MCP server for [apud](https://github.com/cadeos/apud). Lets an agent list IIIF canvases, view
4
+ regions at full detail, and record transcriptions as W3C annotations anchored to canvas
5
+ coordinates, with a IIIF Content State link for each citation.
6
+
7
+ ```bash
8
+ uvx apud-mcp
9
+ ```
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "apud-mcp"
3
+ version = "0.0.1"
4
+ description = "MCP server exposing apud: IIIF-grounded, citable vision for historical documents"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "cadeos" }]
9
+ dependencies = ["apud", "mcp>=2.3"]
10
+
11
+ [project.scripts]
12
+ apud-mcp = "apud_mcp.server:main"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/apud_mcp"]
20
+
21
+ [tool.uv.sources]
22
+ apud = { workspace = true }
File without changes
@@ -0,0 +1,125 @@
1
+ """MCP server: lets an agent look at IIIF canvas regions and emit citable, anchored transcriptions.
2
+
3
+ All coordinates exchanged with the model are canvas coordinates, so annotations and citations
4
+ stay valid even when the served image size differs from the canvas size.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from mcp.server.mcpserver import Image, MCPServer
12
+
13
+ from apud.annotations import encode_content_state, transcription_annotation
14
+ from apud.client import IIIFClient
15
+ from apud.iiif import Canvas, Region, canvas_to_image
16
+
17
+ # Keeps a JPEG crop comfortably under typical MCP client result limits (~1 MB).
18
+ DEFAULT_MAX_SIDE = 1568
19
+
20
+ server = MCPServer(
21
+ name="apud",
22
+ instructions=(
23
+ "Work on IIIF manuscripts with verifiable citations. Use describe_manifest to list "
24
+ "canvases, view_region to look at a canvas area at full detail, and "
25
+ "annotate_transcription to record what you read, anchored to canvas coordinates."
26
+ ),
27
+ )
28
+
29
+
30
+ def _find_canvas(manifest_url: str, canvas_id: str, client: IIIFClient) -> tuple[str, Canvas]:
31
+ manifest = client.manifest(manifest_url)
32
+ for canvas in manifest.canvases:
33
+ if canvas.id == canvas_id:
34
+ return manifest.id, canvas
35
+ raise ValueError(f"canvas {canvas_id} not found in {manifest_url}")
36
+
37
+
38
+ @server.tool()
39
+ def describe_manifest(manifest_url: str, offset: int = 0, limit: int = 50) -> dict[str, Any]:
40
+ """List the canvases of a IIIF manifest (id, label, size), paginated."""
41
+ with IIIFClient() as client:
42
+ manifest = client.manifest(manifest_url)
43
+ canvases = manifest.canvases[offset : offset + limit]
44
+ return {
45
+ "manifest": manifest.id,
46
+ "label": manifest.label,
47
+ "total_canvases": len(manifest.canvases),
48
+ "canvases": [
49
+ {"id": c.id, "label": c.label, "width": c.width, "height": c.height} for c in canvases
50
+ ],
51
+ }
52
+
53
+
54
+ @server.tool()
55
+ def view_region(
56
+ manifest_url: str,
57
+ canvas_id: str,
58
+ x: int,
59
+ y: int,
60
+ w: int,
61
+ h: int,
62
+ max_side: int = DEFAULT_MAX_SIDE,
63
+ ) -> list[Any]:
64
+ """Fetch a canvas region (canvas coordinates) at the best resolution the server allows.
65
+
66
+ Returns the image and the mapping from crop pixels back to canvas coordinates.
67
+ """
68
+ with IIIFClient() as client:
69
+ _, canvas = _find_canvas(manifest_url, canvas_id, client)
70
+ if canvas.image_service is None:
71
+ raise ValueError("canvas has no IIIF image service")
72
+ service = client.image_service(canvas.image_service)
73
+ image_region = canvas_to_image(Region(x, y, w, h), service.width, service.height, canvas)
74
+ request, data = client.region(service, image_region, max_side)
75
+ canvas_per_crop_px = (canvas.width / service.width) / request.scale
76
+ return [
77
+ Image(data=data, format="jpeg"),
78
+ {
79
+ "canvas_region": {"x": x, "y": y, "w": w, "h": h},
80
+ "crop_size": {"width": request.width, "height": request.height},
81
+ "to_canvas": (
82
+ f"canvas_x = {x} + crop_x * {canvas_per_crop_px:.4f}; "
83
+ f"canvas_y = {y} + crop_y * {canvas_per_crop_px:.4f}"
84
+ ),
85
+ "source_url": request.url,
86
+ },
87
+ ]
88
+
89
+
90
+ @server.tool()
91
+ def annotate_transcription(
92
+ manifest_url: str,
93
+ canvas_id: str,
94
+ x: int,
95
+ y: int,
96
+ w: int,
97
+ h: int,
98
+ text: str,
99
+ language: str | None = None,
100
+ ) -> dict[str, Any]:
101
+ """Record a transcription of a canvas region as a W3C annotation with a citable link."""
102
+ with IIIFClient() as client:
103
+ manifest_id, canvas = _find_canvas(manifest_url, canvas_id, client)
104
+ region = Region(x, y, w, h).clamp(canvas.width, canvas.height)
105
+ annotation = transcription_annotation(
106
+ annotation_id=f"{canvas.id}/apud/{region.xywh}",
107
+ canvas_id=canvas.id,
108
+ manifest_id=manifest_id,
109
+ region=region,
110
+ text=text,
111
+ language=language,
112
+ generator="apud-mcp",
113
+ )
114
+ return {
115
+ "annotation": annotation,
116
+ "content_state": encode_content_state(canvas.id, manifest_id, region),
117
+ }
118
+
119
+
120
+ def main() -> None:
121
+ server.run()
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
@@ -0,0 +1,4 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ sys.path.insert(0, str(Path(__file__).parents[2] / "core" / "tests"))
@@ -0,0 +1,49 @@
1
+ import httpx
2
+ import pytest
3
+
4
+ from apud.client import IIIFClient
5
+ from samples import V3_INFO, V3_MANIFEST
6
+
7
+ MANIFEST_URL = "https://iiif.example.org/manifest.json"
8
+ CANVAS = "https://iiif.example.org/canvas/1"
9
+
10
+
11
+ @pytest.fixture
12
+ def server(monkeypatch):
13
+ def handler(request: httpx.Request) -> httpx.Response:
14
+ url = str(request.url)
15
+ if url == MANIFEST_URL:
16
+ return httpx.Response(200, json=V3_MANIFEST)
17
+ if url.endswith("info.json"):
18
+ return httpx.Response(200, json=V3_INFO)
19
+ if url.endswith("default.jpg"):
20
+ return httpx.Response(200, content=b"\xff\xd8fakejpeg")
21
+ return httpx.Response(404)
22
+
23
+ import apud_mcp.server as module
24
+
25
+ monkeypatch.setattr(
26
+ module,
27
+ "IIIFClient",
28
+ lambda: IIIFClient(client=httpx.Client(transport=httpx.MockTransport(handler))),
29
+ )
30
+ return module
31
+
32
+
33
+ def test_describe_manifest(server):
34
+ result = server.describe_manifest(MANIFEST_URL)
35
+ assert result["total_canvases"] == 1
36
+ assert result["canvases"][0]["id"] == CANVAS
37
+
38
+
39
+ def test_view_region_maps_canvas_to_image(server):
40
+ image, meta = server.view_region(MANIFEST_URL, CANVAS, 100, 100, 500, 250, max_side=500)
41
+ # canvas 2000x3000 -> image 4000x6000, so the image region is doubled and then halved to fit
42
+ assert meta["source_url"].endswith("/200,200,1000,500/500,250/0/default.jpg")
43
+ assert meta["to_canvas"] == "canvas_x = 100 + crop_x * 1.0000; canvas_y = 100 + crop_y * 1.0000"
44
+
45
+
46
+ def test_annotate_transcription(server):
47
+ result = server.annotate_transcription(MANIFEST_URL, CANVAS, 10, 20, 300, 40, "Nel mezzo", "it")
48
+ assert result["annotation"]["target"]["selector"]["value"] == "xywh=10,20,300,40"
49
+ assert result["content_state"]