sdcreader 4.0.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.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sdcreader
3
- Version: 4.0.0
4
- Summary: Read a published Semantic Data Charter model's package for projection into other standards (CDIF, DCAT-US, DCAT-AP, HealthDCAT-AP).
3
+ Version: 4.2.0
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
7
7
  Project-URL: Homepage, https://github.com/SemanticDataCharter/sdcreader
8
8
  Project-URL: Changelog, https://github.com/SemanticDataCharter/sdcreader/blob/main/CHANGELOG.md
9
- Keywords: sdc,semantic data charter,governed data record,cdif,dcat,dcat-ap,healthdcat-ap,fair
9
+ Keywords: sdc,semantic data charter,governed data record,cdif,dcat,dcat-ap,healthdcat-ap,fair,mcp
10
10
  Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: License :: OSI Approved :: Apache Software License
@@ -17,15 +17,16 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  License-File: NOTICE
19
19
  Requires-Dist: lxml>=5.0
20
+ Requires-Dist: requests>=2.31
20
21
  Provides-Extra: fetch
21
- Requires-Dist: requests>=2.31; extra == "fetch"
22
22
  Provides-Extra: dev
23
23
  Requires-Dist: pytest>=8.0; extra == "dev"
24
- Requires-Dist: requests>=2.31; extra == "dev"
25
24
  Dynamic: license-file
26
25
 
27
26
  # sdcreader
28
27
 
28
+ <!-- mcp-name: io.github.SemanticDataCharter/sdcreader -->
29
+
29
30
  Read a published Semantic Data Charter model's package for projection into other standards.
30
31
 
31
32
  A projection describes a model's governed data records in another standard's terms: CDIF, DCAT-US 3.0, DCAT-AP
