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