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.
- {sdcreader-4.0.0/src/sdcreader.egg-info → sdcreader-4.2.0}/PKG-INFO +31 -8
- {sdcreader-4.0.0 → sdcreader-4.2.0}/README.md +27 -3
- {sdcreader-4.0.0 → sdcreader-4.2.0}/pyproject.toml +15 -6
- {sdcreader-4.0.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.0.0 → sdcreader-4.2.0/src/sdcreader.egg-info}/PKG-INFO +31 -8
- {sdcreader-4.0.0 → sdcreader-4.2.0}/src/sdcreader.egg-info/SOURCES.txt +5 -0
- sdcreader-4.2.0/src/sdcreader.egg-info/entry_points.txt +3 -0
- {sdcreader-4.0.0 → sdcreader-4.2.0}/src/sdcreader.egg-info/requires.txt +1 -2
- sdcreader-4.2.0/tests/test_mcp_scaffold.py +88 -0
- sdcreader-4.2.0/tests/test_mcp_server.py +176 -0
- {sdcreader-4.0.0 → sdcreader-4.2.0}/LICENSE +0 -0
- {sdcreader-4.0.0 → sdcreader-4.2.0}/NOTICE +0 -0
- {sdcreader-4.0.0 → sdcreader-4.2.0}/setup.cfg +0 -0
- {sdcreader-4.0.0 → sdcreader-4.2.0}/src/sdcreader/model.py +0 -0
- {sdcreader-4.0.0 → sdcreader-4.2.0}/src/sdcreader/package.py +0 -0
- {sdcreader-4.0.0 → sdcreader-4.2.0}/src/sdcreader.egg-info/dependency_links.txt +0 -0
- {sdcreader-4.0.0 → sdcreader-4.2.0}/src/sdcreader.egg-info/top_level.txt +0 -0
- {sdcreader-4.0.0 → sdcreader-4.2.0}/tests/test_reader.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdcreader
|
|
3
|
-
Version: 4.
|
|
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 #
|
|
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
|
-
##
|
|
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 #
|
|
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
|
-
##
|
|
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.
|
|
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
|
-
#
|
|
29
|
-
fetch = [
|
|
30
|
-
dev = ["pytest>=8.0"
|
|
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.
|
|
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.
|
|
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 #
|
|
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
|
-
##
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|