@@ -35,8 +36,7 @@ any projection in it. The four projection writers (`sdc_cdif`, `sdc_dcat3_us`, `
35
36
  carried it as identical copies until this package.
36
37
 
37
38
  ```
38
- pip install sdcreader # or, until the PyPI release: pip install "sdcreader @ git+https://github.com/SemanticDataCharter/sdcreader@v4.0.0"
39
- pip install "sdcreader[fetch]" # with HTTP, to fetch a package from the public catalog
39
+ pip install sdcreader # the reader, with HTTP for fetching from the public catalog (since 4.1.0 no extra is needed)
40
40
  ```
41
41
 
42
42
  ```python
@@ -89,6 +89,29 @@ which is complete.
89
89
  - Constants: the leaf, numeric and quantified types; the predicate IRIs `IDENTIFIER`, `SOURCE`, `PUBLISHER`, `EXACT`,
90
90
  `CLOSE`, `SEE_ALSO`, `HAS_UNIT`; `DC_DEFAULTS`.
91
91
 
92
+ ## MCP server
93
+
94
+ `sdcreader` is also an MCP server, registered as `io.github.SemanticDataCharter/sdcreader`, so an assistant can read a
95
+ published model the way `sdcvalidator`, `sdcgovernance` and `sdcreceipt` let it validate, govern and settle a record.
96
+ stdio, no SDK, no account, read-only.
97
+
98
+ ```
99
+ uvx sdcreader # or: sdcreader-mcp, or python -m sdcreader.mcp_server
100
+ uvx sdcreader --host https://your-sdcstudio.example # a sovereign SDCStudio's public catalog
101
+ ```
102
+
103
+ | Tool | Does |
104
+ |---|---|
105
+ | `find_models` | search the public catalog by text, or list a public project's models by name |
106
+ | `describe_model` | one model: title, project, root record, leaf and component counts by type, Dublin Core with SDCStudio's defaults as null, the schema URL pinned to its SHA-256 |
107
+ | `list_leaves` | the leaves in document order with path, type, constraints, code count and identity links, paged |
108
+ | `list_codes` | one component's enumerated values with IRI, definition and code system |
109
+ | `check_package` | the reader's integrity check on a package directory |
110
+
111
+ Each tool takes a `ct_id` (fetched from the host the server was started with) or a package `directory` on disk.
112
+ No tool takes a URL: the host is fixed at start, so a caller cannot point the server elsewhere. Every tool calls the
113
+ same functions as the library; the two interfaces describe one model the same way.
114
+
92
115
  ## Tests
93
116
 
94
117
  ```
@@ -104,6 +127,6 @@ The fixture is the NHANES Participant package of the FAIR Data Demo, the record
104
127
  The leading 4 is the SDC4 reference model, as with every Semantic Data Charter package. An addition to what the
105
128
  reader exposes is a minor version, a fix a patch; the major changes only when the reference model does.
106
129
 
107
- ## Licence
130
+ ## License
108
131
 
109
132
  Apache-2.0 (see `LICENSE`, `NOTICE`).
@@ -1,5 +1,7 @@
1
1
  # sdcreader
2
2
 
3
+ <!-- mcp-name: io.github.SemanticDataCharter/sdcreader -->
4
+
3
5
  Read a published Semantic Data Charter model's package for projection into other standards.
4
6
 
5
7
  A projection describes a model's governed data records in another standard's terms: CDIF, DCAT-US 3.0, DCAT-AP
@@ -9,8 +11,7 @@ any projection in it. The four projection writers (`sdc_cdif`, `sdc_dcat3_us`, `
9
11
  carried it as identical copies until this package.
10
12
 
11
13
  ```
12
- pip install sdcreader # or, until the PyPI release: pip install "sdcreader @ git+https://github.com/SemanticDataCharter/sdcreader@v4.0.0"
13
- pip install "sdcreader[fetch]" # with HTTP, to fetch a package from the public catalog
14
+ pip install sdcreader # the reader, with HTTP for fetching from the public catalog (since 4.1.0 no extra is needed)
14
15
  ```
15
16
 
16
17
  ```python
@@ -63,6 +64,29 @@ which is complete.
63
64
  - Constants: the leaf, numeric and quantified types; the predicate IRIs `IDENTIFIER`, `SOURCE`, `PUBLISHER`, `EXACT`,
64
65
  `CLOSE`, `SEE_ALSO`, `HAS_UNIT`; `DC_DEFAULTS`.
65
66
 
67
+ ## MCP server
68
+
69
+ `sdcreader` is also an MCP server, registered as `io.github.SemanticDataCharter/sdcreader`, so an assistant can read a
70
+ published model the way `sdcvalidator`, `sdcgovernance` and `sdcreceipt` let it validate, govern and settle a record.
71
+ stdio, no SDK, no account, read-only.
72
+
73
+ ```
74
+ uvx sdcreader # or: sdcreader-mcp, or python -m sdcreader.mcp_server
75
+ uvx sdcreader --host https://your-sdcstudio.example # a sovereign SDCStudio's public catalog
76
+ ```
77
+
78
+ | Tool | Does |
79
+ |---|---|
80
+ | `find_models` | search the public catalog by text, or list a public project's models by name |
81
+ | `describe_model` | one model: title, project, root record, leaf and component counts by type, Dublin Core with SDCStudio's defaults as null, the schema URL pinned to its SHA-256 |
82
+ | `list_leaves` | the leaves in document order with path, type, constraints, code count and identity links, paged |
83
+ | `list_codes` | one component's enumerated values with IRI, definition and code system |
84
+ | `check_package` | the reader's integrity check on a package directory |
85
+
86
+ Each tool takes a `ct_id` (fetched from the host the server was started with) or a package `directory` on disk.
87
+ No tool takes a URL: the host is fixed at start, so a caller cannot point the server elsewhere. Every tool calls the
88
+ same functions as the library; the two interfaces describe one model the same way.
89
+
66
90
  ## Tests
67
91
 
68
92
  ```
@@ -78,6 +102,6 @@ The fixture is the NHANES Participant package of the FAIR Data Demo, the record
78
102
  The leading 4 is the SDC4 reference model, as with every Semantic Data Charter package. An addition to what the
79
103
  reader exposes is a minor version, a fix a patch; the major changes only when the reference model does.
80
104
 
81
- ## Licence
105
+ ## License
82
106
 
83
107
  Apache-2.0 (see `LICENSE`, `NOTICE`).
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sdcreader"
7
- version = "4.0.0"
8
- description = "Read a published Semantic Data Charter model's package for projection into other standards (CDIF, DCAT-US, DCAT-AP, HealthDCAT-AP)."
7
+ version = "4.2.0"
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"
11
11
  license = { text = "Apache-2.0" }
12
12
  authors = [{ name = "Axius SDC, Inc." }]
13
- keywords = ["sdc", "semantic data charter", "governed data record", "cdif", "dcat", "dcat-ap", "healthdcat-ap", "fair"]
13
+ keywords = ["sdc", "semantic data charter", "governed data record", "cdif", "dcat", "dcat-ap", "healthdcat-ap", "fair", "mcp"]
14
14
  classifiers = [
15
15
  "Development Status :: 4 - Beta",
16
16
  "Intended Audience :: Developers",
@@ -22,12 +22,21 @@ classifiers = [
22
22
  dependencies = [
23
23
  # the schema is read for the enumeration definitions and codes and the model's Dublin Core header
24
24
  "lxml>=5.0",
25
+ # fetching a package from SDCStudio's public catalog; a base dependency since 4.1.0 so that `uvx sdcreader`
26
+ # (the registry's launch) can search and fetch without an extra
27
+ "requests>=2.31",
25
28
  ]
26
29
 
27
30
  [project.optional-dependencies]
28
- # Fetching a package from SDCStudio's public catalog needs HTTP; a package already on disk does not.
29
- fetch = ["requests>=2.31"]
30
- dev = ["pytest>=8.0", "requests>=2.31"]
31
+ # Kept so `sdcreader[fetch]` keeps installing; HTTP is in the base package since 4.1.0.
32
+ fetch = []
33
+ dev = ["pytest>=8.0"]
34
+
35
+ [project.scripts]
36
+ # One package, two names over one implementation, matching the other SDC MCP servers: the bare name is what the
37
+ # MCP registry launches (`uvx sdcreader`).
38
+ sdcreader = "sdcreader.mcp_server:main"
39
+ sdcreader-mcp = "sdcreader.mcp_server:main"
31
40
 
32
41
  [project.urls]
33
42
  Homepage = "https://github.com/SemanticDataCharter/sdcreader"
@@ -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.0.0"
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,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sdcreader
3
- Version: 4.0.0
4
- Summary: Read a published Semantic Data Charter model's package for projection into other standards (CDIF, DCAT-US, DCAT-AP, HealthDCAT-AP).
3
+ Version: 4.2.0
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
7
7
  Project-URL: Homepage, https://github.com/SemanticDataCharter/sdcreader
8
8
  Project-URL: Changelog, https://github.com/SemanticDataCharter/sdcreader/blob/main/CHANGELOG.md
9
- Keywords: sdc,semantic data charter,governed data record,cdif,dcat,dcat-ap,healthdcat-ap,fair
9
+ Keywords: sdc,semantic data charter,governed data record,cdif,dcat,dcat-ap,healthdcat-ap,fair,mcp
10
10
  Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: License :: OSI Approved :: Apache Software License
@@ -17,15 +17,16 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  License-File: NOTICE
19
19
  Requires-Dist: lxml>=5.0
20
+ Requires-Dist: requests>=2.31
20
21
  Provides-Extra: fetch
21
- Requires-Dist: requests>=2.31; extra == "fetch"
22
22
  Provides-Extra: dev
23
23
  Requires-Dist: pytest>=8.0; extra == "dev"
24
- Requires-Dist: requests>=2.31; extra == "dev"
25
24
  Dynamic: license-file
26
25
 
27
26
  # sdcreader
28
27
 
28
+ <!-- mcp-name: io.github.SemanticDataCharter/sdcreader -->
29
+
29
30
  Read a published Semantic Data Charter model's package for projection into other standards.
30
31
 
31
32
  A projection describes a model's governed data records in another standard's terms: CDIF, DCAT-US 3.0, DCAT-AP
@@ -35,8 +36,7 @@ any projection in it. The four projection writers (`sdc_cdif`, `sdc_dcat3_us`, `
35
36
  carried it as identical copies until this package.
36
37
 
37
38
  ```
38
- pip install sdcreader # or, until the PyPI release: pip install "sdcreader @ git+https://github.com/SemanticDataCharter/sdcreader@v4.0.0"
39
- pip install "sdcreader[fetch]" # with HTTP, to fetch a package from the public catalog
39
+ pip install sdcreader # the reader, with HTTP for fetching from the public catalog (since 4.1.0 no extra is needed)
40
40
  ```
41
41
 
42
42
  ```python
@@ -89,6 +89,29 @@ which is complete.
89
89
  - Constants: the leaf, numeric and quantified types; the predicate IRIs `IDENTIFIER`, `SOURCE`, `PUBLISHER`, `EXACT`,
90
90
  `CLOSE`, `SEE_ALSO`, `HAS_UNIT`; `DC_DEFAULTS`.
91
91
 
92
+ ## MCP server
93
+
94
+ `sdcreader` is also an MCP server, registered as `io.github.SemanticDataCharter/sdcreader`, so an assistant can read a
95
+ published model the way `sdcvalidator`, `sdcgovernance` and `sdcreceipt` let it validate, govern and settle a record.
96
+ stdio, no SDK, no account, read-only.
97
+
98
+ ```
99
+ uvx sdcreader # or: sdcreader-mcp, or python -m sdcreader.mcp_server
100
+ uvx sdcreader --host https://your-sdcstudio.example # a sovereign SDCStudio's public catalog
101
+ ```
102
+
103
+ | Tool | Does |
104
+ |---|---|
105
+ | `find_models` | search the public catalog by text, or list a public project's models by name |
106
+ | `describe_model` | one model: title, project, root record, leaf and component counts by type, Dublin Core with SDCStudio's defaults as null, the schema URL pinned to its SHA-256 |
107
+ | `list_leaves` | the leaves in document order with path, type, constraints, code count and identity links, paged |
108
+ | `list_codes` | one component's enumerated values with IRI, definition and code system |
109
+ | `check_package` | the reader's integrity check on a package directory |
110
+
111
+ Each tool takes a `ct_id` (fetched from the host the server was started with) or a package `directory` on disk.
112
+ No tool takes a URL: the host is fixed at start, so a caller cannot point the server elsewhere. Every tool calls the
113
+ same functions as the library; the two interfaces describe one model the same way.
114
+
92
115
  ## Tests
93
116
 
94
117
  ```
@@ -104,6 +127,6 @@ The fixture is the NHANES Participant package of the FAIR Data Demo, the record
104
127
  The leading 4 is the SDC4 reference model, as with every Semantic Data Charter package. An addition to what the
105
128
  reader exposes is a minor version, a fix a patch; the major changes only when the reference model does.
106
129
 
107
- ## Licence
130
+ ## License
108
131
 
109
132
  Apache-2.0 (see `LICENSE`, `NOTICE`).
@@ -3,11 +3,16 @@ NOTICE
3
3
  README.md
4
4
  pyproject.toml
5
5
  src/sdcreader/__init__.py
6
+ src/sdcreader/mcp.py
7
+ src/sdcreader/mcp_server.py
6
8
  src/sdcreader/model.py
7
9
  src/sdcreader/package.py
8
10
  src/sdcreader.egg-info/PKG-INFO
9
11
  src/sdcreader.egg-info/SOURCES.txt
10
12
  src/sdcreader.egg-info/dependency_links.txt
13
+ src/sdcreader.egg-info/entry_points.txt
11
14
  src/sdcreader.egg-info/requires.txt
12
15
  src/sdcreader.egg-info/top_level.txt
16
+ tests/test_mcp_scaffold.py
17
+ tests/test_mcp_server.py
13
18
  tests/test_reader.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sdcreader = sdcreader.mcp_server:main
3
+ sdcreader-mcp = sdcreader.mcp_server:main
@@ -1,8 +1,7 @@
1
1
  lxml>=5.0
2
+ requests>=2.31
2
3
 
3
4
  [dev]
4
5
  pytest>=8.0
5
- requests>=2.31
6
6
 
7
7
  [fetch]
8
- requests>=2.31
@@ -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"}
@@ -0,0 +1,176 @@
1
+ """The MCP server: JSON-RPC 2.0 handling and the five read-only tools, offline against the NHANES fixture."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ import sdcreader.mcp_server as srv
8
+ from sdcreader.mcp_server import TOOLS, TOOL_HANDLERS, _handle_request
9
+
10
+ SAMPLE = Path(__file__).resolve().parents[1] / "samples" / "nhanes-participant"
11
+ CT = "xy8upneajsb8vdcmnve01g6g"
12
+
13
+
14
+ def rpc(method, params=None, id=1):
15
+ req = {"jsonrpc": "2.0", "id": id, "method": method}
16
+ if params is not None:
17
+ req["params"] = params
18
+ return _handle_request(req)
19
+
20
+
21
+ def call(name, arguments):
22
+ resp = rpc("tools/call", {"name": name, "arguments": arguments})
23
+ assert "result" in resp, resp
24
+ content = resp["result"]["content"]
25
+ assert len(content) == 1 and content[0]["type"] == "text"
26
+ return resp["result"], content[0]["text"]
27
+
28
+
29
+ def call_ok(name, arguments):
30
+ result, text = call(name, arguments)
31
+ assert not result.get("isError"), text
32
+ return json.loads(text)
33
+
34
+
35
+ def call_err(name, arguments):
36
+ result, text = call(name, arguments)
37
+ assert result.get("isError") is True, text
38
+ return text
39
+
40
+
41
+ @pytest.fixture(autouse=True)
42
+ def clear_cache():
43
+ srv._CACHE.clear()
44
+ yield
45
+ srv._CACHE.clear()
46
+
47
+
48
+ class TestProtocol:
49
+ def test_initialize_negotiates_a_supported_revision(self):
50
+ r = rpc("initialize", {"protocolVersion": "2025-06-18"})["result"]
51
+ assert r["protocolVersion"] == "2025-06-18"
52
+ assert r["serverInfo"]["name"] == "sdcreader"
53
+ assert "tools" in r["capabilities"]
54
+ assert "No tool takes a URL" in r["instructions"]
55
+
56
+ def test_initialize_answers_newest_for_an_unknown_revision(self):
57
+ r = rpc("initialize", {"protocolVersion": "2024-11-05"})["result"]
58
+ assert r["protocolVersion"] == srv.MCP_PROTOCOL_VERSION
59
+
60
+ def test_initialized_notification_has_no_response(self):
61
+ assert _handle_request({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
62
+
63
+ def test_tools_list_names_the_five_tools_with_schemas(self):
64
+ tools = rpc("tools/list", {})["result"]["tools"]
65
+ assert {t["name"] for t in tools} == {"find_models", "describe_model", "list_leaves", "list_codes", "check_package"}
66
+ assert set(TOOL_HANDLERS) == {t["name"] for t in TOOLS}
67
+ for t in tools:
68
+ assert t["inputSchema"]["type"] == "object"
69
+ assert t["description"]
70
+
71
+ def test_unknown_tool_and_method_are_protocol_errors(self):
72
+ assert rpc("tools/call", {"name": "nope", "arguments": {}})["error"]["code"] == -32601
73
+ assert rpc("no/such", {})["error"]["code"] == -32601
74
+ assert _handle_request({"jsonrpc": "2.0", "method": "no/such"}) is None
75
+
76
+ def test_non_object_request_and_params_are_invalid_requests(self):
77
+ assert _handle_request(["not", "an", "object"])["error"]["code"] == -32600
78
+ assert rpc("tools/list", "x")["error"]["code"] == -32600
79
+ assert rpc("tools/call", {"name": "describe_model", "arguments": "x"})["error"]["code"] == -32602
80
+
81
+ def test_ping(self):
82
+ assert rpc("ping", {})["result"] == {}
83
+
84
+
85
+ class TestTools:
86
+ def test_describe_model_from_a_directory(self):
87
+ d = call_ok("describe_model", {"directory": str(SAMPLE)})
88
+ assert d["ct_id"] == CT
89
+ assert d["title"] == "NHANES Participant"
90
+ assert d["leaves"] == 153 and d["components"] > d["leaves"]
91
+ assert d["schema_url_pinned"].startswith(f"https://sdcstudio.axius-sdc.com/dmlib/dm-{CT}.xsd?sha256=")
92
+ assert d["schema_is_current"] is True
93
+ assert sum(d["leaves_by_type"].values()) == 153
94
+ assert d["root"]["ct_id"] and d["root"]["label"]
95
+ # defaults are null, never facts
96
+ assert d["dublin_core"]["coverage"] is None or d["dublin_core"]["coverage"] != "Universal"
97
+ assert d["dublin_core"]["relation"] is None or d["dublin_core"]["relation"] != "None"
98
+
99
+ def test_list_leaves_pages_in_document_order(self):
100
+ first = call_ok("list_leaves", {"directory": str(SAMPLE), "limit": 10})
101
+ assert first["total"] == 153 and first["returned"] == 10 and first["next_offset"] == 10
102
+ assert [l["position"] for l in first["leaves"]] == list(range(10))
103
+ last = call_ok("list_leaves", {"directory": str(SAMPLE), "offset": 150, "limit": 10})
104
+ assert last["returned"] == 3 and last["next_offset"] is None
105
+ leaf = first["leaves"][0]
106
+ from sdcreader import LEAF_TYPES
107
+ assert leaf["slug"] and leaf["path"] and leaf["sdc_type"] in LEAF_TYPES
108
+ assert isinstance(leaf["codes"], int) and isinstance(leaf["links"], dict)
109
+
110
+ def test_list_codes_for_an_enumerated_component(self):
111
+ leaves = call_ok("list_leaves", {"directory": str(SAMPLE), "limit": 200})["leaves"]
112
+ coded = next(l for l in leaves if l["codes"] > 0)
113
+ c = call_ok("list_codes", {"directory": str(SAMPLE), "component": coded["ct_id"]})
114
+ assert c["count"] == coded["codes"] and c["component"]["ct_id"] == coded["ct_id"]
115
+ assert all(k["value"] and k["iri"].startswith("http") for k in c["codes"])
116
+
117
+ def test_list_codes_unknown_component_is_a_tool_error(self):
118
+ text = call_err("list_codes", {"directory": str(SAMPLE), "component": "nope"})
119
+ assert "No component" in text
120
+
121
+ def test_source_argument_rules(self):
122
+ assert "exactly one" in call_err("describe_model", {})
123
+ assert "exactly one" in call_err("describe_model", {"ct_id": CT, "directory": str(SAMPLE)})
124
+ assert "integer" in call_err("list_leaves", {"directory": str(SAMPLE), "limit": "ten"})
125
+ assert "between" in call_err("list_leaves", {"directory": str(SAMPLE), "limit": 10_000})
126
+
127
+ def test_check_package_ok_and_refusals(self, tmp_path):
128
+ ok = call_ok("check_package", {"directory": str(SAMPLE)})
129
+ assert ok["ok"] is True and ok["ct_id"] == CT and ok["versions_present"] is True
130
+ bad = call_ok("check_package", {"directory": str(tmp_path)})
131
+ assert bad["ok"] is False and "expected one dm-<ct>.xsd" in bad["error"]
132
+
133
+ def test_a_package_is_read_once_per_process(self):
134
+ call_ok("describe_model", {"directory": str(SAMPLE)})
135
+ call_ok("list_leaves", {"directory": str(SAMPLE)})
136
+ assert len(srv._CACHE) == 1
137
+
138
+ def test_find_models_queries_the_fixed_host(self, monkeypatch):
139
+ import requests
140
+
141
+ seen = {}
142
+
143
+ class R:
144
+ status_code = 200
145
+
146
+ def json(self):
147
+ return {"count": 1, "results": [{"ct_id": CT, "title": "NHANES Participant", "project_name": "FAIR Data Demo", "description": "x", "updated": "2026-01-01"}]}
148
+
149
+ def fake_get(url, params=None, timeout=None):
150
+ seen["url"], seen["params"] = url, params
151
+ return R()
152
+
153
+ monkeypatch.setattr(requests, "get", fake_get)
154
+ r = call_ok("find_models", {"search": "NHANES", "project": "FAIR Data Demo", "page_size": 5})
155
+ assert seen["url"] == f"{srv._HOST}/api/v1/catalog/dms/"
156
+ assert seen["params"]["search"] == "NHANES" and seen["params"]["project"] == "FAIR Data Demo" and seen["params"]["page_size"] == 5
157
+ assert r["count"] == 1 and r["models"][0]["ct_id"] == CT and r["models"][0]["project"] == "FAIR Data Demo"
158
+
159
+ def test_describe_model_by_ct_id_uses_fetch_package(self, monkeypatch):
160
+ from sdcreader import package as pkgmod
161
+
162
+ calls = []
163
+
164
+ def fake_fetch(ct_id, host=None, **kw):
165
+ calls.append((ct_id, host))
166
+ return pkgmod.load_package(SAMPLE, host=host)
167
+
168
+ monkeypatch.setattr(srv, "fetch_package", fake_fetch)
169
+ d = call_ok("describe_model", {"ct_id": CT})
170
+ assert d["ct_id"] == CT and calls == [(CT, srv._HOST)]
171
+
172
+
173
+ @pytest.mark.network
174
+ def test_find_models_against_production():
175
+ r = call_ok("find_models", {"project": "FAIR Data Demo", "page_size": 3})
176
+ assert r["count"] >= 1 and r["models"][0]["project"] == "FAIR Data Demo"
File without changes
File without changes
File without changes