Skip to content

Commit 23db777

Browse files
committed
feat(mcp): add osw-mcp server exposing a live OSL instance
Add an in-repo `osw[mcp]` extra and an `osw-mcp` stdio console script that wraps OswExpress and serves it over the Model Context Protocol for clients such as Claude Code. Tools: semantic/SPARQL/full-text search, category schema introspection, entity read + JSON-LD export, create/update/delete, full page-slot access, and file up/download. - Delete is provenance-guarded: a local JSON ledger records pages the server created/modified; deleting anything untracked requires confirm_external_delete=true. - Credentials resolve from env/.env and are validated up front (fail fast, never prompts, so stdio is never corrupted by an input() call). - osw stdout is redirected to stderr so it never leaks onto the JSON-RPC channel. - OSW_MCP_READ_ONLY hides all mutating tools.
1 parent 4251a0e commit 23db777

22 files changed

Lines changed: 1872 additions & 4 deletions

‎README.md‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ pip install osw
2222
```
2323

2424
Optional extras (`osw[wikitext]`, `osw[DB]`, `osw[S3]`, `osw[dataimport]`,
25-
`osw[UI]`, `osw[all]`) are described in the
25+
`osw[UI]`, `osw[mcp]`, `osw[all]`) are described in the
2626
[Get Started guide](https://opensemanticlab.github.io/osw-python/get-started/).
2727

2828
## Quickstart
@@ -39,6 +39,65 @@ More runnable scripts live in [examples/](examples/), and the
3939
[Basics tutorial](docs/tutorials/basics.ipynb) walks through the
4040
OpenSemanticLab data model.
4141

42+
## MCP server
43+
44+
`osw[mcp]` ships an [MCP](https://modelcontextprotocol.io) server that exposes a
45+
live OpenSemanticLab instance to MCP clients such as Claude Code. It wraps
46+
`OswExpress` and provides tools to search (semantic / SPARQL / full-text),
47+
introspect category schemas, read entities and every page slot, create/update
48+
and delete entities, and upload/download files.
49+
50+
```bash
51+
pip install "osw[mcp]"
52+
```
53+
54+
Configure credentials in a gitignored `.env` file (the server reads them at
55+
startup and never writes them to disk):
56+
57+
```dotenv
58+
OSW_DOMAIN=wiki-dev.open-semantic-lab.org
59+
OSW_USERNAME=your-user
60+
OSW_PASSWORD=your-password
61+
# optional
62+
OSW_SPARQL_ENDPOINT=https://.../sparql
63+
OSW_MCP_READ_ONLY=false # true hides all mutating tools
64+
```
65+
66+
Register it with Claude Code (reference the `.env` via `OSW_MCP_ENV_FILE`; do
67+
not put `OSW_PASSWORD` inline in a committed `.mcp.json`):
68+
69+
```json
70+
{
71+
"mcpServers": {
72+
"osw": {
73+
"command": "uvx",
74+
"args": ["--from", "osw[mcp]", "osw-mcp"],
75+
"env": { "OSW_MCP_ENV_FILE": "/abs/path/to/.env" }
76+
}
77+
}
78+
}
79+
```
80+
81+
Or via the CLI:
82+
83+
```bash
84+
claude mcp add osw --env OSW_MCP_ENV_FILE=/abs/path/to/.env -- uvx --from "osw[mcp]" osw-mcp
85+
```
86+
87+
**Safe deletes:** the server records every entity it creates or modifies in a
88+
local provenance ledger. It deletes those without extra prompting, but refuses
89+
to delete anything it did not create unless the caller passes
90+
`confirm_external_delete=true`.
91+
92+
**Editable-checkout caveat:** `create_or_update_entity` and
93+
`export_entity_jsonld` call `fetch_schema`, which regenerates
94+
`src/osw/model/entity.py` inside the installed package. With a normal
95+
`pip install "osw[mcp]"` this writes into site-packages and is harmless. If you
96+
run the server from an editable source checkout, those two tools will modify the
97+
generated model file in your working tree. The read tools (`get_entity`,
98+
`get_slot`, `get_category_schema`, ...) read raw page slots and never trigger
99+
this.
100+
42101
## Contributing
43102

44103
Contributions are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md).

‎pyproject.toml‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ dataimport = [
6969
"openpyxl",
7070
]
7171
UI = ["pysimplegui"]
72+
mcp = [
73+
# official MCP Python SDK; FastMCP lives in mcp.server.fastmcp
74+
"mcp>=1.2",
75+
# .env loading for the stdio server (OSW_DOMAIN/OSW_USERNAME/OSW_PASSWORD)
76+
"python-dotenv>=1.0",
77+
]
7278
workflow = [
7379
"prefect>=2.20.25,<3.0",
7480
# prefect 2.20.25 is the final 2.x release (no backports). Its
@@ -79,7 +85,11 @@ workflow = [
7985
"anyio>=4.4.0,<4.7",
8086
]
8187
tutorial = ["osw[dataimport]"]
82-
all = ["osw[dataimport,DB,UI,S3,wikitext]"]
88+
all = ["osw[dataimport,DB,UI,S3,wikitext,mcp]"]
89+
90+
[project.scripts]
91+
# stdio MCP server exposing a live OSL instance to MCP clients (e.g. Claude Code)
92+
osw-mcp = "osw.mcp.server:main"
8393

8494
[build-system]
8595
requires = ["hatchling"]
@@ -95,6 +105,9 @@ dev = [
95105
# inherit the capped prefect pin (<3.0); a bare "prefect" here resolved to
96106
# 3.x in CI, whose server API breaks the prefect-2.20-targeted tests
97107
"osw[workflow]",
108+
# MCP server extra installed in dev so its modules type-check (ty) and
109+
# deptry can resolve the mcp/dotenv imports in src/osw/mcp
110+
"osw[mcp]",
98111
"geopy",
99112
"deepl",
100113
"sqlalchemy",
@@ -343,6 +356,8 @@ pybars3-wheel = "pybars"
343356
psycopg2 = "psycopg2"
344357
openpyxl = "openpyxl"
345358
pysimplegui = "PySimpleGUI"
359+
# python-dotenv imports as `dotenv`
360+
python-dotenv = "dotenv"
346361

347362
[tool.deptry.per_rule_ignores]
348363
# DEP002: declared but not imported anywhere in src

‎src/osw/mcp/__init__.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""osw-mcp: an MCP server exposing a live OpenSemanticLab instance.
2+
3+
The server wraps :class:`osw.express.OswExpress` and serves it over the Model
4+
Context Protocol (stdio) so MCP clients such as Claude Code can search, read,
5+
write and manage entities, page slots and files on a live OSL instance.
6+
7+
``main`` is imported lazily so ``import osw.mcp`` does not require the optional
8+
``mcp`` / ``python-dotenv`` dependencies unless the server is actually started.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
__all__ = ["main"]
14+
15+
16+
def __getattr__(name: str):
17+
if name == "main":
18+
from .server import main
19+
20+
return main
21+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

‎src/osw/mcp/__main__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Allow ``python -m osw.mcp`` to launch the server."""
2+
3+
from __future__ import annotations
4+
5+
from .server import main
6+
7+
if __name__ == "__main__":
8+
main()

‎src/osw/mcp/config.py‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Configuration for the osw-mcp server.
2+
3+
Loads settings from the environment (optionally via a ``.env`` file) and
4+
validates that connection credentials are present *before* the server ever
5+
touches the osw library. This matters because ``OswExpress`` / ``SmwSparqlClient``
6+
fall back to an interactive ``input()`` / ``getpass`` prompt when credentials are
7+
missing, which would hang a stdio MCP server (it would read the JSON-RPC stream
8+
as a password). We therefore fail fast with a clear error instead.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
from dataclasses import dataclass, field
15+
from typing import Optional
16+
17+
# python-dotenv is part of the [mcp] extra
18+
from dotenv import load_dotenv
19+
20+
_TRUTHY = {"1", "true", "yes", "on"}
21+
22+
# Environment variable names (OSL_* are accepted as fallbacks, matching osw).
23+
ENV_DOMAIN = ("OSW_DOMAIN", "OSL_DOMAIN")
24+
ENV_USERNAME = ("OSW_USERNAME", "OSL_USERNAME")
25+
ENV_PASSWORD = ("OSW_PASSWORD", "OSL_PASSWORD")
26+
27+
28+
def _first_env(names: tuple[str, ...]) -> Optional[str]:
29+
"""Return the first non-empty environment value among ``names``."""
30+
for name in names:
31+
value = os.getenv(name)
32+
if value:
33+
return value
34+
return None
35+
36+
37+
@dataclass(frozen=True)
38+
class Settings:
39+
"""Resolved, validated server settings."""
40+
41+
domain: str
42+
username: str
43+
# kept only to build the SPARQL client; never returned by any tool
44+
password: str = field(repr=False)
45+
sparql_endpoint: Optional[str] = None
46+
read_only: bool = False
47+
state_dir: Optional[str] = None
48+
max_results: int = 100
49+
max_chars: int = 100_000
50+
51+
def redacted(self) -> dict:
52+
"""A dict view safe for logging / the status tool (no password)."""
53+
return {
54+
"domain": self.domain,
55+
"username": self.username,
56+
"read_only": self.read_only,
57+
"sparql_endpoint_configured": bool(self.sparql_endpoint),
58+
}
59+
60+
61+
def _int_env(name: str, default: int) -> int:
62+
raw = os.getenv(name)
63+
if raw is None or raw.strip() == "":
64+
return default
65+
try:
66+
return int(raw)
67+
except ValueError:
68+
raise RuntimeError(
69+
f"Environment variable {name}={raw!r} is not a valid integer."
70+
)
71+
72+
73+
def load() -> Settings:
74+
"""Load and validate settings from the environment.
75+
76+
Loads a ``.env`` file first: the path in ``OSW_MCP_ENV_FILE`` if set,
77+
otherwise dotenv's default search from the current working directory upward.
78+
79+
Raises
80+
------
81+
RuntimeError
82+
If any of domain / username / password is missing, so the osw
83+
interactive credential prompt is never reached.
84+
"""
85+
env_file = os.getenv("OSW_MCP_ENV_FILE")
86+
if env_file:
87+
load_dotenv(env_file)
88+
else:
89+
load_dotenv()
90+
91+
domain = _first_env(ENV_DOMAIN)
92+
username = _first_env(ENV_USERNAME)
93+
password = _first_env(ENV_PASSWORD)
94+
95+
missing = [
96+
names[0]
97+
for names, value in (
98+
(ENV_DOMAIN, domain),
99+
(ENV_USERNAME, username),
100+
(ENV_PASSWORD, password),
101+
)
102+
if not value
103+
]
104+
if missing:
105+
raise RuntimeError(
106+
"Missing required OSW credential environment variables: "
107+
+ ", ".join(missing)
108+
+ ". Set them in your environment or a .env file "
109+
"(pointed to by OSW_MCP_ENV_FILE). The server refuses to start "
110+
"without them to avoid an interactive credential prompt that would "
111+
"hang the stdio transport."
112+
)
113+
114+
return Settings(
115+
domain=domain,
116+
username=username,
117+
password=password,
118+
sparql_endpoint=os.getenv("OSW_SPARQL_ENDPOINT") or None,
119+
read_only=(os.getenv("OSW_MCP_READ_ONLY", "").lower() in _TRUTHY),
120+
state_dir=os.getenv("OSW_MCP_STATE_DIR") or None,
121+
max_results=_int_env("OSW_MCP_MAX_RESULTS", 100),
122+
max_chars=_int_env("OSW_MCP_MAX_CHARS", 100_000),
123+
)
124+
125+
126+
_settings: Optional[Settings] = None
127+
128+
129+
def get_settings() -> Settings:
130+
"""Return cached settings, loading (and validating) them on first use."""
131+
global _settings
132+
if _settings is None:
133+
_settings = load()
134+
return _settings
135+
136+
137+
def reset() -> None:
138+
"""Drop cached settings (used by tests)."""
139+
global _settings
140+
_settings = None

‎src/osw/mcp/connection.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Shared, thread-safe connection to a live OSL instance.
2+
3+
A single process-wide ``OswExpress`` is built lazily on first use. Because
4+
mwclient's session is not thread-safe and FastMCP runs synchronous tools in a
5+
worker-thread pool, every osw access is serialized through one lock.
6+
7+
The osw library prints progress to ``stdout`` (e.g. "Connecting to ..."), but on
8+
the stdio transport ``stdout`` is the JSON-RPC channel. The :func:`osw_guard`
9+
context manager therefore redirects ``stdout`` to ``stderr`` for the duration of
10+
each osw call (safe because the transport captured its own stream at startup and
11+
the lock guarantees only one redirect at a time).
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import sys
17+
import threading
18+
from contextlib import contextmanager, redirect_stdout
19+
from typing import Callable, Optional
20+
21+
from osw.express import OswExpress
22+
23+
from . import config
24+
from .ledger import Ledger
25+
26+
_LOCK = threading.RLock()
27+
_osw: Optional[OswExpress] = None
28+
_ledger: Optional[Ledger] = None
29+
30+
31+
def get_osw() -> OswExpress:
32+
"""Return the shared ``OswExpress``, connecting on first use.
33+
34+
Credentials and domain are resolved by osw from the environment
35+
(``OSW_DOMAIN`` / ``OSW_USERNAME`` / ``OSW_PASSWORD``), which
36+
:func:`osw.mcp.config.load` has already validated as present.
37+
"""
38+
global _osw
39+
if _osw is None:
40+
settings = config.get_settings()
41+
_osw = OswExpress(domain=settings.domain)
42+
return _osw
43+
44+
45+
def get_ledger() -> Ledger:
46+
"""Return the shared provenance ledger."""
47+
global _ledger
48+
if _ledger is None:
49+
settings = config.get_settings()
50+
_ledger = Ledger(domain=settings.domain, state_dir=settings.state_dir)
51+
return _ledger
52+
53+
54+
@contextmanager
55+
def osw_guard():
56+
"""Serialize osw access and keep osw's stdout off the protocol channel."""
57+
with _LOCK, redirect_stdout(sys.stderr):
58+
yield get_osw()
59+
60+
61+
def run_guarded(fn: Callable[[OswExpress], dict]) -> dict:
62+
"""Run ``fn(osw)`` under the guard, converting exceptions into error dicts.
63+
64+
Keeps tool signatures clean (no ``osw`` parameter leaks into the MCP schema)
65+
and prevents stack traces from reaching the client; the model sees a
66+
structured ``{"error", "type"}`` instead.
67+
"""
68+
try:
69+
with osw_guard() as osw:
70+
return fn(osw)
71+
except Exception as exc:
72+
print(f"[osw-mcp] tool error: {exc!r}", file=sys.stderr)
73+
return {"error": str(exc), "type": type(exc).__name__}
74+
75+
76+
def reset() -> None:
77+
"""Drop the shared connection so the next call rebuilds it."""
78+
global _osw
79+
with _LOCK:
80+
if _osw is not None:
81+
try:
82+
with redirect_stdout(sys.stderr):
83+
_osw.close_connection()
84+
except Exception as exc:
85+
print(f"[osw-mcp] error closing connection: {exc!r}", file=sys.stderr)
86+
_osw = None
87+
88+
89+
def shutdown() -> None:
90+
"""Close the connection on server exit."""
91+
reset()

0 commit comments

Comments
 (0)