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.
- apud_mcp-0.0.1/.gitignore +9 -0
- apud_mcp-0.0.1/PKG-INFO +20 -0
- apud_mcp-0.0.1/README.md +9 -0
- apud_mcp-0.0.1/pyproject.toml +22 -0
- apud_mcp-0.0.1/src/apud_mcp/__init__.py +0 -0
- apud_mcp-0.0.1/src/apud_mcp/server.py +125 -0
- apud_mcp-0.0.1/tests/conftest.py +4 -0
- apud_mcp-0.0.1/tests/test_server.py +49 -0
apud_mcp-0.0.1/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
apud_mcp-0.0.1/README.md
ADDED
|
@@ -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,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"]
|