sdcreader 4.1.0__tar.gz → 4.2.0__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.
- {sdcreader-4.1.0/src/sdcreader.egg-info → sdcreader-4.2.0}/PKG-INFO +1 -1
- {sdcreader-4.1.0 → sdcreader-4.2.0}/pyproject.toml +1 -1
- {sdcreader-4.1.0 → sdcreader-4.2.0}/src/sdcreader/__init__.py +1 -1
- sdcreader-4.2.0/src/sdcreader/mcp.py +245 -0
- sdcreader-4.2.0/src/sdcreader/mcp_server.py +325 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0/src/sdcreader.egg-info}/PKG-INFO +1 -1
- {sdcreader-4.1.0 → sdcreader-4.2.0}/src/sdcreader.egg-info/SOURCES.txt +2 -0
- sdcreader-4.2.0/tests/test_mcp_scaffold.py +88 -0
- sdcreader-4.1.0/src/sdcreader/mcp_server.py +0 -496
- {sdcreader-4.1.0 → sdcreader-4.2.0}/LICENSE +0 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0}/NOTICE +0 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0}/README.md +0 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0}/setup.cfg +0 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0}/src/sdcreader/model.py +0 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0}/src/sdcreader/package.py +0 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0}/src/sdcreader.egg-info/dependency_links.txt +0 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0}/src/sdcreader.egg-info/entry_points.txt +0 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0}/src/sdcreader.egg-info/requires.txt +0 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0}/src/sdcreader.egg-info/top_level.txt +0 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0}/tests/test_mcp_server.py +0 -0
- {sdcreader-4.1.0 → sdcreader-4.2.0}/tests/test_reader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdcreader
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: Read a published Semantic Data Charter model's package for projection into other standards (CDIF, DCAT-US, DCAT-AP, HealthDCAT-AP); MCP server included.
|
|
5
5
|
Author: Axius SDC, Inc.
|
|
6
6
|
License: Apache-2.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sdcreader"
|
|
7
|
-
version = "4.
|
|
7
|
+
version = "4.2.0"
|
|
8
8
|
description = "Read a published Semantic Data Charter model's package for projection into other standards (CDIF, DCAT-US, DCAT-AP, HealthDCAT-AP); MCP server included."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -12,7 +12,7 @@ from .model import (CLOSE, DC_DEFAULTS, EXACT, HAS_UNIT, IDENTIFIER, LEAF_TYPES,
|
|
|
12
12
|
SOURCE, Code, Component, Leaf, Model, enumeration_iri, read_header, read_model)
|
|
13
13
|
from .package import DEFAULT_HOST, ModelPackage, PackageError, fetch_package, load_package
|
|
14
14
|
|
|
15
|
-
__version__ = "4.
|
|
15
|
+
__version__ = "4.2.0"
|
|
16
16
|
__all__ = ["ModelPackage", "PackageError", "DEFAULT_HOST", "load_package", "fetch_package", "read_model", "read_header", "enumeration_iri",
|
|
17
17
|
"Model", "Component", "Leaf", "Code", "LEAF_TYPES", "NUMERIC_TYPES", "QUANTIFIED_TYPES", "DC_DEFAULTS", "IDENTIFIER", "SOURCE",
|
|
18
18
|
"PUBLISHER", "EXACT", "CLOSE", "SEE_ALSO", "HAS_UNIT", "__version__"]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2026, Axius SDC, Inc.
|
|
3
|
+
# Licensed under the Apache License, Version 2.0.
|
|
4
|
+
#
|
|
5
|
+
"""
|
|
6
|
+
The MCP scaffold the SDC servers share: JSON-RPC 2.0 over stdio, one message
|
|
7
|
+
per line, no SDK.
|
|
8
|
+
|
|
9
|
+
`sdcvalidator`, `sdcgovernance`, `sdcreceipt` and `sdcreader` each carried this
|
|
10
|
+
by hand. The four projection servers would have made eight copies, so it lives
|
|
11
|
+
here once, as the reader itself does: `sdcreader` is the package every
|
|
12
|
+
projection already depends on.
|
|
13
|
+
|
|
14
|
+
What a server built on this gets, and must not re-implement:
|
|
15
|
+
|
|
16
|
+
- `initialize` that negotiates rather than asserts: a client asking for a
|
|
17
|
+
revision in `SUPPORTED_PROTOCOL_VERSIONS` gets it echoed; anything else gets
|
|
18
|
+
the newest and decides for itself. 2026-07-28 removes the initialize
|
|
19
|
+
handshake and is a different architecture, so it is not claimed.
|
|
20
|
+
- Tool failures as `isError` results (SEP-1303), never JSON-RPC errors, so the
|
|
21
|
+
calling model sees what went wrong and can correct itself. A `ToolError`
|
|
22
|
+
is the recoverable kind and travels with its message; anything else is a
|
|
23
|
+
defect and travels without its traceback.
|
|
24
|
+
- `-32600` for a request or `params` that is not an object, `-32602` for
|
|
25
|
+
`arguments` that is not an object, `-32601` for an unknown method or tool,
|
|
26
|
+
`-32700` for a line that is not JSON.
|
|
27
|
+
- `PackageCache`: the `ct_id`-or-`directory` source argument every reader-based
|
|
28
|
+
tool takes, resolved once per process. A published package is immutable, so
|
|
29
|
+
caching it for the life of the server is safe.
|
|
30
|
+
|
|
31
|
+
No tool built on this may take a URL as an argument. The catalog host is fixed
|
|
32
|
+
when the server starts, so a caller cannot point a server at an arbitrary host.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import sys
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any, Callable
|
|
41
|
+
|
|
42
|
+
from sdcreader.model import Model, read_model
|
|
43
|
+
from sdcreader.package import DEFAULT_HOST, ModelPackage, PackageError, fetch_package, load_package
|
|
44
|
+
|
|
45
|
+
JSONRPC_VERSION = "2.0"
|
|
46
|
+
|
|
47
|
+
#: Newest revision this scaffold implements, and the set it negotiates.
|
|
48
|
+
MCP_PROTOCOL_VERSION = "2025-11-25"
|
|
49
|
+
SUPPORTED_PROTOCOL_VERSIONS = ("2025-06-18", "2025-11-25")
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"JSONRPC_VERSION", "MCP_PROTOCOL_VERSION", "SUPPORTED_PROTOCOL_VERSIONS",
|
|
53
|
+
"ToolError", "Server", "PackageCache", "SOURCE_ARGS", "source_properties", "int_arg", "str_arg",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ToolError(Exception):
|
|
58
|
+
"""A tool failure the calling model can act on: bad input, or a package that does not hold up."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
#: The source argument every reader-based tool takes: one model by identifier (fetched from the fixed host) or one
|
|
62
|
+
#: package directory on disk, never both.
|
|
63
|
+
SOURCE_ARGS = {
|
|
64
|
+
"ct_id": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"description": "The model's identifier (CUID2) in the public catalog; the package is fetched from the host this server was started with.",
|
|
67
|
+
},
|
|
68
|
+
"directory": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"description": "A package directory already on disk (dm-<ct>.jsonld and dm-<ct>.xsd, optionally catalog.json and versions.json). Give this or ct_id, not both.",
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def source_properties(plural: bool = False) -> dict:
|
|
76
|
+
"""The JSON Schema properties for the source argument; `plural` gives the list forms a multi-model catalog takes."""
|
|
77
|
+
if not plural:
|
|
78
|
+
return dict(SOURCE_ARGS)
|
|
79
|
+
return {
|
|
80
|
+
"ct_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1,
|
|
81
|
+
"description": "Model identifiers (CUID2) in the public catalog, fetched from the host this server was started with."},
|
|
82
|
+
"directories": {"type": "array", "items": {"type": "string"}, "minItems": 1,
|
|
83
|
+
"description": "Package directories on disk. Give ct_ids or directories or both; at least one model."},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def int_arg(args: dict[str, Any], name: str, default: int, lo: int, hi: int) -> int:
|
|
88
|
+
v = args.get(name, default)
|
|
89
|
+
if isinstance(v, bool) or not isinstance(v, int):
|
|
90
|
+
raise ToolError(f"`{name}` must be an integer.")
|
|
91
|
+
if v < lo or v > hi:
|
|
92
|
+
raise ToolError(f"`{name}` must be between {lo} and {hi}.")
|
|
93
|
+
return v
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def str_arg(args: dict[str, Any], name: str, default: str | None = None, required: bool = False) -> str | None:
|
|
97
|
+
v = args.get(name, default)
|
|
98
|
+
if v is None:
|
|
99
|
+
if required:
|
|
100
|
+
raise ToolError(f"`{name}` is required.")
|
|
101
|
+
return None
|
|
102
|
+
if not isinstance(v, str):
|
|
103
|
+
raise ToolError(f"`{name}` must be a string.")
|
|
104
|
+
return v
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PackageCache:
|
|
108
|
+
"""Packages fetched or loaded in this process, by source, read once."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, host: str = DEFAULT_HOST, storage: dict | None = None, fetcher: Callable | None = None, loader: Callable | None = None):
|
|
111
|
+
self.host = host.rstrip("/")
|
|
112
|
+
self.storage: dict[str, tuple[ModelPackage, Model]] = storage if storage is not None else {}
|
|
113
|
+
# Injectable so a server module can expose `fetch_package` as its own patchable name for tests.
|
|
114
|
+
self.fetcher = fetcher or (lambda ct_id, host: fetch_package(ct_id, host=host))
|
|
115
|
+
self.loader = loader or (lambda directory, host: load_package(directory, host=host))
|
|
116
|
+
|
|
117
|
+
def get(self, ct_id: str | None = None, directory: str | None = None) -> tuple[ModelPackage, Model]:
|
|
118
|
+
if bool(ct_id) == bool(directory):
|
|
119
|
+
raise ToolError("Give exactly one of `ct_id` (fetched from the catalog) or `directory` (a package on disk).")
|
|
120
|
+
if ct_id is not None and not isinstance(ct_id, str):
|
|
121
|
+
raise ToolError("`ct_id` must be a string.")
|
|
122
|
+
if directory is not None and not isinstance(directory, str):
|
|
123
|
+
raise ToolError("`directory` must be a string path.")
|
|
124
|
+
key = ct_id or f"dir:{Path(directory).resolve()}"
|
|
125
|
+
if key in self.storage:
|
|
126
|
+
return self.storage[key]
|
|
127
|
+
try:
|
|
128
|
+
pkg = self.fetcher(ct_id, self.host) if ct_id else self.loader(directory, self.host)
|
|
129
|
+
model = read_model(pkg)
|
|
130
|
+
except PackageError as exc:
|
|
131
|
+
raise ToolError(str(exc)) from exc
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
raise ToolError(f"Could not read the package: {exc}") from exc
|
|
134
|
+
self.storage[key] = (pkg, model)
|
|
135
|
+
return pkg, model
|
|
136
|
+
|
|
137
|
+
def one(self, args: dict[str, Any]) -> tuple[ModelPackage, Model]:
|
|
138
|
+
"""The single source a tool's arguments name."""
|
|
139
|
+
return self.get(args.get("ct_id"), args.get("directory"))
|
|
140
|
+
|
|
141
|
+
def many(self, args: dict[str, Any]) -> list[tuple[ModelPackage, Model]]:
|
|
142
|
+
"""Every source a multi-model tool's arguments name, in the order given, identifiers first."""
|
|
143
|
+
ct_ids = args.get("ct_ids") or []
|
|
144
|
+
dirs = args.get("directories") or []
|
|
145
|
+
if not isinstance(ct_ids, list) or not isinstance(dirs, list) or not all(isinstance(x, str) for x in ct_ids + dirs):
|
|
146
|
+
raise ToolError("`ct_ids` and `directories` must be arrays of strings.")
|
|
147
|
+
if not ct_ids and not dirs:
|
|
148
|
+
raise ToolError("Give at least one model in `ct_ids` or `directories`.")
|
|
149
|
+
return [self.get(ct_id=c) for c in ct_ids] + [self.get(directory=d) for d in dirs]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Server:
|
|
153
|
+
"""One MCP server: a name, a version, its instructions, its tools and their handlers."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, name: str, version: str, instructions: str, tools: list[dict],
|
|
156
|
+
handlers: dict[str, Callable[[dict[str, Any]], Any]], issues_url: str):
|
|
157
|
+
if set(handlers) != {t["name"] for t in tools}:
|
|
158
|
+
raise ValueError("every tool needs a handler and every handler a tool")
|
|
159
|
+
self.name, self.version, self.instructions = name, version, instructions
|
|
160
|
+
self.tools, self.handlers, self.issues_url = tools, handlers, issues_url
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _response(id: Any, result: Any) -> dict:
|
|
164
|
+
return {"jsonrpc": JSONRPC_VERSION, "id": id, "result": result}
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _error(id: Any, code: int, message: str, data: Any = None) -> dict:
|
|
168
|
+
error = {"code": code, "message": message}
|
|
169
|
+
if data is not None:
|
|
170
|
+
error["data"] = data
|
|
171
|
+
return {"jsonrpc": JSONRPC_VERSION, "id": id, "error": error}
|
|
172
|
+
|
|
173
|
+
def _tool_error(self, id: Any, text: str) -> dict:
|
|
174
|
+
return self._response(id, {"content": [{"type": "text", "text": text}], "isError": True})
|
|
175
|
+
|
|
176
|
+
def handle(self, request: Any) -> dict | None:
|
|
177
|
+
"""Handle one JSON-RPC 2.0 request. Returns a response, or None for a notification."""
|
|
178
|
+
if not isinstance(request, dict):
|
|
179
|
+
return self._error(None, -32600, "Invalid request: expected a JSON object.")
|
|
180
|
+
method = request.get("method", "")
|
|
181
|
+
params = request.get("params", {})
|
|
182
|
+
req_id = request.get("id")
|
|
183
|
+
if params is None:
|
|
184
|
+
params = {}
|
|
185
|
+
if not isinstance(params, dict):
|
|
186
|
+
return self._error(req_id, -32600, "Invalid request: `params` must be an object.")
|
|
187
|
+
|
|
188
|
+
if method == "initialize":
|
|
189
|
+
requested = params.get("protocolVersion")
|
|
190
|
+
negotiated = requested if requested in SUPPORTED_PROTOCOL_VERSIONS else MCP_PROTOCOL_VERSION
|
|
191
|
+
return self._response(req_id, {
|
|
192
|
+
"protocolVersion": negotiated,
|
|
193
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
194
|
+
"serverInfo": {"name": self.name, "version": self.version},
|
|
195
|
+
"instructions": self.instructions,
|
|
196
|
+
})
|
|
197
|
+
if method == "notifications/initialized":
|
|
198
|
+
return None
|
|
199
|
+
if method == "tools/list":
|
|
200
|
+
return self._response(req_id, {"tools": self.tools})
|
|
201
|
+
if method == "tools/call":
|
|
202
|
+
tool_name = params.get("name", "")
|
|
203
|
+
tool_args = params.get("arguments", {})
|
|
204
|
+
if tool_args is None:
|
|
205
|
+
tool_args = {}
|
|
206
|
+
if not isinstance(tool_args, dict):
|
|
207
|
+
return self._error(req_id, -32602, "Invalid params: `arguments` must be an object.")
|
|
208
|
+
handler = self.handlers.get(tool_name)
|
|
209
|
+
if handler is None:
|
|
210
|
+
return self._error(req_id, -32601, f"Unknown tool: {tool_name}")
|
|
211
|
+
try:
|
|
212
|
+
result = handler(tool_args)
|
|
213
|
+
except ToolError as exc:
|
|
214
|
+
return self._tool_error(req_id, f"Tool execution error: {exc}")
|
|
215
|
+
except Exception as exc: # a defect in the server, reported without its traceback
|
|
216
|
+
return self._tool_error(req_id, (
|
|
217
|
+
f"Tool execution error: the server could not process this input ({type(exc).__name__}). "
|
|
218
|
+
f"This is a defect worth reporting at {self.issues_url}"))
|
|
219
|
+
if isinstance(result, str):
|
|
220
|
+
text = result
|
|
221
|
+
else:
|
|
222
|
+
text = json.dumps(result, default=str)
|
|
223
|
+
return self._response(req_id, {"content": [{"type": "text", "text": text}]})
|
|
224
|
+
if method == "ping":
|
|
225
|
+
return self._response(req_id, {})
|
|
226
|
+
if req_id is not None:
|
|
227
|
+
return self._error(req_id, -32601, f"Method not found: {method}")
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def run_stdio(self) -> None:
|
|
231
|
+
"""One JSON-RPC message per line on stdin, one response per line on stdout."""
|
|
232
|
+
for line in sys.stdin:
|
|
233
|
+
line = line.strip()
|
|
234
|
+
if not line:
|
|
235
|
+
continue
|
|
236
|
+
try:
|
|
237
|
+
request = json.loads(line)
|
|
238
|
+
except json.JSONDecodeError as exc:
|
|
239
|
+
sys.stdout.write(json.dumps(self._error(None, -32700, f"Parse error: {exc}")) + "\n")
|
|
240
|
+
sys.stdout.flush()
|
|
241
|
+
continue
|
|
242
|
+
response = self.handle(request)
|
|
243
|
+
if response is not None:
|
|
244
|
+
sys.stdout.write(json.dumps(response) + "\n")
|
|
245
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2026, Axius SDC, Inc.
|
|
3
|
+
# Licensed under the Apache License, Version 2.0.
|
|
4
|
+
#
|
|
5
|
+
"""
|
|
6
|
+
MCP stdio server for `sdcreader`.
|
|
7
|
+
|
|
8
|
+
`sdcvalidator`, `sdcgovernance` and `sdcreceipt` are registered MCP servers, so
|
|
9
|
+
an agent can validate a record, evaluate governance over it and settle it. It
|
|
10
|
+
could not read the model those acts are about. This closes that gap: find a
|
|
11
|
+
published model in the public catalog, read its record structure, its
|
|
12
|
+
enumerated values with their codes and definitions, its Dublin Core as the
|
|
13
|
+
modeler wrote it, and the pinned schema URL, with no account anywhere.
|
|
14
|
+
|
|
15
|
+
**Five tools, read-only.** `find_models` searches the public catalog.
|
|
16
|
+
`describe_model` summarizes one model. `list_leaves` walks its leaves in
|
|
17
|
+
document order, paged, because a model can have more than a thousand.
|
|
18
|
+
`list_codes` gives one component's enumerated values. `check_package` runs the
|
|
19
|
+
reader's integrity check on a package directory.
|
|
20
|
+
|
|
21
|
+
**No tool takes a URL.** The catalog host is fixed when the server starts
|
|
22
|
+
(`--host`, default SDCStudio's public catalog), so a caller cannot point this
|
|
23
|
+
server at an arbitrary host. A tool takes either a model identifier, fetched
|
|
24
|
+
from that host, or a package directory already on disk.
|
|
25
|
+
|
|
26
|
+
**A default is never reported as a fact.** Dublin Core comes through
|
|
27
|
+
`Model.dc()`, so SDCStudio's field defaults ("Universal" coverage, "None"
|
|
28
|
+
relation) come back as null rather than as what the modeler said.
|
|
29
|
+
|
|
30
|
+
The protocol handling lives in `sdcreader.mcp`, shared with the projection
|
|
31
|
+
servers. Reuse, do not reimplement: every tool calls the same functions the
|
|
32
|
+
library exposes.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from sdcreader import __version__
|
|
40
|
+
from sdcreader.mcp import (MCP_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, PackageCache, Server, ToolError,
|
|
41
|
+
int_arg, source_properties)
|
|
42
|
+
from sdcreader.model import EXACT, IDENTIFIER, PUBLISHER, SEE_ALSO, SOURCE
|
|
43
|
+
from sdcreader.package import DEFAULT_HOST, fetch_package # noqa: F401 (fetch_package is patched by tests)
|
|
44
|
+
|
|
45
|
+
__all__ = ["TOOLS", "TOOL_HANDLERS", "MCP_PROTOCOL_VERSION", "SUPPORTED_PROTOCOL_VERSIONS", "main", "run_stdio"]
|
|
46
|
+
|
|
47
|
+
SERVER_NAME = "sdcreader"
|
|
48
|
+
SERVER_VERSION = __version__
|
|
49
|
+
ISSUES = "https://github.com/SemanticDataCharter/sdcreader/issues"
|
|
50
|
+
|
|
51
|
+
#: Set once by `main()`, never from tool arguments.
|
|
52
|
+
_HOST: str = DEFAULT_HOST
|
|
53
|
+
#: The process-wide package cache's storage; a published package is immutable.
|
|
54
|
+
_CACHE: dict = {}
|
|
55
|
+
|
|
56
|
+
DEFAULT_PAGE = 50
|
|
57
|
+
MAX_PAGE = 200
|
|
58
|
+
DEFAULT_FIND = 20
|
|
59
|
+
MAX_FIND = 100
|
|
60
|
+
|
|
61
|
+
ReaderToolError = ToolError
|
|
62
|
+
|
|
63
|
+
TOOLS = [
|
|
64
|
+
{
|
|
65
|
+
"name": "find_models",
|
|
66
|
+
"description": (
|
|
67
|
+
"Search SDCStudio's public catalog of published models by text, or list "
|
|
68
|
+
"the models of one public project by name. Needs no account. Returns "
|
|
69
|
+
"identifiers, titles and projects; pass an identifier to describe_model."
|
|
70
|
+
),
|
|
71
|
+
"inputSchema": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"properties": {
|
|
74
|
+
"search": {"type": "string", "description": "Text matched against title and description."},
|
|
75
|
+
"project": {"type": "string", "description": "A public project's exact name, for example NIH_CDE."},
|
|
76
|
+
"page": {"type": "integer", "minimum": 1, "default": 1},
|
|
77
|
+
"page_size": {"type": "integer", "minimum": 1, "maximum": MAX_FIND, "default": DEFAULT_FIND},
|
|
78
|
+
},
|
|
79
|
+
"additionalProperties": False,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "describe_model",
|
|
84
|
+
"description": (
|
|
85
|
+
"Describe one published model: title, description, project, the root "
|
|
86
|
+
"record, how many leaves and components it has and of which types, its "
|
|
87
|
+
"Dublin Core as the modeler wrote it (defaults reported as null), its "
|
|
88
|
+
"semantic links, and the schema URL pinned to the SHA-256 of the bytes "
|
|
89
|
+
"every record is validated against."
|
|
90
|
+
),
|
|
91
|
+
"inputSchema": {"type": "object", "properties": source_properties(), "additionalProperties": False},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"name": "list_leaves",
|
|
95
|
+
"description": (
|
|
96
|
+
"The model's leaves in document order, each with its path of clusters, "
|
|
97
|
+
"its SDC4 type, its constraints, how many enumerated values it has and "
|
|
98
|
+
"its identity links. Paged: a model can have more than a thousand leaves."
|
|
99
|
+
),
|
|
100
|
+
"inputSchema": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {
|
|
103
|
+
**source_properties(),
|
|
104
|
+
"offset": {"type": "integer", "minimum": 0, "default": 0},
|
|
105
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": MAX_PAGE, "default": DEFAULT_PAGE},
|
|
106
|
+
},
|
|
107
|
+
"additionalProperties": False,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "list_codes",
|
|
112
|
+
"description": (
|
|
113
|
+
"The enumerated values of one component in a model: each value with the "
|
|
114
|
+
"IRI, definition and code system the published schema annotates it with."
|
|
115
|
+
),
|
|
116
|
+
"inputSchema": {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": {
|
|
119
|
+
**source_properties(),
|
|
120
|
+
"component": {"type": "string", "description": "The component's identifier (ct_id), as list_leaves reports it."},
|
|
121
|
+
},
|
|
122
|
+
"required": ["component"],
|
|
123
|
+
"additionalProperties": False,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"name": "check_package",
|
|
128
|
+
"description": (
|
|
129
|
+
"Run the reader's integrity check on a package directory: the JSON-LD "
|
|
130
|
+
"names the directory's model, it has components, the schema is an XML "
|
|
131
|
+
"Schema, and its bytes are the published current version when "
|
|
132
|
+
"versions.json is present. Returns the SHA-256 and the pinned schema URL."
|
|
133
|
+
),
|
|
134
|
+
"inputSchema": {
|
|
135
|
+
"type": "object",
|
|
136
|
+
"properties": {"directory": source_properties()["directory"]},
|
|
137
|
+
"required": ["directory"],
|
|
138
|
+
"additionalProperties": False,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _cache() -> PackageCache:
|
|
145
|
+
# Built per call so a test that swaps `_HOST` or patches `fetch_package` on this module is honored; the storage
|
|
146
|
+
# dict is shared, so a package is still read once per process.
|
|
147
|
+
import sdcreader.mcp_server as me
|
|
148
|
+
|
|
149
|
+
return PackageCache(host=_HOST, storage=_CACHE, fetcher=lambda ct_id, host: me.fetch_package(ct_id, host=host))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _links(component) -> dict[str, list[str]]:
|
|
153
|
+
names = {IDENTIFIER: "identifier", SOURCE: "source", PUBLISHER: "publisher", EXACT: "exactMatch", SEE_ALSO: "seeAlso"}
|
|
154
|
+
return {short: list(component.links[pred]) for pred, short in names.items() if component.links.get(pred)}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _handle_find_models(args: dict[str, Any]) -> Any:
|
|
158
|
+
import requests
|
|
159
|
+
|
|
160
|
+
search = args.get("search", "")
|
|
161
|
+
project = args.get("project", "")
|
|
162
|
+
if not isinstance(search, str) or not isinstance(project, str):
|
|
163
|
+
raise ToolError("`search` and `project` must be strings.")
|
|
164
|
+
page = int_arg(args, "page", 1, 1, 10_000)
|
|
165
|
+
page_size = int_arg(args, "page_size", DEFAULT_FIND, 1, MAX_FIND)
|
|
166
|
+
params = {"format": "json", "page": page, "page_size": page_size}
|
|
167
|
+
if search:
|
|
168
|
+
params["search"] = search
|
|
169
|
+
if project:
|
|
170
|
+
params["project"] = project
|
|
171
|
+
try:
|
|
172
|
+
r = requests.get(f"{_HOST}/api/v1/catalog/dms/", params=params, timeout=60)
|
|
173
|
+
except requests.RequestException as exc:
|
|
174
|
+
raise ToolError(f"The catalog could not be reached: {exc}") from exc
|
|
175
|
+
if r.status_code != 200:
|
|
176
|
+
raise ToolError(f"The catalog answered HTTP {r.status_code}.")
|
|
177
|
+
data = r.json()
|
|
178
|
+
return {
|
|
179
|
+
"host": _HOST,
|
|
180
|
+
"count": data.get("count"),
|
|
181
|
+
"page": page,
|
|
182
|
+
"page_size": page_size,
|
|
183
|
+
"models": [
|
|
184
|
+
{"ct_id": m.get("ct_id"), "title": m.get("title"), "project": m.get("project_name"),
|
|
185
|
+
"description": (m.get("description") or "")[:300], "updated": m.get("updated")}
|
|
186
|
+
for m in data.get("results", [])
|
|
187
|
+
],
|
|
188
|
+
"hint": "Pass a ct_id to describe_model. Models are listed only from public projects.",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _handle_describe_model(args: dict[str, Any]) -> Any:
|
|
193
|
+
pkg, model = _cache().one(args)
|
|
194
|
+
by_type: dict[str, int] = {}
|
|
195
|
+
for leaf in model.leaves:
|
|
196
|
+
by_type[leaf.component.sdc_type] = by_type.get(leaf.component.sdc_type, 0) + 1
|
|
197
|
+
dc_names = ("creator", "publisher", "date", "language", "coverage", "relation", "source", "type", "format", "identifier")
|
|
198
|
+
dublin_core = {name: model.dc(name) for name in dc_names}
|
|
199
|
+
dublin_core["contributors"] = model.contributors
|
|
200
|
+
dublin_core["subjects"] = model.subjects
|
|
201
|
+
dublin_core["rights_url"] = model.rights_url or None
|
|
202
|
+
dublin_core["rights_statement"] = model.rights_statement or None
|
|
203
|
+
return {
|
|
204
|
+
"ct_id": model.ct_id,
|
|
205
|
+
"title": model.title,
|
|
206
|
+
"description": model.description,
|
|
207
|
+
"project": pkg.catalog.get("project_name"),
|
|
208
|
+
"catalog_url": pkg.catalog_url,
|
|
209
|
+
"schema_url": pkg.schema_url,
|
|
210
|
+
"schema_sha256": pkg.sha256,
|
|
211
|
+
"schema_url_pinned": pkg.schema_url_pinned,
|
|
212
|
+
"schema_is_current": (pkg.versions.get("current_sha256") in (None, pkg.sha256)),
|
|
213
|
+
"root": {"ct_id": model.root.ct_id, "label": model.root.label, "description": model.root.description, "links": _links(model.root)},
|
|
214
|
+
"components": len(model.components),
|
|
215
|
+
"leaves": len(model.leaves),
|
|
216
|
+
"leaves_by_type": dict(sorted(by_type.items())),
|
|
217
|
+
"enumerated_components": sum(1 for v in model.codes.values() if v),
|
|
218
|
+
"dublin_core": dublin_core,
|
|
219
|
+
"hint": "Dublin Core values are what the modeler wrote; SDCStudio's defaults are reported as null, never as facts.",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _handle_list_leaves(args: dict[str, Any]) -> Any:
|
|
224
|
+
pkg, model = _cache().one(args)
|
|
225
|
+
offset = int_arg(args, "offset", 0, 0, 10_000_000)
|
|
226
|
+
limit = int_arg(args, "limit", DEFAULT_PAGE, 1, MAX_PAGE)
|
|
227
|
+
page = model.leaves[offset : offset + limit]
|
|
228
|
+
leaves = [
|
|
229
|
+
{
|
|
230
|
+
"position": leaf.position, "slug": leaf.slug, "ct_id": leaf.component.ct_id, "label": leaf.component.label,
|
|
231
|
+
"sdc_type": leaf.component.sdc_type, "data_type": leaf.component.data_type, "path": [p.label for p in leaf.path],
|
|
232
|
+
"description": leaf.component.description, "constraints": leaf.component.constraints,
|
|
233
|
+
"codes": len(model.codes.get(leaf.component.ct_id, [])), "links": _links(leaf.component),
|
|
234
|
+
}
|
|
235
|
+
for leaf in page
|
|
236
|
+
]
|
|
237
|
+
return {
|
|
238
|
+
"ct_id": model.ct_id, "title": model.title, "total": len(model.leaves), "offset": offset, "limit": limit,
|
|
239
|
+
"returned": len(leaves), "next_offset": offset + limit if offset + limit < len(model.leaves) else None, "leaves": leaves,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _handle_list_codes(args: dict[str, Any]) -> Any:
|
|
244
|
+
pkg, model = _cache().one(args)
|
|
245
|
+
comp_id = args.get("component")
|
|
246
|
+
if not isinstance(comp_id, str) or not comp_id:
|
|
247
|
+
raise ToolError("`component` must be a component identifier (ct_id).")
|
|
248
|
+
comp = model.components.get(comp_id)
|
|
249
|
+
if comp is None:
|
|
250
|
+
raise ToolError(f"No component {comp_id!r} in model {model.ct_id}. list_leaves reports the identifiers.")
|
|
251
|
+
codes = model.codes.get(comp_id, [])
|
|
252
|
+
return {
|
|
253
|
+
"ct_id": model.ct_id,
|
|
254
|
+
"component": {"ct_id": comp.ct_id, "label": comp.label, "sdc_type": comp.sdc_type, "links": _links(comp)},
|
|
255
|
+
"count": len(codes),
|
|
256
|
+
"codes": [{"value": k.value, "iri": k.iri, "definition": k.definition, "defined_by": k.defined_by} for k in codes],
|
|
257
|
+
"hint": None if codes else "This component has no enumerated values in the published schema.",
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _handle_check_package(args: dict[str, Any]) -> Any:
|
|
262
|
+
from sdcreader.package import PackageError, load_package
|
|
263
|
+
|
|
264
|
+
directory = args.get("directory")
|
|
265
|
+
if not isinstance(directory, str) or not directory:
|
|
266
|
+
raise ToolError("`directory` must be a string path.")
|
|
267
|
+
try:
|
|
268
|
+
pkg = load_package(directory, host=_HOST)
|
|
269
|
+
except PackageError as exc:
|
|
270
|
+
return {"ok": False, "directory": directory, "error": str(exc)}
|
|
271
|
+
except OSError as exc:
|
|
272
|
+
raise ToolError(f"Could not read the package: {exc}") from exc
|
|
273
|
+
return {
|
|
274
|
+
"ok": True, "directory": directory, "ct_id": pkg.ct_id, "schema_sha256": pkg.sha256,
|
|
275
|
+
"schema_url_pinned": pkg.schema_url_pinned, "published_current_sha256": pkg.versions.get("current_sha256"),
|
|
276
|
+
"versions_present": bool(pkg.versions), "catalog_present": bool(pkg.catalog),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
TOOL_HANDLERS = {
|
|
281
|
+
"find_models": _handle_find_models,
|
|
282
|
+
"describe_model": _handle_describe_model,
|
|
283
|
+
"list_leaves": _handle_list_leaves,
|
|
284
|
+
"list_codes": _handle_list_codes,
|
|
285
|
+
"check_package": _handle_check_package,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _server() -> Server:
|
|
290
|
+
return Server(
|
|
291
|
+
name=SERVER_NAME, version=SERVER_VERSION, tools=TOOLS, handlers=TOOL_HANDLERS, issues_url=ISSUES,
|
|
292
|
+
instructions=(
|
|
293
|
+
"Read published Semantic Data Charter models. `find_models` searches "
|
|
294
|
+
f"the public catalog at {_HOST}; `describe_model`, `list_leaves` and "
|
|
295
|
+
"`list_codes` read one model by its ct_id from that host or from a "
|
|
296
|
+
"package directory on disk; `check_package` verifies a directory. "
|
|
297
|
+
"All tools are read-only and need no account. No tool takes a URL."
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _handle_request(request: Any) -> dict | None:
|
|
303
|
+
return _server().handle(request)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def run_stdio() -> None:
|
|
307
|
+
_server().run_stdio()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def main(argv: list[str] | None = None) -> None:
|
|
311
|
+
"""Entry point for `sdcreader` and `sdcreader-mcp`."""
|
|
312
|
+
import argparse
|
|
313
|
+
|
|
314
|
+
global _HOST
|
|
315
|
+
|
|
316
|
+
parser = argparse.ArgumentParser(prog="sdcreader-mcp", description="MCP stdio server for reading published Semantic Data Charter models.")
|
|
317
|
+
parser.add_argument("--host", default=DEFAULT_HOST,
|
|
318
|
+
help=f"The SDCStudio host whose public catalog is read, fixed here so a caller cannot redirect the server. Default {DEFAULT_HOST}.")
|
|
319
|
+
args = parser.parse_args(argv)
|
|
320
|
+
_HOST = args.host.rstrip("/")
|
|
321
|
+
run_stdio()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
main()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdcreader
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: Read a published Semantic Data Charter model's package for projection into other standards (CDIF, DCAT-US, DCAT-AP, HealthDCAT-AP); MCP server included.
|
|
5
5
|
Author: Axius SDC, Inc.
|
|
6
6
|
License: Apache-2.0
|
|
@@ -3,6 +3,7 @@ NOTICE
|
|
|
3
3
|
README.md
|
|
4
4
|
pyproject.toml
|
|
5
5
|
src/sdcreader/__init__.py
|
|
6
|
+
src/sdcreader/mcp.py
|
|
6
7
|
src/sdcreader/mcp_server.py
|
|
7
8
|
src/sdcreader/model.py
|
|
8
9
|
src/sdcreader/package.py
|
|
@@ -12,5 +13,6 @@ src/sdcreader.egg-info/dependency_links.txt
|
|
|
12
13
|
src/sdcreader.egg-info/entry_points.txt
|
|
13
14
|
src/sdcreader.egg-info/requires.txt
|
|
14
15
|
src/sdcreader.egg-info/top_level.txt
|
|
16
|
+
tests/test_mcp_scaffold.py
|
|
15
17
|
tests/test_mcp_server.py
|
|
16
18
|
tests/test_reader.py
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The shared MCP scaffold in sdcreader.mcp: protocol handling, tool errors, and the package cache."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from sdcreader.mcp import PackageCache, Server, ToolError, int_arg, source_properties, str_arg
|
|
7
|
+
|
|
8
|
+
SAMPLE = Path(__file__).resolve().parents[1] / "samples" / "nhanes-participant"
|
|
9
|
+
CT = "xy8upneajsb8vdcmnve01g6g"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make():
|
|
13
|
+
tools = [{"name": "echo", "description": "echo", "inputSchema": {"type": "object"}},
|
|
14
|
+
{"name": "boom", "description": "boom", "inputSchema": {"type": "object"}},
|
|
15
|
+
{"name": "defect", "description": "defect", "inputSchema": {"type": "object"}}]
|
|
16
|
+
handlers = {"echo": lambda a: {"got": a}, "boom": lambda a: (_ for _ in ()).throw(ToolError("bad input")),
|
|
17
|
+
"defect": lambda a: 1 / 0}
|
|
18
|
+
return Server("t", "0.0", "do things", tools, handlers, "https://example.org/issues")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_a_server_needs_a_handler_per_tool_and_vice_versa():
|
|
22
|
+
with pytest.raises(ValueError):
|
|
23
|
+
Server("t", "0", "i", [{"name": "a", "description": "", "inputSchema": {}}], {}, "u")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_initialize_negotiates_and_lists_tools():
|
|
27
|
+
s = make()
|
|
28
|
+
r = s.handle({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})["result"]
|
|
29
|
+
assert r["protocolVersion"] == "2025-06-18" and r["serverInfo"] == {"name": "t", "version": "0.0"} and r["instructions"] == "do things"
|
|
30
|
+
r = s.handle({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "1999-01-01"}})["result"]
|
|
31
|
+
assert r["protocolVersion"] == "2025-11-25"
|
|
32
|
+
assert [t["name"] for t in s.handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})["result"]["tools"]] == ["echo", "boom", "defect"]
|
|
33
|
+
assert s.handle({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
|
|
34
|
+
assert s.handle({"jsonrpc": "2.0", "id": 3, "method": "ping"})["result"] == {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_tool_results_errors_and_defects():
|
|
38
|
+
s = make()
|
|
39
|
+
call = lambda n, a: s.handle({"jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": {"name": n, "arguments": a}})["result"]
|
|
40
|
+
ok = call("echo", {"x": 1})
|
|
41
|
+
assert "isError" not in ok and ok["content"][0]["text"] == '{"got": {"x": 1}}'
|
|
42
|
+
bad = call("boom", {})
|
|
43
|
+
assert bad["isError"] is True and "bad input" in bad["content"][0]["text"]
|
|
44
|
+
defect = call("defect", {})
|
|
45
|
+
assert defect["isError"] is True and "ZeroDivisionError" in defect["content"][0]["text"] and "example.org/issues" in defect["content"][0]["text"]
|
|
46
|
+
assert "Traceback" not in defect["content"][0]["text"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_request_guards():
|
|
50
|
+
s = make()
|
|
51
|
+
assert s.handle(["x"])["error"]["code"] == -32600
|
|
52
|
+
assert s.handle({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": "x"})["error"]["code"] == -32600
|
|
53
|
+
assert s.handle({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "echo", "arguments": []}})["error"]["code"] == -32602
|
|
54
|
+
assert s.handle({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "nope"}})["error"]["code"] == -32601
|
|
55
|
+
assert s.handle({"jsonrpc": "2.0", "id": 1, "method": "no/such"})["error"]["code"] == -32601
|
|
56
|
+
assert s.handle({"jsonrpc": "2.0", "method": "no/such"}) is None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_package_cache_reads_once_and_resolves_singular_and_plural():
|
|
60
|
+
seen = []
|
|
61
|
+
cache = PackageCache(host="https://h.example", loader=lambda d, h: seen.append(d) or __import__("sdcreader").load_package(d, host=h))
|
|
62
|
+
pkg, model = cache.one({"directory": str(SAMPLE)})
|
|
63
|
+
assert model.ct_id == CT and pkg.host == "https://h.example"
|
|
64
|
+
cache.one({"directory": str(SAMPLE)})
|
|
65
|
+
assert len(seen) == 1 and len(cache.storage) == 1
|
|
66
|
+
both = cache.many({"directories": [str(SAMPLE)]})
|
|
67
|
+
assert len(both) == 1 and both[0][1].ct_id == CT
|
|
68
|
+
with pytest.raises(ToolError):
|
|
69
|
+
cache.one({})
|
|
70
|
+
with pytest.raises(ToolError):
|
|
71
|
+
cache.one({"ct_id": CT, "directory": str(SAMPLE)})
|
|
72
|
+
with pytest.raises(ToolError):
|
|
73
|
+
cache.many({})
|
|
74
|
+
with pytest.raises(ToolError):
|
|
75
|
+
cache.many({"ct_ids": "x"})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_argument_helpers_and_source_schemas():
|
|
79
|
+
assert int_arg({"n": 3}, "n", 1, 1, 5) == 3 and int_arg({}, "n", 1, 1, 5) == 1
|
|
80
|
+
for bad in ({"n": "3"}, {"n": True}, {"n": 9}):
|
|
81
|
+
with pytest.raises(ToolError):
|
|
82
|
+
int_arg(bad, "n", 1, 1, 5)
|
|
83
|
+
assert str_arg({"s": "x"}, "s") == "x" and str_arg({}, "s") is None
|
|
84
|
+
with pytest.raises(ToolError):
|
|
85
|
+
str_arg({}, "s", required=True)
|
|
86
|
+
with pytest.raises(ToolError):
|
|
87
|
+
str_arg({"s": 1}, "s")
|
|
88
|
+
assert set(source_properties()) == {"ct_id", "directory"} and set(source_properties(plural=True)) == {"ct_ids", "directories"}
|
|
@@ -1,496 +0,0 @@
|
|
|
1
|
-
#
|
|
2
|
-
# Copyright (c) 2026, Axius SDC, Inc.
|
|
3
|
-
# Licensed under the Apache License, Version 2.0.
|
|
4
|
-
#
|
|
5
|
-
"""
|
|
6
|
-
MCP stdio server for `sdcreader`.
|
|
7
|
-
|
|
8
|
-
`sdcvalidator`, `sdcgovernance` and `sdcreceipt` are registered MCP servers, so
|
|
9
|
-
an agent can validate a record, evaluate governance over it and settle it. It
|
|
10
|
-
could not read the model those acts are about. This closes that gap: find a
|
|
11
|
-
published model in the public catalog, read its record structure, its
|
|
12
|
-
enumerated values with their codes and definitions, its Dublin Core as the
|
|
13
|
-
modeler wrote it, and the pinned schema URL, with no account anywhere.
|
|
14
|
-
|
|
15
|
-
**Five tools, read-only.** `find_models` searches the public catalog.
|
|
16
|
-
`describe_model` summarizes one model. `list_leaves` walks its leaves in
|
|
17
|
-
document order, paged, because a model can have more than a thousand.
|
|
18
|
-
`list_codes` gives one component's enumerated values. `check_package` runs the
|
|
19
|
-
reader's integrity check on a package directory.
|
|
20
|
-
|
|
21
|
-
**No tool takes a URL.** The catalog host is fixed when the server starts
|
|
22
|
-
(`--host`, default SDCStudio's public catalog), so a caller cannot point this
|
|
23
|
-
server at an arbitrary host. A tool takes either a model identifier, fetched
|
|
24
|
-
from that host, or a package directory already on disk.
|
|
25
|
-
|
|
26
|
-
**A default is never reported as a fact.** Dublin Core comes through
|
|
27
|
-
`Model.dc()`, so SDCStudio's field defaults ("Universal" coverage, "None"
|
|
28
|
-
relation) come back as null rather than as what the modeler said.
|
|
29
|
-
|
|
30
|
-
Reuse, do not reimplement: every tool calls the same functions the library
|
|
31
|
-
exposes. If the two interfaces ever describe one model differently, the
|
|
32
|
-
product is broken.
|
|
33
|
-
"""
|
|
34
|
-
|
|
35
|
-
from __future__ import annotations
|
|
36
|
-
|
|
37
|
-
import json
|
|
38
|
-
import sys
|
|
39
|
-
from pathlib import Path
|
|
40
|
-
from typing import Any
|
|
41
|
-
|
|
42
|
-
from sdcreader import __version__
|
|
43
|
-
from sdcreader.model import EXACT, IDENTIFIER, PUBLISHER, SEE_ALSO, SOURCE, Model, read_model
|
|
44
|
-
from sdcreader.package import DEFAULT_HOST, ModelPackage, PackageError, fetch_package, load_package
|
|
45
|
-
|
|
46
|
-
JSONRPC_VERSION = "2.0"
|
|
47
|
-
|
|
48
|
-
#: Newest revision this server implements. `initialize` negotiates rather than
|
|
49
|
-
#: asserting, so a client on an older supported revision is honored. The
|
|
50
|
-
#: 2026-07-28 revision removes the initialize handshake and is not claimed.
|
|
51
|
-
MCP_PROTOCOL_VERSION = "2025-11-25"
|
|
52
|
-
SUPPORTED_PROTOCOL_VERSIONS = ("2025-06-18", "2025-11-25")
|
|
53
|
-
|
|
54
|
-
SERVER_NAME = "sdcreader"
|
|
55
|
-
SERVER_VERSION = __version__
|
|
56
|
-
|
|
57
|
-
#: Set once by `main()`, never from tool arguments.
|
|
58
|
-
_HOST: str = DEFAULT_HOST
|
|
59
|
-
|
|
60
|
-
#: Packages fetched or loaded in this process, by model identifier. A model's
|
|
61
|
-
#: package is immutable once published, so caching it for the life of the
|
|
62
|
-
#: server is safe and keeps a paged walk from re-fetching four files per page.
|
|
63
|
-
_CACHE: dict[str, tuple[ModelPackage, Model]] = {}
|
|
64
|
-
|
|
65
|
-
DEFAULT_PAGE = 50
|
|
66
|
-
MAX_PAGE = 200
|
|
67
|
-
DEFAULT_FIND = 20
|
|
68
|
-
MAX_FIND = 100
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
class ReaderToolError(Exception):
|
|
72
|
-
"""A tool failure the calling model can act on: bad input, or a package that does not hold up."""
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
SOURCE_ARGS = {
|
|
76
|
-
"ct_id": {
|
|
77
|
-
"type": "string",
|
|
78
|
-
"description": "The model's identifier (CUID2) in the public catalog; the package is fetched from the host this server was started with.",
|
|
79
|
-
},
|
|
80
|
-
"directory": {
|
|
81
|
-
"type": "string",
|
|
82
|
-
"description": "A package directory already on disk (dm-<ct>.jsonld and dm-<ct>.xsd, optionally catalog.json and versions.json). Give this or ct_id, not both.",
|
|
83
|
-
},
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
TOOLS = [
|
|
87
|
-
{
|
|
88
|
-
"name": "find_models",
|
|
89
|
-
"description": (
|
|
90
|
-
"Search SDCStudio's public catalog of published models by text, or list "
|
|
91
|
-
"the models of one public project by name. Needs no account. Returns "
|
|
92
|
-
"identifiers, titles and projects; pass an identifier to describe_model."
|
|
93
|
-
),
|
|
94
|
-
"inputSchema": {
|
|
95
|
-
"type": "object",
|
|
96
|
-
"properties": {
|
|
97
|
-
"search": {"type": "string", "description": "Text matched against title and description."},
|
|
98
|
-
"project": {"type": "string", "description": "A public project's exact name, for example NIH_CDE."},
|
|
99
|
-
"page": {"type": "integer", "minimum": 1, "default": 1},
|
|
100
|
-
"page_size": {"type": "integer", "minimum": 1, "maximum": MAX_FIND, "default": DEFAULT_FIND},
|
|
101
|
-
},
|
|
102
|
-
"additionalProperties": False,
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
"name": "describe_model",
|
|
107
|
-
"description": (
|
|
108
|
-
"Describe one published model: title, description, project, the root "
|
|
109
|
-
"record, how many leaves and components it has and of which types, its "
|
|
110
|
-
"Dublin Core as the modeler wrote it (defaults reported as null), its "
|
|
111
|
-
"semantic links, and the schema URL pinned to the SHA-256 of the bytes "
|
|
112
|
-
"every record is validated against."
|
|
113
|
-
),
|
|
114
|
-
"inputSchema": {"type": "object", "properties": dict(SOURCE_ARGS), "additionalProperties": False},
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
"name": "list_leaves",
|
|
118
|
-
"description": (
|
|
119
|
-
"The model's leaves in document order, each with its path of clusters, "
|
|
120
|
-
"its SDC4 type, its constraints, how many enumerated values it has and "
|
|
121
|
-
"its identity links. Paged: a model can have more than a thousand leaves."
|
|
122
|
-
),
|
|
123
|
-
"inputSchema": {
|
|
124
|
-
"type": "object",
|
|
125
|
-
"properties": {
|
|
126
|
-
**SOURCE_ARGS,
|
|
127
|
-
"offset": {"type": "integer", "minimum": 0, "default": 0},
|
|
128
|
-
"limit": {"type": "integer", "minimum": 1, "maximum": MAX_PAGE, "default": DEFAULT_PAGE},
|
|
129
|
-
},
|
|
130
|
-
"additionalProperties": False,
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
"name": "list_codes",
|
|
135
|
-
"description": (
|
|
136
|
-
"The enumerated values of one component in a model: each value with the "
|
|
137
|
-
"IRI, definition and code system the published schema annotates it with."
|
|
138
|
-
),
|
|
139
|
-
"inputSchema": {
|
|
140
|
-
"type": "object",
|
|
141
|
-
"properties": {
|
|
142
|
-
**SOURCE_ARGS,
|
|
143
|
-
"component": {"type": "string", "description": "The component's identifier (ct_id), as list_leaves reports it."},
|
|
144
|
-
},
|
|
145
|
-
"required": ["component"],
|
|
146
|
-
"additionalProperties": False,
|
|
147
|
-
},
|
|
148
|
-
},
|
|
149
|
-
{
|
|
150
|
-
"name": "check_package",
|
|
151
|
-
"description": (
|
|
152
|
-
"Run the reader's integrity check on a package directory: the JSON-LD "
|
|
153
|
-
"names the directory's model, it has components, the schema is an XML "
|
|
154
|
-
"Schema, and its bytes are the published current version when "
|
|
155
|
-
"versions.json is present. Returns the SHA-256 and the pinned schema URL."
|
|
156
|
-
),
|
|
157
|
-
"inputSchema": {
|
|
158
|
-
"type": "object",
|
|
159
|
-
"properties": {"directory": SOURCE_ARGS["directory"]},
|
|
160
|
-
"required": ["directory"],
|
|
161
|
-
"additionalProperties": False,
|
|
162
|
-
},
|
|
163
|
-
},
|
|
164
|
-
]
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def _package(args: dict[str, Any]) -> tuple[ModelPackage, Model]:
|
|
168
|
-
ct_id = args.get("ct_id")
|
|
169
|
-
directory = args.get("directory")
|
|
170
|
-
if bool(ct_id) == bool(directory):
|
|
171
|
-
raise ReaderToolError("Give exactly one of `ct_id` (fetched from the catalog) or `directory` (a package on disk).")
|
|
172
|
-
if ct_id is not None and not isinstance(ct_id, str):
|
|
173
|
-
raise ReaderToolError("`ct_id` must be a string.")
|
|
174
|
-
if directory is not None and not isinstance(directory, str):
|
|
175
|
-
raise ReaderToolError("`directory` must be a string path.")
|
|
176
|
-
key = ct_id or f"dir:{Path(directory).resolve()}"
|
|
177
|
-
if key in _CACHE:
|
|
178
|
-
return _CACHE[key]
|
|
179
|
-
try:
|
|
180
|
-
pkg = fetch_package(ct_id, host=_HOST) if ct_id else load_package(directory, host=_HOST)
|
|
181
|
-
model = read_model(pkg)
|
|
182
|
-
except PackageError as exc:
|
|
183
|
-
raise ReaderToolError(str(exc)) from exc
|
|
184
|
-
except OSError as exc:
|
|
185
|
-
raise ReaderToolError(f"Could not read the package: {exc}") from exc
|
|
186
|
-
_CACHE[key] = (pkg, model)
|
|
187
|
-
return pkg, model
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _int(args: dict[str, Any], name: str, default: int, lo: int, hi: int) -> int:
|
|
191
|
-
v = args.get(name, default)
|
|
192
|
-
if isinstance(v, bool) or not isinstance(v, int):
|
|
193
|
-
raise ReaderToolError(f"`{name}` must be an integer.")
|
|
194
|
-
if v < lo or v > hi:
|
|
195
|
-
raise ReaderToolError(f"`{name}` must be between {lo} and {hi}.")
|
|
196
|
-
return v
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def _links(component) -> dict[str, list[str]]:
|
|
200
|
-
"""The component's semantic links by short predicate name, only those present."""
|
|
201
|
-
names = {IDENTIFIER: "identifier", SOURCE: "source", PUBLISHER: "publisher", EXACT: "exactMatch", SEE_ALSO: "seeAlso"}
|
|
202
|
-
out = {}
|
|
203
|
-
for pred, short in names.items():
|
|
204
|
-
if component.links.get(pred):
|
|
205
|
-
out[short] = list(component.links[pred])
|
|
206
|
-
return out
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def _handle_find_models(args: dict[str, Any]) -> Any:
|
|
210
|
-
import requests # a base dependency since 4.1.0, so the registry's `uvx sdcreader` can search
|
|
211
|
-
|
|
212
|
-
search = args.get("search", "")
|
|
213
|
-
project = args.get("project", "")
|
|
214
|
-
if not isinstance(search, str) or not isinstance(project, str):
|
|
215
|
-
raise ReaderToolError("`search` and `project` must be strings.")
|
|
216
|
-
page = _int(args, "page", 1, 1, 10_000)
|
|
217
|
-
page_size = _int(args, "page_size", DEFAULT_FIND, 1, MAX_FIND)
|
|
218
|
-
params = {"format": "json", "page": page, "page_size": page_size}
|
|
219
|
-
if search:
|
|
220
|
-
params["search"] = search
|
|
221
|
-
if project:
|
|
222
|
-
params["project"] = project
|
|
223
|
-
try:
|
|
224
|
-
r = requests.get(f"{_HOST}/api/v1/catalog/dms/", params=params, timeout=60)
|
|
225
|
-
except requests.RequestException as exc:
|
|
226
|
-
raise ReaderToolError(f"The catalog could not be reached: {exc}") from exc
|
|
227
|
-
if r.status_code != 200:
|
|
228
|
-
raise ReaderToolError(f"The catalog answered HTTP {r.status_code}.")
|
|
229
|
-
data = r.json()
|
|
230
|
-
results = [
|
|
231
|
-
{
|
|
232
|
-
"ct_id": m.get("ct_id"),
|
|
233
|
-
"title": m.get("title"),
|
|
234
|
-
"project": m.get("project_name"),
|
|
235
|
-
"description": (m.get("description") or "")[:300],
|
|
236
|
-
"updated": m.get("updated"),
|
|
237
|
-
}
|
|
238
|
-
for m in data.get("results", [])
|
|
239
|
-
]
|
|
240
|
-
return {
|
|
241
|
-
"host": _HOST,
|
|
242
|
-
"count": data.get("count"),
|
|
243
|
-
"page": page,
|
|
244
|
-
"page_size": page_size,
|
|
245
|
-
"models": results,
|
|
246
|
-
"hint": "Pass a ct_id to describe_model. Models are listed only from public projects.",
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def _handle_describe_model(args: dict[str, Any]) -> Any:
|
|
251
|
-
pkg, model = _package(args)
|
|
252
|
-
by_type: dict[str, int] = {}
|
|
253
|
-
for leaf in model.leaves:
|
|
254
|
-
by_type[leaf.component.sdc_type] = by_type.get(leaf.component.sdc_type, 0) + 1
|
|
255
|
-
dc_names = ("creator", "publisher", "date", "language", "coverage", "relation", "source", "type", "format", "identifier")
|
|
256
|
-
dublin_core = {name: model.dc(name) for name in dc_names}
|
|
257
|
-
dublin_core["contributors"] = model.contributors
|
|
258
|
-
dublin_core["subjects"] = model.subjects
|
|
259
|
-
dublin_core["rights_url"] = model.rights_url or None
|
|
260
|
-
dublin_core["rights_statement"] = model.rights_statement or None
|
|
261
|
-
return {
|
|
262
|
-
"ct_id": model.ct_id,
|
|
263
|
-
"title": model.title,
|
|
264
|
-
"description": model.description,
|
|
265
|
-
"project": pkg.catalog.get("project_name"),
|
|
266
|
-
"catalog_url": pkg.catalog_url,
|
|
267
|
-
"schema_url": pkg.schema_url,
|
|
268
|
-
"schema_sha256": pkg.sha256,
|
|
269
|
-
"schema_url_pinned": pkg.schema_url_pinned,
|
|
270
|
-
"schema_is_current": (pkg.versions.get("current_sha256") in (None, pkg.sha256)),
|
|
271
|
-
"root": {"ct_id": model.root.ct_id, "label": model.root.label, "description": model.root.description, "links": _links(model.root)},
|
|
272
|
-
"components": len(model.components),
|
|
273
|
-
"leaves": len(model.leaves),
|
|
274
|
-
"leaves_by_type": dict(sorted(by_type.items())),
|
|
275
|
-
"enumerated_components": sum(1 for v in model.codes.values() if v),
|
|
276
|
-
"dublin_core": dublin_core,
|
|
277
|
-
"hint": "Dublin Core values are what the modeler wrote; SDCStudio's defaults are reported as null, never as facts.",
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
def _handle_list_leaves(args: dict[str, Any]) -> Any:
|
|
282
|
-
pkg, model = _package(args)
|
|
283
|
-
offset = _int(args, "offset", 0, 0, 10_000_000)
|
|
284
|
-
limit = _int(args, "limit", DEFAULT_PAGE, 1, MAX_PAGE)
|
|
285
|
-
page = model.leaves[offset : offset + limit]
|
|
286
|
-
leaves = []
|
|
287
|
-
for leaf in page:
|
|
288
|
-
c = leaf.component
|
|
289
|
-
leaves.append(
|
|
290
|
-
{
|
|
291
|
-
"position": leaf.position,
|
|
292
|
-
"slug": leaf.slug,
|
|
293
|
-
"ct_id": c.ct_id,
|
|
294
|
-
"label": c.label,
|
|
295
|
-
"sdc_type": c.sdc_type,
|
|
296
|
-
"data_type": c.data_type,
|
|
297
|
-
"path": [p.label for p in leaf.path],
|
|
298
|
-
"description": c.description,
|
|
299
|
-
"constraints": c.constraints,
|
|
300
|
-
"codes": len(model.codes.get(c.ct_id, [])),
|
|
301
|
-
"links": _links(c),
|
|
302
|
-
}
|
|
303
|
-
)
|
|
304
|
-
return {
|
|
305
|
-
"ct_id": model.ct_id,
|
|
306
|
-
"title": model.title,
|
|
307
|
-
"total": len(model.leaves),
|
|
308
|
-
"offset": offset,
|
|
309
|
-
"limit": limit,
|
|
310
|
-
"returned": len(leaves),
|
|
311
|
-
"next_offset": offset + limit if offset + limit < len(model.leaves) else None,
|
|
312
|
-
"leaves": leaves,
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def _handle_list_codes(args: dict[str, Any]) -> Any:
|
|
317
|
-
pkg, model = _package(args)
|
|
318
|
-
comp_id = args.get("component")
|
|
319
|
-
if not isinstance(comp_id, str) or not comp_id:
|
|
320
|
-
raise ReaderToolError("`component` must be a component identifier (ct_id).")
|
|
321
|
-
comp = model.components.get(comp_id)
|
|
322
|
-
if comp is None:
|
|
323
|
-
raise ReaderToolError(f"No component {comp_id!r} in model {model.ct_id}. list_leaves reports the identifiers.")
|
|
324
|
-
codes = model.codes.get(comp_id, [])
|
|
325
|
-
return {
|
|
326
|
-
"ct_id": model.ct_id,
|
|
327
|
-
"component": {"ct_id": comp.ct_id, "label": comp.label, "sdc_type": comp.sdc_type, "links": _links(comp)},
|
|
328
|
-
"count": len(codes),
|
|
329
|
-
"codes": [{"value": k.value, "iri": k.iri, "definition": k.definition, "defined_by": k.defined_by} for k in codes],
|
|
330
|
-
"hint": None if codes else "This component has no enumerated values in the published schema.",
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def _handle_check_package(args: dict[str, Any]) -> Any:
|
|
335
|
-
directory = args.get("directory")
|
|
336
|
-
if not isinstance(directory, str) or not directory:
|
|
337
|
-
raise ReaderToolError("`directory` must be a string path.")
|
|
338
|
-
try:
|
|
339
|
-
pkg = load_package(directory, host=_HOST)
|
|
340
|
-
except PackageError as exc:
|
|
341
|
-
return {"ok": False, "directory": directory, "error": str(exc)}
|
|
342
|
-
except OSError as exc:
|
|
343
|
-
raise ReaderToolError(f"Could not read the package: {exc}") from exc
|
|
344
|
-
return {
|
|
345
|
-
"ok": True,
|
|
346
|
-
"directory": directory,
|
|
347
|
-
"ct_id": pkg.ct_id,
|
|
348
|
-
"schema_sha256": pkg.sha256,
|
|
349
|
-
"schema_url_pinned": pkg.schema_url_pinned,
|
|
350
|
-
"published_current_sha256": pkg.versions.get("current_sha256"),
|
|
351
|
-
"versions_present": bool(pkg.versions),
|
|
352
|
-
"catalog_present": bool(pkg.catalog),
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
TOOL_HANDLERS = {
|
|
357
|
-
"find_models": _handle_find_models,
|
|
358
|
-
"describe_model": _handle_describe_model,
|
|
359
|
-
"list_leaves": _handle_list_leaves,
|
|
360
|
-
"list_codes": _handle_list_codes,
|
|
361
|
-
"check_package": _handle_check_package,
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
def _jsonrpc_response(id: Any, result: Any) -> dict:
|
|
366
|
-
return {"jsonrpc": JSONRPC_VERSION, "id": id, "result": result}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def _jsonrpc_error(id: Any, code: int, message: str, data: Any = None) -> dict:
|
|
370
|
-
error = {"code": code, "message": message}
|
|
371
|
-
if data is not None:
|
|
372
|
-
error["data"] = data
|
|
373
|
-
return {"jsonrpc": JSONRPC_VERSION, "id": id, "error": error}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
def _handle_request(request: Any) -> dict | None:
|
|
377
|
-
"""Handle one JSON-RPC 2.0 request. Returns a response, or None for a notification."""
|
|
378
|
-
if not isinstance(request, dict):
|
|
379
|
-
return _jsonrpc_error(None, -32600, "Invalid request: expected a JSON object.")
|
|
380
|
-
method = request.get("method", "")
|
|
381
|
-
params = request.get("params", {})
|
|
382
|
-
req_id = request.get("id")
|
|
383
|
-
if params is None:
|
|
384
|
-
params = {}
|
|
385
|
-
if not isinstance(params, dict):
|
|
386
|
-
return _jsonrpc_error(req_id, -32600, "Invalid request: `params` must be an object.")
|
|
387
|
-
|
|
388
|
-
if method == "initialize":
|
|
389
|
-
requested = params.get("protocolVersion")
|
|
390
|
-
negotiated = requested if requested in SUPPORTED_PROTOCOL_VERSIONS else MCP_PROTOCOL_VERSION
|
|
391
|
-
return _jsonrpc_response(
|
|
392
|
-
req_id,
|
|
393
|
-
{
|
|
394
|
-
"protocolVersion": negotiated,
|
|
395
|
-
"capabilities": {"tools": {"listChanged": False}},
|
|
396
|
-
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
397
|
-
"instructions": (
|
|
398
|
-
"Read published Semantic Data Charter models. `find_models` searches "
|
|
399
|
-
f"the public catalog at {_HOST}; `describe_model`, `list_leaves` and "
|
|
400
|
-
"`list_codes` read one model by its ct_id from that host or from a "
|
|
401
|
-
"package directory on disk; `check_package` verifies a directory. "
|
|
402
|
-
"All tools are read-only and need no account. No tool takes a URL."
|
|
403
|
-
),
|
|
404
|
-
},
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
elif method == "notifications/initialized":
|
|
408
|
-
return None
|
|
409
|
-
|
|
410
|
-
elif method == "tools/list":
|
|
411
|
-
return _jsonrpc_response(req_id, {"tools": TOOLS})
|
|
412
|
-
|
|
413
|
-
elif method == "tools/call":
|
|
414
|
-
tool_name = params.get("name", "")
|
|
415
|
-
tool_args = params.get("arguments", {})
|
|
416
|
-
if tool_args is None:
|
|
417
|
-
tool_args = {}
|
|
418
|
-
if not isinstance(tool_args, dict):
|
|
419
|
-
return _jsonrpc_error(req_id, -32602, "Invalid params: `arguments` must be an object.")
|
|
420
|
-
handler = TOOL_HANDLERS.get(tool_name)
|
|
421
|
-
if handler is None:
|
|
422
|
-
return _jsonrpc_error(req_id, -32601, f"Unknown tool: {tool_name}")
|
|
423
|
-
try:
|
|
424
|
-
result = handler(tool_args)
|
|
425
|
-
return _jsonrpc_response(req_id, {"content": [{"type": "text", "text": json.dumps(result, default=str)}]})
|
|
426
|
-
except ReaderToolError as exc:
|
|
427
|
-
# SEP-1303: a failure the model can correct is a tool error in the
|
|
428
|
-
# result, not a protocol error it would never see.
|
|
429
|
-
return _jsonrpc_response(req_id, {"content": [{"type": "text", "text": f"Tool execution error: {exc}"}], "isError": True})
|
|
430
|
-
except Exception as exc:
|
|
431
|
-
return _jsonrpc_response(
|
|
432
|
-
req_id,
|
|
433
|
-
{
|
|
434
|
-
"content": [
|
|
435
|
-
{
|
|
436
|
-
"type": "text",
|
|
437
|
-
"text": (
|
|
438
|
-
"Tool execution error: the server could not process this input "
|
|
439
|
-
f"({type(exc).__name__}). This is a defect worth reporting at "
|
|
440
|
-
"https://github.com/SemanticDataCharter/sdcreader/issues"
|
|
441
|
-
),
|
|
442
|
-
}
|
|
443
|
-
],
|
|
444
|
-
"isError": True,
|
|
445
|
-
},
|
|
446
|
-
)
|
|
447
|
-
|
|
448
|
-
elif method == "ping":
|
|
449
|
-
return _jsonrpc_response(req_id, {})
|
|
450
|
-
|
|
451
|
-
else:
|
|
452
|
-
if req_id is not None:
|
|
453
|
-
return _jsonrpc_error(req_id, -32601, f"Method not found: {method}")
|
|
454
|
-
return None
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
def run_stdio() -> None:
|
|
458
|
-
"""One JSON-RPC message per line on stdin, one response per line on stdout. stdio and no SDK."""
|
|
459
|
-
for line in sys.stdin:
|
|
460
|
-
line = line.strip()
|
|
461
|
-
if not line:
|
|
462
|
-
continue
|
|
463
|
-
try:
|
|
464
|
-
request = json.loads(line)
|
|
465
|
-
except json.JSONDecodeError as exc:
|
|
466
|
-
sys.stdout.write(json.dumps(_jsonrpc_error(None, -32700, f"Parse error: {exc}")) + "\n")
|
|
467
|
-
sys.stdout.flush()
|
|
468
|
-
continue
|
|
469
|
-
response = _handle_request(request)
|
|
470
|
-
if response is not None:
|
|
471
|
-
sys.stdout.write(json.dumps(response) + "\n")
|
|
472
|
-
sys.stdout.flush()
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
def main(argv: list[str] | None = None) -> None:
|
|
476
|
-
"""Entry point for `sdcreader` and `sdcreader-mcp`."""
|
|
477
|
-
import argparse
|
|
478
|
-
|
|
479
|
-
global _HOST
|
|
480
|
-
|
|
481
|
-
parser = argparse.ArgumentParser(prog="sdcreader-mcp", description="MCP stdio server for reading published Semantic Data Charter models.")
|
|
482
|
-
parser.add_argument(
|
|
483
|
-
"--host",
|
|
484
|
-
default=DEFAULT_HOST,
|
|
485
|
-
help=(
|
|
486
|
-
"The SDCStudio host whose public catalog is read, fixed here so a caller "
|
|
487
|
-
f"cannot redirect the server. Default {DEFAULT_HOST}."
|
|
488
|
-
),
|
|
489
|
-
)
|
|
490
|
-
args = parser.parse_args(argv)
|
|
491
|
-
_HOST = args.host.rstrip("/")
|
|
492
|
-
run_stdio()
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if __name__ == "__main__":
|
|
496
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|