From 23db777f632a3c0bf17a0ec97aaf4a330623a9cb Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Thu, 16 Jul 2026 11:50:06 +0200 Subject: [PATCH 01/28] 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. --- README.md | 61 ++++++- pyproject.toml | 17 +- src/osw/mcp/__init__.py | 21 +++ src/osw/mcp/__main__.py | 8 + src/osw/mcp/config.py | 140 ++++++++++++++++ src/osw/mcp/connection.py | 91 +++++++++++ src/osw/mcp/ledger.py | 138 ++++++++++++++++ src/osw/mcp/serialization.py | 51 ++++++ src/osw/mcp/server.py | 47 ++++++ src/osw/mcp/tools/__init__.py | 19 +++ src/osw/mcp/tools/entities.py | 229 +++++++++++++++++++++++++++ src/osw/mcp/tools/files.py | 87 ++++++++++ src/osw/mcp/tools/schema.py | 41 +++++ src/osw/mcp/tools/search.py | 104 ++++++++++++ src/osw/mcp/tools/slots.py | 141 +++++++++++++++++ src/osw/mcp/tools/status.py | 47 ++++++ tests/integration/test_mcp_server.py | 88 ++++++++++ tests/test_mcp_config.py | 99 ++++++++++++ tests/test_mcp_ledger.py | 78 +++++++++ tests/test_mcp_serialization.py | 58 +++++++ tests/test_mcp_tools.py | 195 +++++++++++++++++++++++ uv.lock | 116 +++++++++++++- 22 files changed, 1872 insertions(+), 4 deletions(-) create mode 100644 src/osw/mcp/__init__.py create mode 100644 src/osw/mcp/__main__.py create mode 100644 src/osw/mcp/config.py create mode 100644 src/osw/mcp/connection.py create mode 100644 src/osw/mcp/ledger.py create mode 100644 src/osw/mcp/serialization.py create mode 100644 src/osw/mcp/server.py create mode 100644 src/osw/mcp/tools/__init__.py create mode 100644 src/osw/mcp/tools/entities.py create mode 100644 src/osw/mcp/tools/files.py create mode 100644 src/osw/mcp/tools/schema.py create mode 100644 src/osw/mcp/tools/search.py create mode 100644 src/osw/mcp/tools/slots.py create mode 100644 src/osw/mcp/tools/status.py create mode 100644 tests/integration/test_mcp_server.py create mode 100644 tests/test_mcp_config.py create mode 100644 tests/test_mcp_ledger.py create mode 100644 tests/test_mcp_serialization.py create mode 100644 tests/test_mcp_tools.py diff --git a/README.md b/README.md index 40bad96..035dc38 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ pip install osw ``` Optional extras (`osw[wikitext]`, `osw[DB]`, `osw[S3]`, `osw[dataimport]`, -`osw[UI]`, `osw[all]`) are described in the +`osw[UI]`, `osw[mcp]`, `osw[all]`) are described in the [Get Started guide](https://opensemanticlab.github.io/osw-python/get-started/). ## Quickstart @@ -39,6 +39,65 @@ More runnable scripts live in [examples/](examples/), and the [Basics tutorial](docs/tutorials/basics.ipynb) walks through the OpenSemanticLab data model. +## MCP server + +`osw[mcp]` ships an [MCP](https://modelcontextprotocol.io) server that exposes a +live OpenSemanticLab instance to MCP clients such as Claude Code. It wraps +`OswExpress` and provides tools to search (semantic / SPARQL / full-text), +introspect category schemas, read entities and every page slot, create/update +and delete entities, and upload/download files. + +```bash +pip install "osw[mcp]" +``` + +Configure credentials in a gitignored `.env` file (the server reads them at +startup and never writes them to disk): + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_USERNAME=your-user +OSW_PASSWORD=your-password +# optional +OSW_SPARQL_ENDPOINT=https://.../sparql +OSW_MCP_READ_ONLY=false # true hides all mutating tools +``` + +Register it with Claude Code (reference the `.env` via `OSW_MCP_ENV_FILE`; do +not put `OSW_PASSWORD` inline in a committed `.mcp.json`): + +```json +{ + "mcpServers": { + "osw": { + "command": "uvx", + "args": ["--from", "osw[mcp]", "osw-mcp"], + "env": { "OSW_MCP_ENV_FILE": "/abs/path/to/.env" } + } + } +} +``` + +Or via the CLI: + +```bash +claude mcp add osw --env OSW_MCP_ENV_FILE=/abs/path/to/.env -- uvx --from "osw[mcp]" osw-mcp +``` + +**Safe deletes:** the server records every entity it creates or modifies in a +local provenance ledger. It deletes those without extra prompting, but refuses +to delete anything it did not create unless the caller passes +`confirm_external_delete=true`. + +**Editable-checkout caveat:** `create_or_update_entity` and +`export_entity_jsonld` call `fetch_schema`, which regenerates +`src/osw/model/entity.py` inside the installed package. With a normal +`pip install "osw[mcp]"` this writes into site-packages and is harmless. If you +run the server from an editable source checkout, those two tools will modify the +generated model file in your working tree. The read tools (`get_entity`, +`get_slot`, `get_category_schema`, ...) read raw page slots and never trigger +this. + ## Contributing Contributions are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/pyproject.toml b/pyproject.toml index ffa589b..6259ad0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,12 @@ dataimport = [ "openpyxl", ] UI = ["pysimplegui"] +mcp = [ + # official MCP Python SDK; FastMCP lives in mcp.server.fastmcp + "mcp>=1.2", + # .env loading for the stdio server (OSW_DOMAIN/OSW_USERNAME/OSW_PASSWORD) + "python-dotenv>=1.0", +] workflow = [ "prefect>=2.20.25,<3.0", # prefect 2.20.25 is the final 2.x release (no backports). Its @@ -79,7 +85,11 @@ workflow = [ "anyio>=4.4.0,<4.7", ] tutorial = ["osw[dataimport]"] -all = ["osw[dataimport,DB,UI,S3,wikitext]"] +all = ["osw[dataimport,DB,UI,S3,wikitext,mcp]"] + +[project.scripts] +# stdio MCP server exposing a live OSL instance to MCP clients (e.g. Claude Code) +osw-mcp = "osw.mcp.server:main" [build-system] requires = ["hatchling"] @@ -95,6 +105,9 @@ dev = [ # inherit the capped prefect pin (<3.0); a bare "prefect" here resolved to # 3.x in CI, whose server API breaks the prefect-2.20-targeted tests "osw[workflow]", + # MCP server extra installed in dev so its modules type-check (ty) and + # deptry can resolve the mcp/dotenv imports in src/osw/mcp + "osw[mcp]", "geopy", "deepl", "sqlalchemy", @@ -343,6 +356,8 @@ pybars3-wheel = "pybars" psycopg2 = "psycopg2" openpyxl = "openpyxl" pysimplegui = "PySimpleGUI" +# python-dotenv imports as `dotenv` +python-dotenv = "dotenv" [tool.deptry.per_rule_ignores] # DEP002: declared but not imported anywhere in src diff --git a/src/osw/mcp/__init__.py b/src/osw/mcp/__init__.py new file mode 100644 index 0000000..aca77bc --- /dev/null +++ b/src/osw/mcp/__init__.py @@ -0,0 +1,21 @@ +"""osw-mcp: an MCP server exposing a live OpenSemanticLab instance. + +The server wraps :class:`osw.express.OswExpress` and serves it over the Model +Context Protocol (stdio) so MCP clients such as Claude Code can search, read, +write and manage entities, page slots and files on a live OSL instance. + +``main`` is imported lazily so ``import osw.mcp`` does not require the optional +``mcp`` / ``python-dotenv`` dependencies unless the server is actually started. +""" + +from __future__ import annotations + +__all__ = ["main"] + + +def __getattr__(name: str): + if name == "main": + from .server import main + + return main + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/osw/mcp/__main__.py b/src/osw/mcp/__main__.py new file mode 100644 index 0000000..eef6a18 --- /dev/null +++ b/src/osw/mcp/__main__.py @@ -0,0 +1,8 @@ +"""Allow ``python -m osw.mcp`` to launch the server.""" + +from __future__ import annotations + +from .server import main + +if __name__ == "__main__": + main() diff --git a/src/osw/mcp/config.py b/src/osw/mcp/config.py new file mode 100644 index 0000000..fd41214 --- /dev/null +++ b/src/osw/mcp/config.py @@ -0,0 +1,140 @@ +"""Configuration for the osw-mcp server. + +Loads settings from the environment (optionally via a ``.env`` file) and +validates that connection credentials are present *before* the server ever +touches the osw library. This matters because ``OswExpress`` / ``SmwSparqlClient`` +fall back to an interactive ``input()`` / ``getpass`` prompt when credentials are +missing, which would hang a stdio MCP server (it would read the JSON-RPC stream +as a password). We therefore fail fast with a clear error instead. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Optional + +# python-dotenv is part of the [mcp] extra +from dotenv import load_dotenv + +_TRUTHY = {"1", "true", "yes", "on"} + +# Environment variable names (OSL_* are accepted as fallbacks, matching osw). +ENV_DOMAIN = ("OSW_DOMAIN", "OSL_DOMAIN") +ENV_USERNAME = ("OSW_USERNAME", "OSL_USERNAME") +ENV_PASSWORD = ("OSW_PASSWORD", "OSL_PASSWORD") + + +def _first_env(names: tuple[str, ...]) -> Optional[str]: + """Return the first non-empty environment value among ``names``.""" + for name in names: + value = os.getenv(name) + if value: + return value + return None + + +@dataclass(frozen=True) +class Settings: + """Resolved, validated server settings.""" + + domain: str + username: str + # kept only to build the SPARQL client; never returned by any tool + password: str = field(repr=False) + sparql_endpoint: Optional[str] = None + read_only: bool = False + state_dir: Optional[str] = None + max_results: int = 100 + max_chars: int = 100_000 + + def redacted(self) -> dict: + """A dict view safe for logging / the status tool (no password).""" + return { + "domain": self.domain, + "username": self.username, + "read_only": self.read_only, + "sparql_endpoint_configured": bool(self.sparql_endpoint), + } + + +def _int_env(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + try: + return int(raw) + except ValueError: + raise RuntimeError( + f"Environment variable {name}={raw!r} is not a valid integer." + ) + + +def load() -> Settings: + """Load and validate settings from the environment. + + Loads a ``.env`` file first: the path in ``OSW_MCP_ENV_FILE`` if set, + otherwise dotenv's default search from the current working directory upward. + + Raises + ------ + RuntimeError + If any of domain / username / password is missing, so the osw + interactive credential prompt is never reached. + """ + env_file = os.getenv("OSW_MCP_ENV_FILE") + if env_file: + load_dotenv(env_file) + else: + load_dotenv() + + domain = _first_env(ENV_DOMAIN) + username = _first_env(ENV_USERNAME) + password = _first_env(ENV_PASSWORD) + + missing = [ + names[0] + for names, value in ( + (ENV_DOMAIN, domain), + (ENV_USERNAME, username), + (ENV_PASSWORD, password), + ) + if not value + ] + if missing: + raise RuntimeError( + "Missing required OSW credential environment variables: " + + ", ".join(missing) + + ". Set them in your environment or a .env file " + "(pointed to by OSW_MCP_ENV_FILE). The server refuses to start " + "without them to avoid an interactive credential prompt that would " + "hang the stdio transport." + ) + + return Settings( + domain=domain, + username=username, + password=password, + sparql_endpoint=os.getenv("OSW_SPARQL_ENDPOINT") or None, + read_only=(os.getenv("OSW_MCP_READ_ONLY", "").lower() in _TRUTHY), + state_dir=os.getenv("OSW_MCP_STATE_DIR") or None, + max_results=_int_env("OSW_MCP_MAX_RESULTS", 100), + max_chars=_int_env("OSW_MCP_MAX_CHARS", 100_000), + ) + + +_settings: Optional[Settings] = None + + +def get_settings() -> Settings: + """Return cached settings, loading (and validating) them on first use.""" + global _settings + if _settings is None: + _settings = load() + return _settings + + +def reset() -> None: + """Drop cached settings (used by tests).""" + global _settings + _settings = None diff --git a/src/osw/mcp/connection.py b/src/osw/mcp/connection.py new file mode 100644 index 0000000..df6a133 --- /dev/null +++ b/src/osw/mcp/connection.py @@ -0,0 +1,91 @@ +"""Shared, thread-safe connection to a live OSL instance. + +A single process-wide ``OswExpress`` is built lazily on first use. Because +mwclient's session is not thread-safe and FastMCP runs synchronous tools in a +worker-thread pool, every osw access is serialized through one lock. + +The osw library prints progress to ``stdout`` (e.g. "Connecting to ..."), but on +the stdio transport ``stdout`` is the JSON-RPC channel. The :func:`osw_guard` +context manager therefore redirects ``stdout`` to ``stderr`` for the duration of +each osw call (safe because the transport captured its own stream at startup and +the lock guarantees only one redirect at a time). +""" + +from __future__ import annotations + +import sys +import threading +from contextlib import contextmanager, redirect_stdout +from typing import Callable, Optional + +from osw.express import OswExpress + +from . import config +from .ledger import Ledger + +_LOCK = threading.RLock() +_osw: Optional[OswExpress] = None +_ledger: Optional[Ledger] = None + + +def get_osw() -> OswExpress: + """Return the shared ``OswExpress``, connecting on first use. + + Credentials and domain are resolved by osw from the environment + (``OSW_DOMAIN`` / ``OSW_USERNAME`` / ``OSW_PASSWORD``), which + :func:`osw.mcp.config.load` has already validated as present. + """ + global _osw + if _osw is None: + settings = config.get_settings() + _osw = OswExpress(domain=settings.domain) + return _osw + + +def get_ledger() -> Ledger: + """Return the shared provenance ledger.""" + global _ledger + if _ledger is None: + settings = config.get_settings() + _ledger = Ledger(domain=settings.domain, state_dir=settings.state_dir) + return _ledger + + +@contextmanager +def osw_guard(): + """Serialize osw access and keep osw's stdout off the protocol channel.""" + with _LOCK, redirect_stdout(sys.stderr): + yield get_osw() + + +def run_guarded(fn: Callable[[OswExpress], dict]) -> dict: + """Run ``fn(osw)`` under the guard, converting exceptions into error dicts. + + Keeps tool signatures clean (no ``osw`` parameter leaks into the MCP schema) + and prevents stack traces from reaching the client; the model sees a + structured ``{"error", "type"}`` instead. + """ + try: + with osw_guard() as osw: + return fn(osw) + except Exception as exc: + print(f"[osw-mcp] tool error: {exc!r}", file=sys.stderr) + return {"error": str(exc), "type": type(exc).__name__} + + +def reset() -> None: + """Drop the shared connection so the next call rebuilds it.""" + global _osw + with _LOCK: + if _osw is not None: + try: + with redirect_stdout(sys.stderr): + _osw.close_connection() + except Exception as exc: + print(f"[osw-mcp] error closing connection: {exc!r}", file=sys.stderr) + _osw = None + + +def shutdown() -> None: + """Close the connection on server exit.""" + reset() diff --git a/src/osw/mcp/ledger.py b/src/osw/mcp/ledger.py new file mode 100644 index 0000000..86f2795 --- /dev/null +++ b/src/osw/mcp/ledger.py @@ -0,0 +1,138 @@ +"""Provenance ledger for the osw-mcp server. + +The server records every page it *creates or modifies* through its own mutating +tools. Deleting a tracked page is allowed automatically; deleting a page the +server never touched requires an explicit ``confirm_external_delete`` override. + +The ledger is a small JSON file (never credentials) stored in an OS-appropriate +state directory, namespaced by domain so multiple instances do not collide. +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Optional + +LEDGER_VERSION = 1 + + +def _default_state_dir() -> Path: + """Return an OS-appropriate per-user state directory (no extra dependency).""" + if sys.platform.startswith("win"): + base = os.getenv("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local") + elif sys.platform == "darwin": + base = os.path.expanduser("~/Library/Application Support") + else: + base = os.getenv("XDG_STATE_HOME") or os.path.expanduser("~/.local/state") + return Path(base) / "osw-mcp" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _safe_domain(domain: str) -> str: + """Turn a domain into a filesystem-safe filename fragment.""" + return "".join(c if c.isalnum() or c in "-._" else "_" for c in domain) + + +class Ledger: + """A JSON-backed record of pages created/modified by this server.""" + + def __init__(self, domain: str, state_dir: Optional[str] = None): + self.domain = domain + base = Path(state_dir) if state_dir else _default_state_dir() + self.path = base / f"ledger-{_safe_domain(domain)}.json" + + # -- persistence ------------------------------------------------------- + def _load(self) -> dict: + if not self.path.is_file(): + return {"version": LEDGER_VERSION, "domain": self.domain, "entries": {}} + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + # A corrupt ledger must not take the server down; start fresh but + # warn so the operator can investigate. + print( + f"[osw-mcp] ledger at {self.path} unreadable ({exc}); " + "starting a new one.", + file=sys.stderr, + ) + return {"version": LEDGER_VERSION, "domain": self.domain, "entries": {}} + data.setdefault("entries", {}) + return data + + def _save(self, data: dict) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_name(self.path.name + f".{os.getpid()}.tmp") + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + os.replace(tmp, self.path) # atomic on POSIX and Windows + + # -- public API -------------------------------------------------------- + def record( + self, + title: str, + *, + op: str, + tool: str, + uuid: Optional[str] = None, + namespace: Optional[str] = None, + change_id: Optional[str] = None, + slots: Optional[List[str]] = None, + ) -> None: + """Upsert a create/update record for ``title`` (idempotent, merging).""" + data = self._load() + entry = data["entries"].get(title) + now = _now() + if entry is None: + entry = { + "title": title, + "uuid": uuid, + "namespace": namespace, + "first_created_at": now, + "last_modified_at": now, + "change_ids": [], + "ops": [], + "tools": [], + "slots_written": [], + "deleted_at": None, + } + data["entries"][title] = entry + entry["last_modified_at"] = now + entry["deleted_at"] = None # a re-created/edited page is tracked again + if uuid and not entry.get("uuid"): + entry["uuid"] = uuid + if namespace and not entry.get("namespace"): + entry["namespace"] = namespace + if change_id and change_id not in entry["change_ids"]: + entry["change_ids"].append(change_id) + entry["ops"].append(op) + if tool not in entry["tools"]: + entry["tools"].append(tool) + for slot in slots or []: + if slot not in entry["slots_written"]: + entry["slots_written"].append(slot) + self._save(data) + + def is_tracked(self, title: str) -> bool: + """True if ``title`` was created/modified by this server and not deleted.""" + entry = self._load()["entries"].get(title) + return entry is not None and entry.get("deleted_at") is None + + def mark_deleted(self, title: str) -> None: + """Mark ``title`` as deleted (kept for audit, not purged).""" + data = self._load() + entry = data["entries"].get(title) + if entry is not None: + entry["deleted_at"] = _now() + self._save(data) + + def entry_count(self) -> int: + """Number of currently-tracked (non-deleted) entries.""" + return sum( + 1 for e in self._load()["entries"].values() if e.get("deleted_at") is None + ) diff --git a/src/osw/mcp/serialization.py b/src/osw/mcp/serialization.py new file mode 100644 index 0000000..780a169 --- /dev/null +++ b/src/osw/mcp/serialization.py @@ -0,0 +1,51 @@ +"""JSON-safety and truncation helpers for tool return values. + +Tool results are sent over the wire as JSON and shown to a model, so they must +be JSON-serializable and reasonably small. These helpers cap list lengths and +large text/JSON blobs, flagging when truncation occurred so the caller can +narrow the query. +""" + +from __future__ import annotations + +import json +from typing import Any, List, Tuple + + +def to_jsonable(obj: Any) -> Any: + """Best-effort conversion of ``obj`` into a JSON-serializable structure. + + Falls back to ``str`` for anything json cannot encode (dates, Paths, etc.). + """ + return json.loads(json.dumps(obj, default=str, ensure_ascii=False)) + + +def cap_list(items: List[Any], limit: int) -> Tuple[List[Any], int, bool]: + """Cap a list to ``limit`` entries. + + Returns ``(capped_items, total_count, truncated)``. + """ + items = list(items) + total = len(items) + if limit is not None and total > limit: + return items[:limit], total, True + return items, total, False + + +def maybe_truncate(value: Any, max_chars: int) -> Tuple[Any, bool]: + """Truncate ``value`` if its JSON/text form exceeds ``max_chars``. + + For strings, the string is truncated directly. For other structures, the + value is returned unchanged when small enough, otherwise a truncated JSON + string of it is returned. Returns ``(value_or_truncated, truncated)``. + """ + if value is None: + return None, False + if isinstance(value, str): + if len(value) > max_chars: + return value[:max_chars], True + return value, False + encoded = json.dumps(value, default=str, ensure_ascii=False) + if len(encoded) > max_chars: + return encoded[:max_chars], True + return to_jsonable(value), False diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py new file mode 100644 index 0000000..d4e95cc --- /dev/null +++ b/src/osw/mcp/server.py @@ -0,0 +1,47 @@ +"""Entry point for the osw-mcp stdio server. + +Run via the ``osw-mcp`` console script or ``python -m osw.mcp``. Connection +credentials come from the environment / a ``.env`` file (see +:mod:`osw.mcp.config`). +""" + +from __future__ import annotations + +import atexit +import sys + +from mcp.server.fastmcp import FastMCP + +from . import config, connection +from .tools import register_all + + +def create_server() -> FastMCP: + """Build the FastMCP server, registering tools per the read-only setting. + + Loads and validates settings first so a missing-credential misconfiguration + fails fast (before any osw call that could trigger an interactive prompt). + """ + settings = config.get_settings() + mcp = FastMCP("osw") + register_all(mcp, include_writes=not settings.read_only) + return mcp + + +def main() -> None: + """Console-script entry point: build the server and serve over stdio.""" + try: + mcp = create_server() + except Exception as exc: + print(f"[osw-mcp] failed to start: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + atexit.register(connection.shutdown) + try: + mcp.run() # defaults to stdio transport + finally: + connection.shutdown() + + +if __name__ == "__main__": + main() diff --git a/src/osw/mcp/tools/__init__.py b/src/osw/mcp/tools/__init__.py new file mode 100644 index 0000000..d7e782b --- /dev/null +++ b/src/osw/mcp/tools/__init__.py @@ -0,0 +1,19 @@ +"""MCP tool groups for the osw-mcp server.""" + +from __future__ import annotations + +from . import entities, files, schema, search, slots, status + + +def register_all(mcp, *, include_writes: bool) -> None: + """Register every tool group on ``mcp``. + + Mutating tools (create/update/delete/upload/set_slot) are only registered + when ``include_writes`` is true, so a read-only server never exposes them. + """ + search.register(mcp) + schema.register(mcp) + entities.register(mcp, include_writes=include_writes) + files.register(mcp, include_writes=include_writes) + slots.register(mcp, include_writes=include_writes) + status.register(mcp) diff --git a/src/osw/mcp/tools/entities.py b/src/osw/mcp/tools/entities.py new file mode 100644 index 0000000..d97e140 --- /dev/null +++ b/src/osw/mcp/tools/entities.py @@ -0,0 +1,229 @@ +"""Entity tools: read entity JSON, export JSON-LD, create/update, delete.""" + +from __future__ import annotations + +import sys +from typing import Optional + +import osw.model.entity as model_entity +from osw.core import OSW, AddOverwriteClassOptions, OverwriteOptions +from osw.wtsite import WtSite + +from .. import config +from ..connection import get_ledger, run_guarded +from ..serialization import maybe_truncate, to_jsonable + +_OVERWRITE = { + "true": OverwriteOptions.true, + "false": OverwriteOptions.false, + "only empty": OverwriteOptions.only_empty, + "replace remote": AddOverwriteClassOptions.replace_remote, + "keep existing": AddOverwriteClassOptions.keep_existing, +} + + +def _parse_overwrite(value: str): + key = str(value).lower().strip() + if key not in _OVERWRITE: + raise ValueError( + f"Invalid overwrite '{value}'. Valid options: {list(_OVERWRITE)}" + ) + return _OVERWRITE[key] + + +def _resolve_category_class(category: str): + """Find the generated model class whose ``type`` default targets ``category``. + + Avoids guessing the datamodel-code-generator class name; matches on the + ``type`` default (e.g. ``["Category:OSW..."]``) instead. + """ + for obj in vars(model_entity).values(): + if not isinstance(obj, type) or not hasattr(obj, "__fields__"): + continue + field = obj.__fields__.get("type") + default = getattr(field, "default", None) if field is not None else None + if default and category in default: + return obj + return None + + +def register(mcp, *, include_writes: bool) -> None: + """Register entity tools; mutating ones only when ``include_writes``.""" + settings = config.get_settings() + + @mcp.tool() + def get_entity(title: str) -> dict: + """Return an entity's stored JSON data (its ``jsondata`` slot). + + ``title`` is a full page name, e.g. ``Item:OSW123...``. Reading the slot + directly does not modify any local files. + """ + + def _run(osw): + page = osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + return {"title": title, "exists": False, "jsondata": None} + content, truncated = maybe_truncate( + page.get_slot_content("jsondata"), settings.max_chars + ) + return { + "title": title, + "exists": True, + "jsondata": content, + "url": page.get_url(), + "truncated": truncated, + } + + return run_guarded(_run) + + @mcp.tool() + def export_entity_jsonld( + title: str, mode: str = "expand", build_rdf: bool = False + ) -> dict: + """Export an entity as JSON-LD (and optionally RDF/Turtle). + + ``mode`` is one of expand | flatten | compact | frame. Note: this loads + the entity with schema auto-fetch, which regenerates the local generated + model module as a side effect. + """ + + def _run(osw): + result = osw.load_entity( + OSW.LoadEntityParam(titles=[title], autofetch_schema=True) + ) + entities = result.entities + if not isinstance(entities, list): + entities = [entities] + if not entities: + return {"error": f"Entity '{title}' not found.", "type": "NotFound"} + export = osw.export_jsonld( + OSW.ExportJsonLdParams( + entities=entities, mode=mode, build_rdf_graph=build_rdf + ) + ) + out = { + "jsonld": to_jsonable(export.documents[0]) if export.documents else None + } + if build_rdf and export.graph is not None: + out["rdf_turtle"] = export.graph.serialize(format="turtle") + return out + + return run_guarded(_run) + + if not include_writes: + return + + @mcp.tool() + def create_or_update_entity( + category: str, + jsondata: dict, + namespace: Optional[str] = None, + overwrite: str = "keep existing", + comment: Optional[str] = None, + ) -> dict: + """Create or update an entity of ``category`` from a ``jsondata`` payload. + + ``category`` is a full category page name (e.g. ``Category:Item``); use + ``get_category_schema`` to learn the valid fields first. ``overwrite`` + controls update behavior: one of true | false | only empty | + replace remote | keep existing. Records the resulting page(s) in the + provenance ledger so they can be deleted without extra confirmation. + """ + ledger = get_ledger() + + def _run(osw): + fetch = osw.fetch_schema( + OSW.FetchSchemaParam(schema_title=category, mode="append") + ) + if fetch.error_messages: + return { + "error": "; ".join(fetch.error_messages), + "type": "SchemaError", + } + cls = _resolve_category_class(category) + if cls is None: + return { + "error": ( + f"Could not resolve a model class for '{category}' after " + "fetching its schema. Check the category page name." + ), + "type": "ClassNotFound", + } + try: + entity = cls(**jsondata) + except Exception as exc: + return { + "error": f"jsondata does not validate against {category}: {exc}", + "type": "ValidationError", + } + store = osw.store_entity( + OSW.StoreEntityParam( + entities=[entity], + namespace=namespace, + overwrite=_parse_overwrite(overwrite), + edit_comment=comment, + bot_edit=True, + ) + ) + titles = list(store.pages.keys()) + for page_title in titles: + ledger.record( + page_title, + op="create_or_update", + tool="create_or_update_entity", + change_id=store.change_id, + slots=["jsondata"], + ) + return { + "titles": titles, + "change_id": store.change_id, + "urls": [f"https://{settings.domain}/wiki/{t}" for t in titles], + } + + return run_guarded(_run) + + @mcp.tool() + def delete_entity( + title: str, + confirm_external_delete: bool = False, + comment: Optional[str] = None, + ) -> dict: + """Delete a page by full title, guarded by provenance. + + Pages this server created/modified (tracked in the ledger) are deleted + without extra confirmation. Deleting any other page requires + ``confirm_external_delete=true``. + """ + ledger = get_ledger() + + def _run(osw): + tracked = ledger.is_tracked(title) + if not tracked and not confirm_external_delete: + return { + "error": ( + f"Refusing to delete '{title}': it was not created by this " + "MCP server. Re-run with confirm_external_delete=true to " + "override." + ), + "type": "ExternalDeleteBlocked", + "title": title, + } + page = osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + return { + "title": title, + "deleted": False, + "error": f"Page '{title}' does not exist.", + "type": "NotFound", + } + if not tracked: + print( + f"[osw-mcp] WARNING: deleting externally-created page " + f"'{title}' (confirm_external_delete=True)", + file=sys.stderr, + ) + page.delete(comment or "[osw-mcp] delete") + ledger.mark_deleted(title) + return {"title": title, "deleted": True} + + return run_guarded(_run) diff --git a/src/osw/mcp/tools/files.py b/src/osw/mcp/tools/files.py new file mode 100644 index 0000000..470b80d --- /dev/null +++ b/src/osw/mcp/tools/files.py @@ -0,0 +1,87 @@ +"""File tools: download a file to local disk, upload a local file to the wiki.""" + +from __future__ import annotations + +from typing import Optional + +from osw.core import OverwriteOptions + +from ..connection import get_ledger, run_guarded + + +def register(mcp, *, include_writes: bool) -> None: + """Register file tools; the uploader only when ``include_writes``.""" + + @mcp.tool() + def download_file( + title_or_url: str, + target_dir: Optional[str] = None, + overwrite: bool = False, + ) -> dict: + """Download a WikiFile to the local disk. + + ``title_or_url`` is a ``File:`` full page title or a file URL. Writes only + to the local filesystem (no wiki mutation). Returns the local path. + """ + + def _run(osw): + result = osw.download_file( + title_or_url, target_dir=target_dir, overwrite=overwrite + ) + return { + "title": title_or_url, + "path": str(result.path) if result.path is not None else None, + } + + return run_guarded(_run) + + if not include_writes: + return + + @mcp.tool() + def upload_file( + source_path: str, + target_title: Optional[str] = None, + overwrite: bool = True, + name: Optional[str] = None, + ) -> dict: + """Upload a local file to the wiki as a WikiFile page. + + ``source_path`` is a path on the local disk. ``target_title`` is an + optional ``File:`` full page title (otherwise auto-generated). Records + the created page in the provenance ledger. + """ + ledger = get_ledger() + overwrite_opt = OverwriteOptions.true if overwrite else OverwriteOptions.false + + def _run(osw): + kwargs = {} + if name: + kwargs["name"] = name + result = osw.upload_file( + source=source_path, + url_or_title=target_title, + overwrite=overwrite_opt, + **kwargs, + ) + title = ( + getattr(result, "target_fpt", None) + or getattr(result, "url_or_title", None) + or getattr(result, "title", None) + ) + try: + url = result.get_url() + except Exception: + url = getattr(result, "url", None) + change_id = getattr(result, "change_id", None) + if title: + ledger.record( + title, + op="create", + tool="upload_file", + change_id=change_id, + slots=["jsondata"], + ) + return {"title": title, "url": url, "change_id": change_id} + + return run_guarded(_run) diff --git a/src/osw/mcp/tools/schema.py b/src/osw/mcp/tools/schema.py new file mode 100644 index 0000000..ef8e8dd --- /dev/null +++ b/src/osw/mcp/tools/schema.py @@ -0,0 +1,41 @@ +"""Schema introspection: fetch a category's JSON Schema so the model can build +valid entities before writing them.""" + +from __future__ import annotations + +from osw.wtsite import WtSite + +from .. import config +from ..connection import run_guarded +from ..serialization import maybe_truncate + + +def register(mcp) -> None: + """Register the read-only schema tool on ``mcp``.""" + settings = config.get_settings() + + @mcp.tool() + def get_category_schema(category: str) -> dict: + """Return the JSON Schema of a category (its ``jsonschema`` slot). + + ``category`` is a full category page name, e.g. ``Category:Item``. The + schema is read directly from the page slot, which - unlike fetching and + generating models - does not modify any local files. Use the returned + schema to construct a valid ``jsondata`` payload for + ``create_or_update_entity``. + """ + + def _run(osw): + page = osw.site.get_page(WtSite.GetPageParam(titles=[category])).pages[0] + if not page.exists: + return {"category": category, "exists": False, "schema": None} + schema = page.get_slot_content("jsonschema") + content, truncated = maybe_truncate(schema, settings.max_chars) + return { + "category": category, + "exists": True, + "schema": content, + "truncated": truncated, + } + + return run_guarded(_run) diff --git a/src/osw/mcp/tools/search.py b/src/osw/mcp/tools/search.py new file mode 100644 index 0000000..35fede6 --- /dev/null +++ b/src/osw/mcp/tools/search.py @@ -0,0 +1,104 @@ +"""Search and query tools: semantic (SMW ask), full-text, instances, SPARQL.""" + +from __future__ import annotations + +from typing import Optional + +from osw.core import OSW +from osw.sparql_client_smw import SmwSparqlClient +from osw.wtsite import WtSite + +from .. import config +from ..connection import run_guarded +from ..serialization import cap_list, to_jsonable + + +def register(mcp) -> None: + """Register read-only search/query tools on ``mcp``.""" + settings = config.get_settings() + + @mcp.tool() + def search_entities(ask_query: str, limit: Optional[int] = None) -> dict: + """Run a Semantic MediaWiki 'ask' query and return matching page titles. + + The query uses SMW ask syntax, e.g. ``[[Category:Item]]`` or + ``[[Category:Item]][[Keyword::sensor]]``. Returns full page titles. + """ + lim = limit or settings.max_results + + def _run(osw): + titles = osw.site.semantic_search( + WtSite.SearchParam(query=ask_query, limit=lim) + ) + capped, total, truncated = cap_list(titles, lim) + return {"titles": capped, "count": total, "truncated": truncated} + + return run_guarded(_run) + + @mcp.tool() + def full_text_search(text: str, limit: Optional[int] = None) -> dict: + """Prefix/full-text search for pages whose title matches ``text``.""" + lim = limit or settings.max_results + + def _run(osw): + titles = osw.site.prefix_search(WtSite.SearchParam(query=text, limit=lim)) + capped, total, truncated = cap_list(titles, lim) + return {"titles": capped, "count": total, "truncated": truncated} + + return run_guarded(_run) + + @mcp.tool() + def list_instances_of_category(category: str, limit: Optional[int] = None) -> dict: + """List full page titles of all instances of a category. + + ``category`` is a full category page name, e.g. ``Category:Item``. + """ + lim = limit or settings.max_results + + def _run(osw): + titles = osw.query_instances( + OSW.QueryInstancesParam(categories=category, limit=lim) + ) + capped, total, truncated = cap_list(titles, lim) + return {"titles": capped, "count": total, "truncated": truncated} + + return run_guarded(_run) + + @mcp.tool() + def sparql_query( + query: str, endpoint: Optional[str] = None, limit: int = 500 + ) -> dict: + """Run a raw SPARQL query against the instance's SPARQL endpoint. + + The endpoint defaults to ``OSW_SPARQL_ENDPOINT``; pass ``endpoint`` to + override. Returns ``{vars, bindings, count, truncated}``. + """ + ep = endpoint or settings.sparql_endpoint + if not ep: + return { + "error": ( + "SPARQL endpoint not configured. Set OSW_SPARQL_ENDPOINT " + "or pass the 'endpoint' argument." + ), + "type": "NotConfigured", + } + + def _run(_osw): + client = SmwSparqlClient( + endpoint=ep, + domain=settings.domain, + auth="basic", + user=settings.username, + password=settings.password, + ) + raw = client.sparqlQuery(query) + bindings = raw.get("results", {}).get("bindings", []) + capped, total, truncated = cap_list(bindings, limit) + return { + "vars": raw.get("head", {}).get("vars", []), + "bindings": to_jsonable(capped), + "count": total, + "truncated": truncated, + } + + return run_guarded(_run) diff --git a/src/osw/mcp/tools/slots.py b/src/osw/mcp/tools/slots.py new file mode 100644 index 0000000..c6cb271 --- /dev/null +++ b/src/osw/mcp/tools/slots.py @@ -0,0 +1,141 @@ +"""Full multi-slot page access: list slots, read a slot, write a slot. + +OSW pages are multi-slot MediaWiki pages. The valid slot keys and their content +models come from :data:`osw.wtsite.SLOTS` (main, jsondata, jsonschema, header, +footer, template, header_template, footer_template, data_template, +schema_template). +""" + +from __future__ import annotations + +from typing import Optional, Union + +from osw.wtsite import SLOTS, WtSite + +from .. import config +from ..connection import get_ledger, run_guarded +from ..serialization import maybe_truncate + + +def _invalid_slot(slot: str) -> dict: + return { + "error": f"Unknown slot '{slot}'. Valid slots: {list(SLOTS)}", + "type": "InvalidSlot", + } + + +def register(mcp, *, include_writes: bool) -> None: + """Register slot tools; the writer only when ``include_writes``.""" + settings = config.get_settings() + + @mcp.tool() + def list_page_slots(title: str) -> dict: + """List the slots present on a page with their content models.""" + + def _run(osw): + page = osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + return { + "title": title, + "exists": False, + "slots": [], + "valid_slot_keys": list(SLOTS), + } + slots = [] + for key in page._slots: + content = page.get_slot_content(key) + slots.append({ + "key": key, + "content_model": page.get_slot_content_model(key), + "empty": content in (None, "", {}, []), + }) + return { + "title": title, + "exists": True, + "slots": slots, + "valid_slot_keys": list(SLOTS), + } + + return run_guarded(_run) + + @mcp.tool() + def get_slot(title: str, slot: str) -> dict: + """Return the content of a single slot of a page. + + ``slot`` must be one of the valid slot keys (see ``list_page_slots``). + """ + if slot not in SLOTS: + return _invalid_slot(slot) + + def _run(osw): + page = osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists or slot not in page._slots: + return {"title": title, "slot": slot, "exists": False, "content": None} + content, truncated = maybe_truncate( + page.get_slot_content(slot), settings.max_chars + ) + return { + "title": title, + "slot": slot, + "exists": True, + "content_model": page.get_slot_content_model(slot), + "content": content, + "truncated": truncated, + } + + return run_guarded(_run) + + if not include_writes: + return + + @mcp.tool() + def set_slot( + title: str, + slot: str, + content: Union[str, dict, list], + comment: Optional[str] = None, + create_if_missing: bool = True, + ) -> dict: + """Write the content of a single slot and save the page. + + JSON slots (jsondata, jsonschema) require an object/array; wikitext slots + require a string. Records the page in the provenance ledger. + """ + if slot not in SLOTS: + return _invalid_slot(slot) + content_model = SLOTS[slot]["content_model"] + if content_model == "json" and not isinstance(content, (dict, list)): + return { + "error": f"Slot '{slot}' is JSON; content must be an object or array.", + "type": "InvalidContent", + } + if content_model == "wikitext" and not isinstance(content, str): + return { + "error": f"Slot '{slot}' is wikitext; content must be a string.", + "type": "InvalidContent", + } + ledger = get_ledger() + + def _run(osw): + page = osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if slot not in page._slots: + if not create_if_missing: + return { + "error": ( + f"Slot '{slot}' does not exist on '{title}' and " + "create_if_missing is false." + ), + "type": "SlotMissing", + } + page.create_slot(slot, content_model) + page.set_slot_content(slot, content) + page.edit(comment=comment or f"[osw-mcp] set_slot {slot}", bot_edit=True) + ledger.record(title, op="update", tool="set_slot", slots=[slot]) + return { + "title": title, + "slot": slot, + "changed": True, + "url": page.get_url(), + } + + return run_guarded(_run) diff --git a/src/osw/mcp/tools/status.py b/src/osw/mcp/tools/status.py new file mode 100644 index 0000000..c3229df --- /dev/null +++ b/src/osw/mcp/tools/status.py @@ -0,0 +1,47 @@ +"""Status / whoami tool: report connection and configuration (no secrets).""" + +from __future__ import annotations + +import sys + +from .. import config +from ..connection import get_ledger, osw_guard + + +def _osw_version(): + try: + from importlib.metadata import version + + return version("osw") + except Exception: + return None + + +def register(mcp) -> None: + """Register the read-only status tool on ``mcp``.""" + + @mcp.tool() + def status() -> dict: + """Report the connected domain, user, mode and ledger info. + + Performs a light connectivity check. Never returns the password. + """ + settings = config.get_settings() + ledger = get_ledger() + info = { + **settings.redacted(), + "ledger_path": str(ledger.path), + "ledger_entry_count": ledger.entry_count(), + "osw_version": _osw_version(), + } + try: + with osw_guard(): + info["connected"] = True + except Exception as exc: + print( + f"[osw-mcp] status connection check failed: {exc!r}", + file=sys.stderr, + ) + info["connected"] = False + info["connection_error"] = str(exc) + return info diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py new file mode 100644 index 0000000..894527c --- /dev/null +++ b/tests/integration/test_mcp_server.py @@ -0,0 +1,88 @@ +"""Integration tests for the osw-mcp server against a live OSL instance. + +Excluded from the default run (tests/integration is ignored). Provide live +credentials to run: + + uv run pytest tests/integration/test_mcp_server.py -o addopts="" \ + --wiki_domain --wiki_username --wiki_password + +The wiki_* fixtures self-skip when credentials are absent. +""" + +import pytest + +from osw.mcp import config, connection +from osw.mcp.tools import entities, schema, search, slots, status + + +class _Collector: + """Captures @tool-decorated functions so they can be called directly.""" + + def __init__(self): + self.tools = {} + + def tool(self, *_a, **_k): + def deco(fn): + self.tools[fn.__name__] = fn + return fn + + return deco + + +@pytest.fixture +def mcp_tools(wiki_domain, wiki_username, wiki_password, tmp_path, monkeypatch): + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) + monkeypatch.setenv("OSW_DOMAIN", wiki_domain) + monkeypatch.setenv("OSW_USERNAME", wiki_username) + monkeypatch.setenv("OSW_PASSWORD", wiki_password) + monkeypatch.setenv("OSW_MCP_STATE_DIR", str(tmp_path / "state")) + config.reset() + connection._osw = None + connection._ledger = None + + collector = _Collector() + status.register(collector) + search.register(collector) + schema.register(collector) + slots.register(collector, include_writes=True) + entities.register(collector, include_writes=True) + + yield collector.tools + + connection.shutdown() + connection._osw = None + connection._ledger = None + config.reset() + + +def test_status_connects(mcp_tools): + result = mcp_tools["status"]() + assert result["connected"] is True + assert "password" not in result + + +def test_search_schema_and_read(mcp_tools): + found = mcp_tools["search_entities"](ask_query="[[Category:Item]]", limit=5) + assert "titles" in found + + category_schema = mcp_tools["get_category_schema"](category="Category:Item") + assert "exists" in category_schema + + if found["titles"]: + title = found["titles"][0] + entity = mcp_tools["get_entity"](title=title) + assert entity["title"] == title + assert entity["exists"] is True + + page_slots = mcp_tools["list_page_slots"](title=title) + assert page_slots["exists"] is True + assert any(s["key"] == "jsondata" for s in page_slots["slots"]) + + +def test_delete_guard_blocks_untracked(mcp_tools): + # A page the server never created must be refused without confirmation; + # this returns before any network delete, so it never mutates the instance. + result = mcp_tools["delete_entity"](title="Item:OSWdoesnotexistguardcheck") + assert result["type"] == "ExternalDeleteBlocked" diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py new file mode 100644 index 0000000..39b6daa --- /dev/null +++ b/tests/test_mcp_config.py @@ -0,0 +1,99 @@ +"""Unit tests for osw.mcp.config (fail-fast credential validation).""" + +import pytest + +from osw.mcp import config + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_SPARQL_ENDPOINT", + "OSW_MCP_READ_ONLY", + "OSW_MCP_STATE_DIR", + "OSW_MCP_MAX_RESULTS", + "OSW_MCP_MAX_CHARS", + "OSW_MCP_ENV_FILE", +] + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch, tmp_path): + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + # Point dotenv at an empty file so it never picks up a real .env on disk + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) + config.reset() + yield + config.reset() + + +def test_missing_credentials_raise(monkeypatch): + with pytest.raises(RuntimeError) as exc: + config.load() + # message names the missing vars so the operator can fix it + assert "OSW_DOMAIN" in str(exc.value) + assert "OSW_USERNAME" in str(exc.value) + assert "OSW_PASSWORD" in str(exc.value) + + +def test_missing_credentials_do_not_prompt(monkeypatch): + # If load() ever fell through to input()/getpass, this would hang; a raise + # proves it fails fast instead. + def _boom(*_a, **_k): + raise AssertionError("interactive prompt must never be reached") + + monkeypatch.setattr("builtins.input", _boom) + with pytest.raises(RuntimeError): + config.load() + + +def test_valid_credentials_parse(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MCP_READ_ONLY", "TRUE") + monkeypatch.setenv("OSW_MCP_MAX_RESULTS", "42") + settings = config.load() + assert settings.domain == "wiki.example.org" + assert settings.username == "alice" + assert settings.read_only is True + assert settings.max_results == 42 + # password must not appear in the redacted view + assert "password" not in settings.redacted() + assert "secret" not in repr(settings) + + +def test_osl_fallback(monkeypatch): + monkeypatch.setenv("OSL_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSL_USERNAME", "bob") + monkeypatch.setenv("OSL_PASSWORD", "pw") + settings = config.load() + assert settings.domain == "wiki.example.org" + assert settings.username == "bob" + + +def test_env_file_override(monkeypatch, tmp_path): + env = tmp_path / "creds.env" + env.write_text( + "OSW_DOMAIN=fromfile.example.org\nOSW_USERNAME=fileuser\nOSW_PASSWORD=filepw\n", + encoding="utf-8", + ) + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(env)) + settings = config.load() + assert settings.domain == "fromfile.example.org" + assert settings.username == "fileuser" + + +def test_invalid_int_raises(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MCP_MAX_RESULTS", "notanumber") + with pytest.raises(RuntimeError): + config.load() diff --git a/tests/test_mcp_ledger.py b/tests/test_mcp_ledger.py new file mode 100644 index 0000000..2e4db82 --- /dev/null +++ b/tests/test_mcp_ledger.py @@ -0,0 +1,78 @@ +"""Unit tests for the osw.mcp provenance ledger.""" + +from osw.mcp.ledger import Ledger + + +def _ledger(tmp_path): + return Ledger(domain="wiki.example.org", state_dir=str(tmp_path)) + + +def test_record_and_is_tracked(tmp_path): + ledger = _ledger(tmp_path) + assert ledger.is_tracked("Item:OSW1") is False + ledger.record("Item:OSW1", op="create", tool="create_or_update_entity") + assert ledger.is_tracked("Item:OSW1") is True + assert ledger.path.is_file() + + +def test_mark_deleted_untracks(tmp_path): + ledger = _ledger(tmp_path) + ledger.record("Item:OSW1", op="create", tool="create_or_update_entity") + ledger.mark_deleted("Item:OSW1") + assert ledger.is_tracked("Item:OSW1") is False + + +def test_record_merges_and_dedups(tmp_path): + ledger = _ledger(tmp_path) + ledger.record( + "Item:OSW1", + op="create", + tool="create_or_update_entity", + change_id="c1", + slots=["jsondata"], + ) + ledger.record( + "Item:OSW1", + op="update", + tool="set_slot", + change_id="c1", + slots=["main", "jsondata"], + ) + data = ledger._load()["entries"]["Item:OSW1"] + assert data["ops"] == ["create", "update"] + assert data["tools"] == ["create_or_update_entity", "set_slot"] + assert data["change_ids"] == ["c1"] # deduped + assert sorted(data["slots_written"]) == ["jsondata", "main"] # deduped + + +def test_recreate_after_delete_retracks(tmp_path): + ledger = _ledger(tmp_path) + ledger.record("Item:OSW1", op="create", tool="create_or_update_entity") + ledger.mark_deleted("Item:OSW1") + assert ledger.is_tracked("Item:OSW1") is False + ledger.record("Item:OSW1", op="create", tool="create_or_update_entity") + assert ledger.is_tracked("Item:OSW1") is True + + +def test_entry_count_excludes_deleted(tmp_path): + ledger = _ledger(tmp_path) + ledger.record("Item:OSW1", op="create", tool="t") + ledger.record("Item:OSW2", op="create", tool="t") + ledger.mark_deleted("Item:OSW1") + assert ledger.entry_count() == 1 + + +def test_corrupt_ledger_starts_fresh(tmp_path): + ledger = _ledger(tmp_path) + ledger.path.parent.mkdir(parents=True, exist_ok=True) + ledger.path.write_text("{not valid json", encoding="utf-8") + # is_tracked must not raise on a corrupt file + assert ledger.is_tracked("Item:OSW1") is False + ledger.record("Item:OSW1", op="create", tool="t") + assert ledger.is_tracked("Item:OSW1") is True + + +def test_persistence_across_instances(tmp_path): + _ledger(tmp_path).record("Item:OSW1", op="create", tool="t") + # a fresh Ledger over the same dir sees the persisted entry + assert _ledger(tmp_path).is_tracked("Item:OSW1") is True diff --git a/tests/test_mcp_serialization.py b/tests/test_mcp_serialization.py new file mode 100644 index 0000000..5e4e974 --- /dev/null +++ b/tests/test_mcp_serialization.py @@ -0,0 +1,58 @@ +"""Unit tests for osw.mcp.serialization.""" + +from pathlib import Path + +from osw.mcp.serialization import cap_list, maybe_truncate, to_jsonable + + +def test_cap_list_under_limit(): + items, total, truncated = cap_list([1, 2, 3], 10) + assert items == [1, 2, 3] + assert total == 3 + assert truncated is False + + +def test_cap_list_over_limit(): + items, total, truncated = cap_list(list(range(10)), 3) + assert items == [0, 1, 2] + assert total == 10 + assert truncated is True + + +def test_maybe_truncate_short_string(): + value, truncated = maybe_truncate("hello", 100) + assert value == "hello" + assert truncated is False + + +def test_maybe_truncate_long_string(): + value, truncated = maybe_truncate("x" * 50, 10) + assert value == "x" * 10 + assert truncated is True + + +def test_maybe_truncate_small_dict_roundtrips(): + value, truncated = maybe_truncate({"a": 1}, 100) + assert value == {"a": 1} + assert truncated is False + + +def test_maybe_truncate_large_dict_returns_truncated_json_string(): + big = {"items": list(range(1000))} + value, truncated = maybe_truncate(big, 50) + assert truncated is True + assert isinstance(value, str) + assert len(value) == 50 + + +def test_maybe_truncate_none(): + value, truncated = maybe_truncate(None, 10) + assert value is None + assert truncated is False + + +def test_to_jsonable_falls_back_to_str(): + # Path and set are not natively JSON-serializable + result = to_jsonable({"p": Path("/tmp/x"), "s": {1, 2}}) + assert isinstance(result["p"], str) + assert isinstance(result["s"], str) diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py new file mode 100644 index 0000000..438ea5f --- /dev/null +++ b/tests/test_mcp_tools.py @@ -0,0 +1,195 @@ +"""Unit tests for osw.mcp tool wiring and the delete provenance guard. + +These mock the shared connection so no network is required. +""" + +from unittest.mock import MagicMock + +import pytest + +from osw.mcp import config, connection +from osw.mcp.tools import entities, search, slots + + +class FakeMCP: + """Minimal stand-in that captures @tool-decorated functions by name.""" + + def __init__(self): + self.tools = {} + + def tool(self, *_a, **_k): + def deco(fn): + self.tools[fn.__name__] = fn + return fn + + return deco + + +@pytest.fixture +def env(monkeypatch, tmp_path): + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + monkeypatch.setenv("OSW_MCP_STATE_DIR", str(tmp_path / "state")) + config.reset() + connection._osw = None + connection._ledger = None + yield + config.reset() + connection._osw = None + connection._ledger = None + + +def _osw_with_page(exists=True): + page = MagicMock() + page.exists = exists + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + return osw, page + + +# -- delete guard --------------------------------------------------------- +def test_delete_untracked_is_blocked(env, monkeypatch): + osw, page = _osw_with_page() + monkeypatch.setattr(connection, "get_osw", lambda: osw) + fake = FakeMCP() + entities.register(fake, include_writes=True) + + result = fake.tools["delete_entity"](title="Item:OSWx") + + assert result["type"] == "ExternalDeleteBlocked" + osw.site.get_page.assert_not_called() # never even fetched the page + page.delete.assert_not_called() + + +def test_delete_tracked_is_allowed(env, monkeypatch): + osw, page = _osw_with_page() + monkeypatch.setattr(connection, "get_osw", lambda: osw) + connection.get_ledger().record("Item:OSWx", op="create", tool="t") + fake = FakeMCP() + entities.register(fake, include_writes=True) + + result = fake.tools["delete_entity"](title="Item:OSWx") + + assert result == {"title": "Item:OSWx", "deleted": True} + page.delete.assert_called_once() + # deletion untracks the entry + assert connection.get_ledger().is_tracked("Item:OSWx") is False + + +def test_delete_external_with_confirm(env, monkeypatch): + osw, page = _osw_with_page() + monkeypatch.setattr(connection, "get_osw", lambda: osw) + fake = FakeMCP() + entities.register(fake, include_writes=True) + + result = fake.tools["delete_entity"]( + title="Item:OSWy", confirm_external_delete=True + ) + + assert result["deleted"] is True + page.delete.assert_called_once() + + +def test_delete_nonexistent_page(env, monkeypatch): + osw, page = _osw_with_page(exists=False) + monkeypatch.setattr(connection, "get_osw", lambda: osw) + connection.get_ledger().record("Item:OSWz", op="create", tool="t") + fake = FakeMCP() + entities.register(fake, include_writes=True) + + result = fake.tools["delete_entity"](title="Item:OSWz") + + assert result["deleted"] is False + assert result["type"] == "NotFound" + page.delete.assert_not_called() + + +# -- read wiring ---------------------------------------------------------- +def test_get_entity_reads_jsondata_slot(env, monkeypatch): + page = MagicMock() + page.exists = True + page.get_slot_content.return_value = {"label": [{"text": "X"}]} + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + monkeypatch.setattr(connection, "get_osw", lambda: osw) + fake = FakeMCP() + entities.register(fake, include_writes=False) + + result = fake.tools["get_entity"](title="Item:OSW1") + + assert result["exists"] is True + assert result["jsondata"] == {"label": [{"text": "X"}]} + page.get_slot_content.assert_called_with("jsondata") + + +def test_search_entities_calls_semantic_search(env, monkeypatch): + osw = MagicMock() + osw.site.semantic_search.return_value = ["Item:OSW1", "Item:OSW2"] + monkeypatch.setattr(connection, "get_osw", lambda: osw) + fake = FakeMCP() + search.register(fake) + + result = fake.tools["search_entities"](ask_query="[[Category:Item]]") + + assert result["titles"] == ["Item:OSW1", "Item:OSW2"] + assert result["count"] == 2 + osw.site.semantic_search.assert_called_once() + + +def test_read_only_registration_omits_writes(env): + fake = FakeMCP() + entities.register(fake, include_writes=False) + assert "get_entity" in fake.tools + assert "create_or_update_entity" not in fake.tools + assert "delete_entity" not in fake.tools + + +# -- set_slot validation (no network) ------------------------------------- +def test_set_slot_rejects_unknown_slot(env, monkeypatch): + monkeypatch.setattr(connection, "get_osw", lambda: MagicMock()) + fake = FakeMCP() + slots.register(fake, include_writes=True) + + result = fake.tools["set_slot"](title="Item:OSW1", slot="bogus", content="x") + + assert result["type"] == "InvalidSlot" + + +def test_set_slot_rejects_wrong_content_type(env, monkeypatch): + monkeypatch.setattr(connection, "get_osw", lambda: MagicMock()) + fake = FakeMCP() + slots.register(fake, include_writes=True) + + result = fake.tools["set_slot"]( + title="Item:OSW1", slot="jsondata", content="not-json" + ) + + assert result["type"] == "InvalidContent" + + +def test_sparql_without_endpoint_reports_not_configured(env, monkeypatch): + monkeypatch.setattr(connection, "get_osw", lambda: MagicMock()) + fake = FakeMCP() + search.register(fake) + + result = fake.tools["sparql_query"](query="SELECT * WHERE {?s ?p ?o}") + + assert result["type"] == "NotConfigured" + + +def test_run_guarded_converts_exceptions(env, monkeypatch): + osw = MagicMock() + osw.site.get_page.side_effect = RuntimeError("boom") + monkeypatch.setattr(connection, "get_osw", lambda: osw) + fake = FakeMCP() + entities.register(fake, include_writes=False) + + result = fake.tools["get_entity"](title="Item:OSW1") + + assert result["type"] == "RuntimeError" + assert "boom" in result["error"] diff --git a/uv.lock b/uv.lock index 6ce8fa9..ad44a09 100644 --- a/uv.lock +++ b/uv.lock @@ -1056,6 +1056,15 @@ http2 = [ { name = "h2" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "humanize" version = "4.16.0" @@ -1475,6 +1484,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -2012,10 +2046,12 @@ all = [ { name = "boto3" }, { name = "deepl" }, { name = "geopy" }, + { name = "mcp" }, { name = "mwparserfromhell" }, { name = "openpyxl" }, { name = "psycopg2" }, { name = "pysimplegui" }, + { name = "python-dotenv" }, { name = "sqlalchemy" }, ] dataimport = [ @@ -2027,6 +2063,10 @@ db = [ { name = "psycopg2" }, { name = "sqlalchemy" }, ] +mcp = [ + { name = "mcp" }, + { name = "python-dotenv" }, +] s3 = [ { name = "boto3" }, ] @@ -2057,7 +2097,7 @@ dev = [ { name = "mike" }, { name = "mkdocstrings-python" }, { name = "mwparserfromhell" }, - { name = "osw", extra = ["workflow"] }, + { name = "osw", extra = ["mcp", "workflow"] }, { name = "pre-commit" }, { name = "psycopg2-binary" }, { name = "pytest" }, @@ -2084,6 +2124,8 @@ requires-dist = [ { name = "httpx" }, { name = "isort" }, { name = "jsonpath-ng" }, + { name = "mcp", marker = "extra == 'all'", specifier = ">=1.2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2" }, { name = "mwclient", specifier = ">=0.11.0" }, { name = "mwparserfromhell", marker = "extra == 'wikitext'" }, { name = "numpy" }, @@ -2100,6 +2142,8 @@ requires-dist = [ { name = "pydantic", extras = ["email"], specifier = ">=2.12.0" }, { name = "pyld" }, { name = "pysimplegui", marker = "extra == 'ui'" }, + { name = "python-dotenv", marker = "extra == 'all'", specifier = ">=1.0" }, + { name = "python-dotenv", marker = "extra == 'mcp'", specifier = ">=1.0" }, { name = "pyyaml" }, { name = "rdflib" }, { name = "requests" }, @@ -2108,7 +2152,7 @@ requires-dist = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -provides-extras = ["wikitext", "db", "s3", "dataimport", "ui", "workflow", "tutorial", "all"] +provides-extras = ["all", "dataimport", "db", "mcp", "s3", "tutorial", "ui", "wikitext", "workflow"] [package.metadata.requires-dev] dev = [ @@ -2121,6 +2165,7 @@ dev = [ { name = "mike", git = "https://github.com/squidfunk/mike.git?rev=2.2.0%2Bzensical-0.1.0" }, { name = "mkdocstrings-python", specifier = ">=1.0.3" }, { name = "mwparserfromhell" }, + { name = "osw", extras = ["mcp"] }, { name = "osw", extras = ["workflow"] }, { name = "pre-commit", specifier = ">=4.0.0" }, { name = "psycopg2-binary" }, @@ -2555,6 +2600,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -2564,6 +2623,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyld" version = "3.1.0" @@ -2700,6 +2776,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/78/9b77ecb4644d1bbea94d29abf78f21c47eca6eb79e9745b702ec0bed2e19/python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c", size = 33885, upload-time = "2026-07-03T13:21:50.174Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "python-gitlab" version = "8.4.0" @@ -3441,6 +3526,33 @@ asyncio = [ { name = "greenlet" }, ] +[[package]] +name = "sse-starlette" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678, upload-time = "2024-08-01T08:52:50.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383, upload-time = "2024-08-01T08:52:48.659Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "text-unidecode" version = "1.3" From e219aae1600811bbde0208258f234f34057efcc7 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Sat, 22 Aug 2026 14:20:38 +0200 Subject: [PATCH 02/28] feat(mcp): port server to mcp 2.x and isolate the extra - FastMCP replaced by MCPServer, extra now requires mcp>=2 - mcp dropped from the all extra and the dev group: it needs anyio>=4.9, workflow pins anyio<4.7 (#139) - uv conflicts declare mcp exclusive with workflow and with dev - pytest stack moved to its own test group, so an environment with both pytest and mcp exists - src/osw/mcp excluded from ty, mcp tests guarded by importorskip --- README.md | 8 + pyproject.toml | 54 ++++-- src/osw/mcp/connection.py | 2 +- src/osw/mcp/server.py | 8 +- tests/integration/test_mcp_server.py | 3 + tests/test_mcp_config.py | 3 + tests/test_mcp_tools.py | 3 + uv.lock | 278 +++++++++++++++++---------- 8 files changed, 241 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 035dc38..4a2159d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ pip install osw Optional extras (`osw[wikitext]`, `osw[DB]`, `osw[S3]`, `osw[dataimport]`, `osw[UI]`, `osw[mcp]`, `osw[all]`) are described in the [Get Started guide](https://opensemanticlab.github.io/osw-python/get-started/). +Note that `osw[mcp]` is not part of `osw[all]` and has to be installed +explicitly, see [MCP server](#mcp-server). ## Quickstart @@ -51,6 +53,12 @@ and delete entities, and upload/download files. pip install "osw[mcp]" ``` +This extra is deliberately not part of `osw[all]`. It needs `anyio>=4.9`, which +conflicts with the pin the `osw[workflow]` extra requires for prefect 2.x, so +the two cannot share an environment +([#139](https://github.com/OpenSemanticLab/osw-python/issues/139)). Installing +the server standalone, for example via `uvx`, avoids the question entirely. + Configure credentials in a gitignored `.env` file (the server reads them at startup and never writes them to disk): diff --git a/pyproject.toml b/pyproject.toml index 6259ad0..e1f52ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,8 +70,9 @@ dataimport = [ ] UI = ["pysimplegui"] mcp = [ - # official MCP Python SDK; FastMCP lives in mcp.server.fastmcp - "mcp>=1.2", + # official MCP Python SDK; provides MCPServer from mcp.server. + # requires 2.x: 1.x has no MCPServer, and 2.0 removed the vendored FastMCP. + "mcp>=2", # .env loading for the stdio server (OSW_DOMAIN/OSW_USERNAME/OSW_PASSWORD) "python-dotenv>=1.0", ] @@ -85,7 +86,10 @@ workflow = [ "anyio>=4.4.0,<4.7", ] tutorial = ["osw[dataimport]"] -all = ["osw[dataimport,DB,UI,S3,wikitext,mcp]"] +# mcp is deliberately excluded here: it requires anyio>=4.9, which conflicts +# with the workflow extra's anyio cap. Install it explicitly with osw[mcp]. +# See https://github.com/OpenSemanticLab/osw-python/issues/139 +all = ["osw[dataimport,DB,UI,S3,wikitext]"] [project.scripts] # stdio MCP server exposing a live OSL instance to MCP clients (e.g. Claude Code) @@ -96,18 +100,27 @@ requires = ["hatchling"] build-backend = "hatchling.build" [dependency-groups] -dev = [ - # test stack +# pytest stack in its own group so it can be installed alongside the mcp +# extra, which conflicts with the dev group (see [tool.uv] below). +# Run the MCP tests with: +# uv sync --extra mcp --group test --no-dev +# uv run --extra mcp --group test --no-dev --no-sync python -m pytest tests/test_mcp_*.py +test = [ "pytest", "pytest-cov", "pytest-mock", "pytest-asyncio", - # inherit the capped prefect pin (<3.0); a bare "prefect" here resolved to - # 3.x in CI, whose server API breaks the prefect-2.20-targeted tests - "osw[workflow]", - # MCP server extra installed in dev so its modules type-check (ty) and - # deptry can resolve the mcp/dotenv imports in src/osw/mcp - "osw[mcp]", +] +dev = [ + { include-group = "test" }, + # prefect/anyio are listed directly rather than via osw[workflow]: the + # workflow extra is in a uv conflict set (see [tool.uv] below), so a + # self-referential osw[workflow] entry here would only activate when + # --extra workflow is passed, leaving a bare `uv sync` on the anyio that + # breaks prefect 2.20. Keep these pins in sync with the workflow extra. + # Tracked in https://github.com/OpenSemanticLab/osw-python/issues/139 + "prefect>=2.20.25,<3.0", + "anyio>=4.4.0,<4.7", "geopy", "deepl", "sqlalchemy", @@ -304,6 +317,19 @@ insertion_flag = "" [tool.semantic_release.changelog.default_templates] changelog_file = "CHANGELOG.md" +[tool.uv] +# mcp 2.x needs anyio>=4.9; the workflow extra caps anyio<4.7 for prefect 2.20 +# (see the workflow extra above). They cannot share one resolution, so uv is +# told to resolve them in separate splits. Install the MCP server standalone: +# pip install "osw[mcp]". +# The dev group is included too since it carries the same anyio cap directly +# (see the dev group above). Tracked in +# https://github.com/OpenSemanticLab/osw-python/issues/139 +conflicts = [ + [{ extra = "mcp" }, { extra = "workflow" }], + [{ extra = "mcp" }, { group = "dev" }], +] + [tool.ty.environment] python = "./.venv" python-version = "3.10" @@ -313,12 +339,17 @@ python-version = "3.10" # - src/osw/model/entity.py: generated (datamodel-code-generator) models # - examples, scripts: illustrative/maintenance code, not part of the package # - tests: not yet type-clean, tightened in a follow-up +# - src/osw/mcp: its dependencies (the mcp extra) cannot be installed +# alongside the workflow extra (see [tool.uv] conflicts); revert this +# once the anyio conflict is resolved +# (https://github.com/OpenSemanticLab/osw-python/issues/139) exclude = [ "src/osw/model/entity.py", "examples", "scripts", "tests", "docs", + "src/osw/mcp", ] [tool.ty.rules] @@ -356,6 +387,7 @@ pybars3-wheel = "pybars" psycopg2 = "psycopg2" openpyxl = "openpyxl" pysimplegui = "PySimpleGUI" +mcp = "mcp" # python-dotenv imports as `dotenv` python-dotenv = "dotenv" diff --git a/src/osw/mcp/connection.py b/src/osw/mcp/connection.py index df6a133..4573412 100644 --- a/src/osw/mcp/connection.py +++ b/src/osw/mcp/connection.py @@ -1,7 +1,7 @@ """Shared, thread-safe connection to a live OSL instance. A single process-wide ``OswExpress`` is built lazily on first use. Because -mwclient's session is not thread-safe and FastMCP runs synchronous tools in a +mwclient's session is not thread-safe and MCPServer runs synchronous tools in a worker-thread pool, every osw access is serialized through one lock. The osw library prints progress to ``stdout`` (e.g. "Connecting to ..."), but on diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py index d4e95cc..93b8ed8 100644 --- a/src/osw/mcp/server.py +++ b/src/osw/mcp/server.py @@ -10,20 +10,20 @@ import atexit import sys -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer from . import config, connection from .tools import register_all -def create_server() -> FastMCP: - """Build the FastMCP server, registering tools per the read-only setting. +def create_server() -> MCPServer: + """Build the MCPServer, registering tools per the read-only setting. Loads and validates settings first so a missing-credential misconfiguration fails fast (before any osw call that could trigger an interactive prompt). """ settings = config.get_settings() - mcp = FastMCP("osw") + mcp = MCPServer("osw") register_all(mcp, include_writes=not settings.read_only) return mcp diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py index 894527c..f9b8a6b 100644 --- a/tests/integration/test_mcp_server.py +++ b/tests/integration/test_mcp_server.py @@ -11,6 +11,9 @@ import pytest +pytest.importorskip("mcp", reason="requires the osw[mcp] extra") +pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") + from osw.mcp import config, connection from osw.mcp.tools import entities, schema, search, slots, status diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index 39b6daa..b9879ca 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -2,6 +2,9 @@ import pytest +pytest.importorskip("mcp", reason="requires the osw[mcp] extra") +pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") + from osw.mcp import config _ALL_VARS = [ diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index 438ea5f..9050058 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -7,6 +7,9 @@ import pytest +pytest.importorskip("mcp", reason="requires the osw[mcp] extra") +pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") + from osw.mcp import config, connection from osw.mcp.tools import entities, search, slots diff --git a/uv.lock b/uv.lock index ad44a09..c9e8356 100644 --- a/uv.lock +++ b/uv.lock @@ -2,14 +2,17 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.14" resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] +conflicts = [[ + { package = "osw", extra = "mcp" }, + { package = "osw", extra = "workflow" }, +], [ + { package = "osw", extra = "mcp" }, + { package = "osw", group = "dev" }, +]] [[package]] name = "aiosqlite" @@ -48,17 +51,41 @@ wheels = [ name = "anyio" version = "4.6.2.post1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "(python_full_version < '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "idna", marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, + { name = "sniffio", marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422, upload-time = "2024-10-14T14:31:44.021Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377, upload-time = "2024-10-14T14:31:42.623Z" }, ] +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] +dependencies = [ + { name = "exceptiongroup", marker = "(python_full_version < '3.11' and extra == 'extra-3-osw-mcp') or (python_full_version < '3.11' and extra != 'extra-3-osw-workflow' and extra != 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "idna", marker = "extra == 'extra-3-osw-mcp' or (extra != 'extra-3-osw-workflow' and extra != 'group-3-osw-dev')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and extra == 'extra-3-osw-mcp') or (python_full_version < '3.13' and extra != 'extra-3-osw-workflow' and extra != 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "apprise" version = "1.12.0" @@ -188,8 +215,8 @@ dependencies = [ { name = "pathspec" }, { name = "platformdirs" }, { name = "pytokens" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } wheels = [ @@ -411,7 +438,7 @@ name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ @@ -527,7 +554,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] [[package]] @@ -595,7 +622,7 @@ dependencies = [ { name = "click" }, { name = "cloudpickle" }, { name = "fsspec" }, - { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.12' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "packaging" }, { name = "partd" }, { name = "pyyaml" }, @@ -620,7 +647,7 @@ dependencies = [ { name = "packaging" }, { name = "pydantic" }, { name = "pyyaml" }, - { name = "tomli", marker = "python_full_version < '3.12'" }, + { name = "tomli", marker = "python_full_version < '3.12' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/88/78/dd57f7cb55be1b5465718eb0a53be947984ae07e48c7cdfdef1ae3da976f/datamodel_code_generator-0.51.0.tar.gz", hash = "sha256:8944813cdd9a354e651513868204fffae56c004855f2316a660b023421c712d0", size = 758566, upload-time = "2026-01-01T00:02:32.532Z" } wheels = [ @@ -782,7 +809,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' or (python_full_version < '3.13' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.13' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1036,12 +1063,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.6.2.post1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-3-osw-mcp' or (extra != 'extra-3-osw-workflow' and extra != 'group-3-osw-dev')" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, @@ -1057,12 +1098,29 @@ http2 = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -1106,7 +1164,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp", marker = "python_full_version < '3.12' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1255,8 +1313,8 @@ dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-3-osw-mcp') or (python_full_version < '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-3-osw-mcp') or (python_full_version >= '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version >= '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -1486,16 +1544,16 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" } }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, + { name = "pyjwt", extra = ["crypto"], marker = "extra == 'extra-3-osw-mcp'" }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, @@ -1504,9 +1562,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, ] [[package]] @@ -1771,9 +1842,7 @@ name = "numpy" version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ @@ -1834,9 +1903,7 @@ name = "numpy" version = "2.5.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12'", ] sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } wheels = [ @@ -1909,7 +1976,7 @@ name = "opensemantic" version = "0.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-strenum", marker = "python_full_version < '3.11'" }, + { name = "backports-strenum", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "oold" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/58/2bdbd07aeb065cbe95d0cdfe024e97e7ca69bfe0d2d49b48ff889466e139/opensemantic-0.2.4.tar.gz", hash = "sha256:1e5f6beac3dc84b04a3de9eb5c05bed9be1b2fd286898df7965be5ced387005e", size = 33204, upload-time = "2026-05-08T12:47:36.822Z" } @@ -1944,6 +2011,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/c8/ab45630822479696bd4e7650a7e3a547b782ae3a0b30bfcd04a39e6692d3/opensemantic_core-0.57.4.post1000002003001-py3-none-any.whl", hash = "sha256:6cb35e14e011be95e0ded366d1cc2dd8f547209a68665960ffab530ecb6d7ef4", size = 51538, upload-time = "2026-05-04T06:15:25.887Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -2015,7 +2094,7 @@ name = "osw" version = "2.0.0" source = { editable = "." } dependencies = [ - { name = "backports-strenum", marker = "python_full_version < '3.11'" }, + { name = "backports-strenum", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "black" }, { name = "dask" }, { name = "datamodel-code-generator" }, @@ -2023,9 +2102,9 @@ dependencies = [ { name = "isort" }, { name = "jsonpath-ng" }, { name = "mwclient" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "oold" }, { name = "opensemantic" }, { name = "opensemantic-base" }, @@ -2046,12 +2125,10 @@ all = [ { name = "boto3" }, { name = "deepl" }, { name = "geopy" }, - { name = "mcp" }, { name = "mwparserfromhell" }, { name = "openpyxl" }, { name = "psycopg2" }, { name = "pysimplegui" }, - { name = "python-dotenv" }, { name = "sqlalchemy" }, ] dataimport = [ @@ -2082,12 +2159,13 @@ wikitext = [ { name = "mwparserfromhell" }, ] workflow = [ - { name = "anyio" }, + { name = "anyio", version = "4.6.2.post1", source = { registry = "https://pypi.org/simple" } }, { name = "prefect" }, ] [package.dev-dependencies] dev = [ + { name = "anyio", version = "4.6.2.post1", source = { registry = "https://pypi.org/simple" } }, { name = "backports-strenum" }, { name = "boto3" }, { name = "deepl" }, @@ -2097,8 +2175,8 @@ dev = [ { name = "mike" }, { name = "mkdocstrings-python" }, { name = "mwparserfromhell" }, - { name = "osw", extra = ["mcp", "workflow"] }, { name = "pre-commit" }, + { name = "prefect" }, { name = "psycopg2-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2110,6 +2188,12 @@ dev = [ { name = "ty" }, { name = "zensical" }, ] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] [package.metadata] requires-dist = [ @@ -2124,8 +2208,7 @@ requires-dist = [ { name = "httpx" }, { name = "isort" }, { name = "jsonpath-ng" }, - { name = "mcp", marker = "extra == 'all'", specifier = ">=1.2" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2" }, { name = "mwclient", specifier = ">=0.11.0" }, { name = "mwparserfromhell", marker = "extra == 'wikitext'" }, { name = "numpy" }, @@ -2142,7 +2225,6 @@ requires-dist = [ { name = "pydantic", extras = ["email"], specifier = ">=2.12.0" }, { name = "pyld" }, { name = "pysimplegui", marker = "extra == 'ui'" }, - { name = "python-dotenv", marker = "extra == 'all'", specifier = ">=1.0" }, { name = "python-dotenv", marker = "extra == 'mcp'", specifier = ">=1.0" }, { name = "pyyaml" }, { name = "rdflib" }, @@ -2152,10 +2234,11 @@ requires-dist = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -provides-extras = ["all", "dataimport", "db", "mcp", "s3", "tutorial", "ui", "wikitext", "workflow"] +provides-extras = ["wikitext", "db", "s3", "dataimport", "ui", "mcp", "workflow", "tutorial", "all"] [package.metadata.requires-dev] dev = [ + { name = "anyio", specifier = ">=4.4.0,<4.7" }, { name = "backports-strenum" }, { name = "boto3" }, { name = "deepl" }, @@ -2165,9 +2248,8 @@ dev = [ { name = "mike", git = "https://github.com/squidfunk/mike.git?rev=2.2.0%2Bzensical-0.1.0" }, { name = "mkdocstrings-python", specifier = ">=1.0.3" }, { name = "mwparserfromhell" }, - { name = "osw", extras = ["mcp"] }, - { name = "osw", extras = ["workflow"] }, { name = "pre-commit", specifier = ">=4.0.0" }, + { name = "prefect", specifier = ">=2.20.25,<3.0" }, { name = "psycopg2-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2179,6 +2261,12 @@ dev = [ { name = "ty", specifier = ">=0.0.24" }, { name = "zensical", specifier = ">=0.0.46" }, ] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] [[package]] name = "packaging" @@ -2216,9 +2304,7 @@ name = "pendulum" version = "2.1.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] dependencies = [ @@ -2232,9 +2318,7 @@ name = "pendulum" version = "3.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12'", ] dependencies = [ { name = "python-dateutil", marker = "python_full_version >= '3.12'" }, @@ -2332,7 +2416,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, { name = "alembic" }, - { name = "anyio" }, + { name = "anyio", version = "4.6.2.post1", source = { registry = "https://pypi.org/simple" } }, { name = "apprise" }, { name = "asgi-lifespan" }, { name = "asyncpg" }, @@ -2349,7 +2433,7 @@ dependencies = [ { name = "graphviz" }, { name = "griffe" }, { name = "httpcore" }, - { name = "httpx", extra = ["http2"] }, + { name = "httpx", extra = ["http2"], marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, { name = "humanize" }, { name = "importlib-resources" }, { name = "itsdangerous" }, @@ -2361,9 +2445,9 @@ dependencies = [ { name = "orjson" }, { name = "packaging" }, { name = "pathspec" }, - { name = "pendulum", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "pendulum", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic", extra = ["email"] }, + { name = "pendulum", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.12' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "pendulum", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra == 'extra-3-osw-workflow') or (python_full_version >= '3.12' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "pydantic", extra = ["email"], marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, { name = "pydantic-core" }, { name = "python-dateutil" }, { name = "python-multipart" }, @@ -2375,7 +2459,7 @@ dependencies = [ { name = "rich" }, { name = "ruamel-yaml" }, { name = "sniffio" }, - { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "sqlalchemy", extra = ["asyncio"], marker = "extra == 'extra-3-osw-workflow' or extra == 'group-3-osw-dev'" }, { name = "toml" }, { name = "typer" }, { name = "typing-extensions" }, @@ -2600,20 +2684,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -2698,13 +2768,13 @@ name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -2716,9 +2786,9 @@ name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -2972,7 +3042,7 @@ name = "rdflib" version = "7.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "isodate", marker = "python_full_version < '3.11'" }, + { name = "isodate", marker = "python_full_version < '3.11' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "pyparsing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" } @@ -2995,8 +3065,8 @@ version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-3-osw-mcp') or (python_full_version < '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version < '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-3-osw-mcp') or (python_full_version >= '3.11' and extra == 'extra-3-osw-workflow') or (python_full_version >= '3.11' and extra == 'group-3-osw-dev') or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } @@ -3271,12 +3341,8 @@ name = "rpds-py" version = "2026.6.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ @@ -3489,7 +3555,7 @@ name = "sqlalchemy" version = "2.0.35" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "(python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64')" }, + { name = "greenlet", marker = "(python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64') or (platform_machine != 'AMD64' and platform_machine != 'WIN32' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'ppc64le' and platform_machine != 'win32' and platform_machine != 'x86_64' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine != 'AMD64' and platform_machine != 'WIN32' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'ppc64le' and platform_machine != 'win32' and platform_machine != 'x86_64' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'AMD64' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'AMD64' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'WIN32' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'WIN32' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'aarch64' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'aarch64' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'amd64' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'amd64' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'ppc64le' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'ppc64le' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'win32' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'win32' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev') or (platform_machine == 'x86_64' and extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (platform_machine == 'x86_64' and extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/36/48/4f190a83525f5cefefa44f6adc9e6386c4de5218d686c27eda92eb1f5424/sqlalchemy-2.0.35.tar.gz", hash = "sha256:e11d7ea4d24f0a262bccf9a7cd6284c976c5369dac21db237cff59586045ab9f", size = 9562798, upload-time = "2024-09-16T20:30:05.964Z" } @@ -3528,16 +3594,15 @@ asyncio = [ [[package]] name = "sse-starlette" -version = "2.1.3" +version = "3.4.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" } }, { name = "starlette" }, - { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678, upload-time = "2024-08-01T08:52:50.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383, upload-time = "2024-08-01T08:52:48.659Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, ] [[package]] @@ -3545,7 +3610,7 @@ name = "starlette" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" } }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } @@ -3630,13 +3695,22 @@ name = "tqdm" version = "4.68.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-3-osw-mcp' and extra == 'extra-3-osw-workflow') or (extra == 'extra-3-osw-mcp' and extra == 'group-3-osw-dev')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/5f/57ff8b434839e70dab45601284ea413e947a63799891b7553e5960a793a8/tqdm-4.68.4.tar.gz", hash = "sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520", size = 792418, upload-time = "2026-07-07T09:58:18.369Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/22/2a/5e5e750890ada51017d18d0d4c30da696e5b5bd3180947729927628fc3cb/tqdm-4.68.4-py3-none-any.whl", hash = "sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2", size = 676612, upload-time = "2026-07-07T09:58:16.256Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "ty" version = "0.0.56" From a7c35a7929cdcc497a42724a1aa3a877f5bd47ca Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Sat, 22 Aug 2026 14:22:41 +0200 Subject: [PATCH 03/28] feat(mcp): authenticate from an osw credential file - OSW_MCP_CRED_FILEPATH configures it, OSL_CRED_FILEPATH is a fallback - an alternative to OSW_USERNAME/OSW_PASSWORD, so the password is not duplicated into a second plaintext file - existence and a matching domain entry are validated at startup - lookups use CredentialFallback.none, so osw never prompts and never blocks the stdio transport --- README.md | 18 +++++++ src/osw/mcp/config.py | 107 ++++++++++++++++++++++++++++++++------ src/osw/mcp/connection.py | 18 +++++-- tests/test_mcp_config.py | 88 +++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 4a2159d..58c8992 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,24 @@ OSW_SPARQL_ENDPOINT=https://.../sparql OSW_MCP_READ_ONLY=false # true hides all mutating tools ``` +Alternatively, authenticate from an osw credential file, so the password is not +duplicated into a second plaintext file: + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_MCP_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml +``` + +`OSL_CRED_FILEPATH` is accepted as a fallback, so deployments that already +configure osw's `CredentialManager` need no extra setup. The file is the YAML +format `CredentialManager` already reads, keyed by iri: + +```yaml +wiki-dev.open-semantic-lab.org: + username: your-user + password: your-password +``` + Register it with Claude Code (reference the `.env` via `OSW_MCP_ENV_FILE`; do not put `OSW_PASSWORD` inline in a committed `.mcp.json`): diff --git a/src/osw/mcp/config.py b/src/osw/mcp/config.py index fd41214..b755ad9 100644 --- a/src/osw/mcp/config.py +++ b/src/osw/mcp/config.py @@ -12,17 +12,24 @@ import os from dataclasses import dataclass, field +from pathlib import Path from typing import Optional +import yaml + # python-dotenv is part of the [mcp] extra from dotenv import load_dotenv +from osw.auth import CredentialManager + _TRUTHY = {"1", "true", "yes", "on"} # Environment variable names (OSL_* are accepted as fallbacks, matching osw). ENV_DOMAIN = ("OSW_DOMAIN", "OSL_DOMAIN") ENV_USERNAME = ("OSW_USERNAME", "OSL_USERNAME") ENV_PASSWORD = ("OSW_PASSWORD", "OSL_PASSWORD") +# OSL_CRED_FILEPATH is accepted because existing osw deployments already set it. +ENV_CRED_FILEPATH = ("OSW_MCP_CRED_FILEPATH", "OSL_CRED_FILEPATH") def _first_env(names: tuple[str, ...]) -> Optional[str]: @@ -39,9 +46,12 @@ class Settings: """Resolved, validated server settings.""" domain: str - username: str + # username/password are optional: a configured credential file is an + # alternative source of credentials (see ENV_CRED_FILEPATH). + username: Optional[str] = None # kept only to build the SPARQL client; never returned by any tool - password: str = field(repr=False) + password: Optional[str] = field(default=None, repr=False) + cred_filepath: Optional[str] = None sparql_endpoint: Optional[str] = None read_only: bool = False state_dir: Optional[str] = None @@ -55,6 +65,7 @@ def redacted(self) -> dict: "username": self.username, "read_only": self.read_only, "sparql_endpoint_configured": bool(self.sparql_endpoint), + "cred_filepath_configured": bool(self.cred_filepath), } @@ -70,17 +81,66 @@ def _int_env(name: str, default: int) -> int: ) +def _cred_file_iris(cred_filepath: str) -> list[str]: + """Return the top-level iri keys in a credential YAML file, best effort.""" + try: + with open(cred_filepath, encoding="utf-8") as stream: + data = yaml.safe_load(stream) + except (OSError, yaml.YAMLError): + return [] + if not data: + return [] + return sorted(str(key) for key in data.keys()) + + +def _verify_cred_file_has_domain(cred_filepath: str, domain: str) -> None: + """Verify that the credential file has an entry matching ``domain``. + + Uses ``CredentialManager.get_credential`` with ``fallback="none"`` so this + never prompts interactively and never performs a network login; it only + checks that a matching credential entry already exists in the file. + + Raises + ------ + RuntimeError + If no credential entry matches ``domain``, naming the iris the file + does contain (never their secrets) so the operator can fix it. + """ + cred_mngr = CredentialManager(cred_filepath=cred_filepath) + credential = cred_mngr.get_credential( + CredentialManager.CredentialConfig( + iri=domain, fallback=CredentialManager.CredentialFallback.none + ) + ) + if credential is None: + available = ", ".join(_cred_file_iris(cred_filepath)) or "(none)" + raise RuntimeError( + f"Credential file '{cred_filepath}' has no entry matching domain " + f"'{domain}'. Iris found in the file: {available}. Add an entry " + "for the domain, or configure OSW_USERNAME/OSW_PASSWORD instead." + ) + + def load() -> Settings: """Load and validate settings from the environment. Loads a ``.env`` file first: the path in ``OSW_MCP_ENV_FILE`` if set, otherwise dotenv's default search from the current working directory upward. + Credentials can come from either ``OSW_USERNAME``/``OSW_PASSWORD`` (or + their ``OSL_*`` aliases) or from a credential file configured via + ``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH``. When a credential file + is configured, it is validated here to actually contain an entry for the + configured domain. + Raises ------ RuntimeError - If any of domain / username / password is missing, so the osw - interactive credential prompt is never reached. + If domain is missing, if neither a usable credential file nor + username/password are configured, if a configured credential file + does not exist, or if a configured credential file has no entry + matching the domain. This keeps the osw interactive credential prompt + from ever being reached. """ env_file = os.getenv("OSW_MCP_ENV_FILE") if env_file: @@ -91,30 +151,43 @@ def load() -> Settings: domain = _first_env(ENV_DOMAIN) username = _first_env(ENV_USERNAME) password = _first_env(ENV_PASSWORD) - - missing = [ - names[0] - for names, value in ( - (ENV_DOMAIN, domain), - (ENV_USERNAME, username), - (ENV_PASSWORD, password), - ) - if not value - ] + cred_filepath = _first_env(ENV_CRED_FILEPATH) + + cred_file_usable = False + if cred_filepath: + if not Path(cred_filepath).is_file(): + raise RuntimeError( + f"Configured credential file '{cred_filepath}' does not exist. " + "Set OSW_MCP_CRED_FILEPATH / OSL_CRED_FILEPATH to a valid path, " + "or remove it and configure OSW_USERNAME/OSW_PASSWORD instead." + ) + cred_file_usable = True + + # A usable credential file is an alternative source of username/password. + checks = [(ENV_DOMAIN, domain)] + if not cred_file_usable: + checks.append((ENV_USERNAME, username)) + checks.append((ENV_PASSWORD, password)) + missing = [names[0] for names, value in checks if not value] if missing: raise RuntimeError( "Missing required OSW credential environment variables: " + ", ".join(missing) + ". Set them in your environment or a .env file " - "(pointed to by OSW_MCP_ENV_FILE). The server refuses to start " - "without them to avoid an interactive credential prompt that would " - "hang the stdio transport." + "(pointed to by OSW_MCP_ENV_FILE), or configure a credential file " + "via OSW_MCP_CRED_FILEPATH / OSL_CRED_FILEPATH. The server refuses " + "to start without them to avoid an interactive credential prompt " + "that would hang the stdio transport." ) + if cred_file_usable: + _verify_cred_file_has_domain(cred_filepath, domain) + return Settings( domain=domain, username=username, password=password, + cred_filepath=cred_filepath, sparql_endpoint=os.getenv("OSW_SPARQL_ENDPOINT") or None, read_only=(os.getenv("OSW_MCP_READ_ONLY", "").lower() in _TRUTHY), state_dir=os.getenv("OSW_MCP_STATE_DIR") or None, diff --git a/src/osw/mcp/connection.py b/src/osw/mcp/connection.py index 4573412..856d3ed 100644 --- a/src/osw/mcp/connection.py +++ b/src/osw/mcp/connection.py @@ -18,6 +18,7 @@ from contextlib import contextmanager, redirect_stdout from typing import Callable, Optional +from osw.auth import CredentialManager from osw.express import OswExpress from . import config @@ -31,14 +32,23 @@ def get_osw() -> OswExpress: """Return the shared ``OswExpress``, connecting on first use. - Credentials and domain are resolved by osw from the environment - (``OSW_DOMAIN`` / ``OSW_USERNAME`` / ``OSW_PASSWORD``), which - :func:`osw.mcp.config.load` has already validated as present. + Credentials come from either of two sources, both already validated by + :func:`osw.mcp.config.load`: + + * ``OSW_USERNAME`` / ``OSW_PASSWORD`` (or their ``OSL_*`` aliases), read + by osw from the environment; or + * a credential file (``settings.cred_filepath``), configured via + ``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH``, wrapped in a + ``CredentialManager`` and passed to ``OswExpress`` explicitly. """ global _osw if _osw is None: settings = config.get_settings() - _osw = OswExpress(domain=settings.domain) + if settings.cred_filepath: + cred_mngr = CredentialManager(cred_filepath=settings.cred_filepath) + _osw = OswExpress(domain=settings.domain, cred_mngr=cred_mngr) + else: + _osw = OswExpress(domain=settings.domain) return _osw diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index b9879ca..65aaad9 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -1,6 +1,7 @@ """Unit tests for osw.mcp.config (fail-fast credential validation).""" import pytest +import yaml pytest.importorskip("mcp", reason="requires the osw[mcp] extra") pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") @@ -14,6 +15,8 @@ "OSL_USERNAME", "OSW_PASSWORD", "OSL_PASSWORD", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", "OSW_SPARQL_ENDPOINT", "OSW_MCP_READ_ONLY", "OSW_MCP_STATE_DIR", @@ -100,3 +103,88 @@ def test_invalid_int_raises(monkeypatch): monkeypatch.setenv("OSW_MCP_MAX_RESULTS", "notanumber") with pytest.raises(RuntimeError): config.load() + + +def _write_cred_file(path, data): + path.write_text(yaml.safe_dump(data), encoding="utf-8") + return path + + +def test_cred_file_configured_and_present_no_env_credentials(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + settings = config.load() + assert settings.domain == "wiki.example.org" + assert settings.cred_filepath == str(cred_file) + assert settings.username is None + assert settings.password is None + + +def test_cred_file_missing_raises(monkeypatch, tmp_path): + missing = tmp_path / "does-not-exist.yaml" + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(missing)) + with pytest.raises(RuntimeError) as exc: + config.load() + assert str(missing) in str(exc.value) + + +def test_missing_username_password_without_cred_file_raises(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_USERNAME" in str(exc.value) + assert "OSW_PASSWORD" in str(exc.value) + assert "OSW_DOMAIN" not in str(exc.value) + + +def test_username_password_still_work_with_no_cred_file(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + settings = config.load() + assert settings.domain == "wiki.example.org" + assert settings.username == "alice" + assert settings.cred_filepath is None + + +def test_redacted_never_contains_password_or_credential_value(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki.example.org": {"username": "alice", "password": "supersecret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + settings = config.load() + redacted = settings.redacted() + assert "password" not in redacted + assert "supersecret" not in str(redacted) + assert redacted["cred_filepath_configured"] is True + + +def test_cred_file_missing_domain_entry_raises(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"other.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + with pytest.raises(RuntimeError) as exc: + config.load() + assert "other.example.org" in str(exc.value) + assert "wiki.example.org" in str(exc.value) + + +def test_cred_file_missing_domain_env_still_raises(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_DOMAIN" in str(exc.value) From d393a6632c636b3b2206f56ff85def2b1af1a191 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Sat, 22 Aug 2026 14:23:27 +0200 Subject: [PATCH 04/28] feat(mcp): select between multiple OSL instances at runtime - list_instances and select_instance tools, returning iris only and never any credential value - OSW_DOMAIN becomes optional when a credential file supplies the iris - auto-selects when OSW_DOMAIN is set or the file holds exactly one iri - switching rebuilds the connection and the per-domain provenance ledger - tools resolve the active domain and credentials at call time --- README.md | 21 +++ src/osw/mcp/config.py | 166 ++++++++++++++++-- src/osw/mcp/connection.py | 34 +++- src/osw/mcp/tools/__init__.py | 5 +- src/osw/mcp/tools/entities.py | 3 +- src/osw/mcp/tools/instances.py | 49 ++++++ src/osw/mcp/tools/search.py | 7 +- src/osw/mcp/tools/status.py | 25 ++- tests/test_mcp_config.py | 29 +++- tests/test_mcp_instances.py | 307 +++++++++++++++++++++++++++++++++ tests/test_mcp_tools.py | 41 +++++ 11 files changed, 654 insertions(+), 33 deletions(-) create mode 100644 src/osw/mcp/tools/instances.py create mode 100644 tests/test_mcp_instances.py diff --git a/README.md b/README.md index 58c8992..8015f2f 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,27 @@ wiki-dev.open-semantic-lab.org: password: your-password ``` +**Multiple instances:** when the credential file holds more than one iri, the +server starts without an active instance and exposes two extra tools: + +- `list_instances` returns the available iris, never any credential +- `select_instance(iri)` switches to one, rebuilding the connection and the + provenance ledger, which is kept separate per domain + +If `OSW_DOMAIN` is set, or the file holds exactly one iri, that instance is +selected automatically and neither tool needs to be called. Until an instance is +active the other tools return "No OSL instance selected". `status` reports which +one is active. + +Registering the server once per instance works too, and has the advantage that +the instance is visible in the tool name at every call site, with read-only +settable per instance: + +```bash +claude mcp add osw-dev --env OSW_MCP_ENV_FILE=/abs/path/dev.env -- uvx --from "osw[mcp]" osw-mcp +claude mcp add osw-prod --env OSW_MCP_ENV_FILE=/abs/path/prod.env --env OSW_MCP_READ_ONLY=true -- uvx --from "osw[mcp]" osw-mcp +``` + Register it with Claude Code (reference the `.env` via `OSW_MCP_ENV_FILE`; do not put `OSW_PASSWORD` inline in a committed `.mcp.json`): diff --git a/src/osw/mcp/config.py b/src/osw/mcp/config.py index b755ad9..21a47fe 100644 --- a/src/osw/mcp/config.py +++ b/src/osw/mcp/config.py @@ -14,6 +14,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Optional +from urllib.parse import urlparse import yaml @@ -45,7 +46,10 @@ def _first_env(names: tuple[str, ...]) -> Optional[str]: class Settings: """Resolved, validated server settings.""" - domain: str + # domain is optional: with a usable credential file, no domain need be + # configured via the environment; the active instance is then chosen from + # the credential file (auto-selected or via the select_instance tool). + domain: Optional[str] # username/password are optional: a configured credential file is an # alternative source of credentials (see ENV_CRED_FILEPATH). username: Optional[str] = None @@ -93,6 +97,20 @@ def _cred_file_iris(cred_filepath: str) -> list[str]: return sorted(str(key) for key in data.keys()) +def _derive_domain(iri: str) -> str: + """Derive a bare domain from ``iri`` (a bare domain or a full URL). + + ``OswExpress`` requires a bare domain and validates it with a regex, but + credential-file iris may be either a bare domain (``wiki.example.org``) or + a full URL (``https://wiki.example.org/w/``). + """ + if "://" in iri: + netloc = urlparse(iri).netloc + else: + netloc = iri.split("/", 1)[0] + return netloc.rstrip(".") + + def _verify_cred_file_has_domain(cred_filepath: str, domain: str) -> None: """Verify that the credential file has an entry matching ``domain``. @@ -136,11 +154,12 @@ def load() -> Settings: Raises ------ RuntimeError - If domain is missing, if neither a usable credential file nor - username/password are configured, if a configured credential file - does not exist, or if a configured credential file has no entry - matching the domain. This keeps the osw interactive credential prompt - from ever being reached. + If domain is missing and no usable credential file is configured, if + neither a usable credential file nor username/password are + configured, if a configured credential file does not exist, or if a + configured credential file has no entry matching a configured domain. + This keeps the osw interactive credential prompt from ever being + reached. """ env_file = os.getenv("OSW_MCP_ENV_FILE") if env_file: @@ -163,9 +182,11 @@ def load() -> Settings: ) cred_file_usable = True - # A usable credential file is an alternative source of username/password. - checks = [(ENV_DOMAIN, domain)] + # A usable credential file makes the domain optional: which instance to + # use is then chosen later (auto-selected or via select_instance). + checks = [] if not cred_file_usable: + checks.append((ENV_DOMAIN, domain)) checks.append((ENV_USERNAME, username)) checks.append((ENV_PASSWORD, password)) missing = [names[0] for names, value in checks if not value] @@ -180,7 +201,7 @@ def load() -> Settings: "that would hang the stdio transport." ) - if cred_file_usable: + if cred_file_usable and domain: _verify_cred_file_has_domain(cred_filepath, domain) return Settings( @@ -208,6 +229,129 @@ def get_settings() -> Settings: def reset() -> None: - """Drop cached settings (used by tests).""" - global _settings + """Drop cached settings and the active-instance selection (used by tests).""" + global _settings, _active_iri, _active_resolved _settings = None + _active_iri = None + _active_resolved = False + + +# -- active-instance state --------------------------------------------------- +# +# A server can be configured with several candidate instances (an +# env-configured domain and/or the iris in a credential file). Exactly one of +# them is "active" at a time; tools connect to whichever one is active. The +# active instance is auto-selected on first access (see ``_auto_select_iri``) +# and can be changed at runtime via ``set_active_instance`` (the +# ``select_instance`` tool). + +_active_iri: Optional[str] = None +_active_resolved: bool = False + + +def _auto_select_iri() -> Optional[str]: + """Auto-select the active iri, or return ``None`` if none can be chosen. + + 1. A domain configured via the environment is always the active instance. + 2. Otherwise, if a credential file is configured and contains exactly one + iri, that iri is the active instance. + 3. Otherwise there is no active instance until ``set_active_instance`` is + called (e.g. via the ``select_instance`` tool). + """ + settings = get_settings() + if settings.domain: + return settings.domain + if settings.cred_filepath: + iris = _cred_file_iris(settings.cred_filepath) + if len(iris) == 1: + return iris[0] + return None + + +def available_iris() -> list[str]: + """Return every iri this server can connect to. + + Combines the env-configured domain (if any) with the iris found in a + configured credential file (if any), without duplicates. Never includes + usernames, passwords, or any other credential value. + """ + settings = get_settings() + iris: list[str] = [] + if settings.domain: + iris.append(settings.domain) + if settings.cred_filepath: + for iri in _cred_file_iris(settings.cred_filepath): + if iri not in iris: + iris.append(iri) + return iris + + +def get_active_iri() -> Optional[str]: + """Return the active instance iri, auto-selecting it on first access.""" + global _active_iri, _active_resolved + if not _active_resolved: + _active_iri = _auto_select_iri() + _active_resolved = True + return _active_iri + + +def get_active_domain() -> Optional[str]: + """Return the bare domain of the active instance, or ``None`` if unset.""" + iri = get_active_iri() + if iri is None: + return None + return _derive_domain(iri) + + +def set_active_instance(iri: str) -> None: + """Set the active instance to ``iri``. + + Raises + ------ + ValueError + If ``iri`` is not one of :func:`available_iris`, naming the iris that + are available so the caller can pick a valid one. + """ + global _active_iri, _active_resolved + available = available_iris() + if iri not in available: + raise ValueError( + f"Unknown instance '{iri}'. Available: " + + (", ".join(available) or "(none)") + ) + _active_iri = iri + _active_resolved = True + + +def get_active_credentials() -> tuple[Optional[str], Optional[str]]: + """Return the username/password to use for the currently active instance. + + Resolution order: + + 1. If a credential file is configured, look up the active iri via + ``CredentialManager.get_credential`` with ``fallback=CredentialFallback.none`` + (never prompts interactively, never performs a network login). A + ``UserPwdCredential`` match yields its username/password. A match of any + other credential kind (e.g. ``OAuth1Credential``, which has no + username/password) yields ``(None, None)``. + 2. Otherwise (no credential file configured, or no match found in it), + fall back to ``settings.username`` / ``settings.password``. + 3. If neither source yields anything, returns ``(None, None)``. + + Never raises and never prompts, so this is always safe to call from a + stdio MCP tool. + """ + settings = get_settings() + active_iri = get_active_iri() + if settings.cred_filepath and active_iri: + cred_mngr = CredentialManager(cred_filepath=settings.cred_filepath) + credential = cred_mngr.get_credential( + CredentialManager.CredentialConfig( + iri=active_iri, fallback=CredentialManager.CredentialFallback.none + ) + ) + if credential is not None: + if isinstance(credential, CredentialManager.UserPwdCredential): + return credential.username, credential.password + return None, None + return settings.username, settings.password diff --git a/src/osw/mcp/connection.py b/src/osw/mcp/connection.py index 856d3ed..97ce89a 100644 --- a/src/osw/mcp/connection.py +++ b/src/osw/mcp/connection.py @@ -29,6 +29,18 @@ _ledger: Optional[Ledger] = None +def _require_active_domain() -> str: + """Return the active instance's domain, or raise a clear, actionable error.""" + domain = config.get_active_domain() + if domain is None: + available = ", ".join(config.available_iris()) or "(none)" + raise RuntimeError( + "No OSL instance selected. Call select_instance first; " + f"available: {available}." + ) + return domain + + def get_osw() -> OswExpress: """Return the shared ``OswExpress``, connecting on first use. @@ -40,24 +52,29 @@ def get_osw() -> OswExpress: * a credential file (``settings.cred_filepath``), configured via ``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH``, wrapped in a ``CredentialManager`` and passed to ``OswExpress`` explicitly. + + Connects to the active instance (see :mod:`osw.mcp.config`); raises if + none is selected. """ global _osw if _osw is None: settings = config.get_settings() + domain = _require_active_domain() if settings.cred_filepath: cred_mngr = CredentialManager(cred_filepath=settings.cred_filepath) - _osw = OswExpress(domain=settings.domain, cred_mngr=cred_mngr) + _osw = OswExpress(domain=domain, cred_mngr=cred_mngr) else: - _osw = OswExpress(domain=settings.domain) + _osw = OswExpress(domain=domain) return _osw def get_ledger() -> Ledger: - """Return the shared provenance ledger.""" + """Return the shared provenance ledger, keyed on the active instance's domain.""" global _ledger if _ledger is None: settings = config.get_settings() - _ledger = Ledger(domain=settings.domain, state_dir=settings.state_dir) + domain = _require_active_domain() + _ledger = Ledger(domain=domain, state_dir=settings.state_dir) return _ledger @@ -84,8 +101,12 @@ def run_guarded(fn: Callable[[OswExpress], dict]) -> dict: def reset() -> None: - """Drop the shared connection so the next call rebuilds it.""" - global _osw + """Drop the shared connection and ledger so the next call rebuilds them. + + Called after switching the active instance (``select_instance``) so a + stale connection or a ledger keyed on the previous domain is never reused. + """ + global _osw, _ledger with _LOCK: if _osw is not None: try: @@ -94,6 +115,7 @@ def reset() -> None: except Exception as exc: print(f"[osw-mcp] error closing connection: {exc!r}", file=sys.stderr) _osw = None + _ledger = None def shutdown() -> None: diff --git a/src/osw/mcp/tools/__init__.py b/src/osw/mcp/tools/__init__.py index d7e782b..f67d9aa 100644 --- a/src/osw/mcp/tools/__init__.py +++ b/src/osw/mcp/tools/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from . import entities, files, schema, search, slots, status +from . import entities, files, instances, schema, search, slots, status def register_all(mcp, *, include_writes: bool) -> None: @@ -10,6 +10,8 @@ def register_all(mcp, *, include_writes: bool) -> None: Mutating tools (create/update/delete/upload/set_slot) are only registered when ``include_writes`` is true, so a read-only server never exposes them. + Instance-selection tools are always registered: they change server-local + state (which OSL instance subsequent calls talk to), not wiki content. """ search.register(mcp) schema.register(mcp) @@ -17,3 +19,4 @@ def register_all(mcp, *, include_writes: bool) -> None: files.register(mcp, include_writes=include_writes) slots.register(mcp, include_writes=include_writes) status.register(mcp) + instances.register(mcp) diff --git a/src/osw/mcp/tools/entities.py b/src/osw/mcp/tools/entities.py index d97e140..eec41c9 100644 --- a/src/osw/mcp/tools/entities.py +++ b/src/osw/mcp/tools/entities.py @@ -174,10 +174,11 @@ def _run(osw): change_id=store.change_id, slots=["jsondata"], ) + domain = config.get_active_domain() return { "titles": titles, "change_id": store.change_id, - "urls": [f"https://{settings.domain}/wiki/{t}" for t in titles], + "urls": [f"https://{domain}/wiki/{t}" for t in titles], } return run_guarded(_run) diff --git a/src/osw/mcp/tools/instances.py b/src/osw/mcp/tools/instances.py new file mode 100644 index 0000000..d022686 --- /dev/null +++ b/src/osw/mcp/tools/instances.py @@ -0,0 +1,49 @@ +"""Instance selection tools: list and switch between configured OSL instances. + +A server can be configured with several candidate instances (an env-configured +domain and/or the iris in a credential file, see :mod:`osw.mcp.config`). These +tools let the model discover the available instances and pick which one +subsequent tool calls talk to. Registered unconditionally, not gated on +``include_writes``: they change server-local state, not wiki content. +""" + +from __future__ import annotations + +from .. import config, connection + + +def register(mcp) -> None: + """Register the instance-selection tools on ``mcp``.""" + + @mcp.tool() + def list_instances() -> dict: + """List the OSL instances this server can connect to. + + Reports the iris available from the env-configured domain and/or a + configured credential file, and which one (if any) is currently + active. Never returns usernames, passwords, or any credential value. + """ + return { + "iris": config.available_iris(), + "active_iri": config.get_active_iri(), + "active_domain": config.get_active_domain(), + } + + @mcp.tool() + def select_instance(iri: str) -> dict: + """Select the OSL instance subsequent tool calls should talk to. + + ``iri`` must be one of the iris returned by ``list_instances``. + Rebuilds the shared connection and provenance ledger so a stale + instance is never reused, but does not connect eagerly; the next + tool call connects to the newly selected instance. + """ + try: + config.set_active_instance(iri) + except ValueError as exc: + return {"error": str(exc), "type": "UnknownInstance"} + connection.reset() + return { + "active_iri": config.get_active_iri(), + "active_domain": config.get_active_domain(), + } diff --git a/src/osw/mcp/tools/search.py b/src/osw/mcp/tools/search.py index 35fede6..03282a3 100644 --- a/src/osw/mcp/tools/search.py +++ b/src/osw/mcp/tools/search.py @@ -84,12 +84,13 @@ def sparql_query( } def _run(_osw): + username, password = config.get_active_credentials() client = SmwSparqlClient( endpoint=ep, - domain=settings.domain, + domain=config.get_active_domain(), auth="basic", - user=settings.username, - password=settings.password, + user=username, + password=password, ) raw = client.sparqlQuery(query) bindings = raw.get("results", {}).get("bindings", []) diff --git a/src/osw/mcp/tools/status.py b/src/osw/mcp/tools/status.py index c3229df..d299124 100644 --- a/src/osw/mcp/tools/status.py +++ b/src/osw/mcp/tools/status.py @@ -22,18 +22,31 @@ def register(mcp) -> None: @mcp.tool() def status() -> dict: - """Report the connected domain, user, mode and ledger info. + """Report the active instance, user, mode and ledger info. - Performs a light connectivity check. Never returns the password. + Performs a light connectivity check, but only when an instance is + selected. Never returns the password. """ settings = config.get_settings() - ledger = get_ledger() + active_iri = config.get_active_iri() + active_domain = config.get_active_domain() info = { **settings.redacted(), - "ledger_path": str(ledger.path), - "ledger_entry_count": ledger.entry_count(), - "osw_version": _osw_version(), + "active_iri": active_iri, + "active_domain": active_domain, } + if active_iri is None: + available = ", ".join(config.available_iris()) or "(none)" + info["connected"] = False + info["message"] = ( + "No OSL instance selected. Call select_instance to choose " + f"one; available: {available}." + ) + return info + ledger = get_ledger() + info["ledger_path"] = str(ledger.path) + info["ledger_entry_count"] = ledger.entry_count() + info["osw_version"] = _osw_version() try: with osw_guard(): info["connected"] = True diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index 65aaad9..5c8ba8a 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -179,12 +179,31 @@ def test_cred_file_missing_domain_entry_raises(monkeypatch, tmp_path): assert "wiki.example.org" in str(exc.value) -def test_cred_file_missing_domain_env_still_raises(monkeypatch, tmp_path): +def test_cred_file_without_domain_is_legal(monkeypatch, tmp_path): + # With a usable credential file, a missing domain is no longer an error: + # which instance to use is chosen later (auto-selected or via + # select_instance). cred_file = _write_cred_file( tmp_path / "accounts.yaml", - {"wiki.example.org": {"username": "alice", "password": "secret"}}, + { + "wiki-a.example.org": {"username": "alice", "password": "secret"}, + "wiki-b.example.org": {"username": "bob", "password": "secret2"}, + }, ) monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) - with pytest.raises(RuntimeError) as exc: - config.load() - assert "OSW_DOMAIN" in str(exc.value) + settings = config.load() + assert settings.domain is None + assert settings.cred_filepath == str(cred_file) + + +def test_cred_file_without_domain_skips_domain_verification(monkeypatch, tmp_path): + # No domain configured means there is nothing to verify at startup, even + # though the file does not contain an entry named after any particular + # domain the caller might later select. + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki-a.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + settings = config.load() + assert settings.domain is None diff --git a/tests/test_mcp_instances.py b/tests/test_mcp_instances.py new file mode 100644 index 0000000..3846f10 --- /dev/null +++ b/tests/test_mcp_instances.py @@ -0,0 +1,307 @@ +"""Unit tests for multi-instance selection in osw.mcp (config + connection + tools). + +These are fully offline: no network, no live wiki. +""" + +import pytest +import yaml + +pytest.importorskip("mcp", reason="requires the osw[mcp] extra") +pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") + +from osw.mcp import config, connection +from osw.mcp.tools import instances + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", + "OSW_SPARQL_ENDPOINT", + "OSW_MCP_READ_ONLY", + "OSW_MCP_STATE_DIR", + "OSW_MCP_MAX_RESULTS", + "OSW_MCP_MAX_CHARS", + "OSW_MCP_ENV_FILE", +] + + +class FakeMCP: + """Minimal stand-in that captures @tool-decorated functions by name.""" + + def __init__(self): + self.tools = {} + + def tool(self, *_a, **_k): + def deco(fn): + self.tools[fn.__name__] = fn + return fn + + return deco + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch, tmp_path): + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + # Point dotenv at an empty file so it never picks up a real .env on disk + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) + config.reset() + connection._osw = None + connection._ledger = None + yield + config.reset() + connection._osw = None + connection._ledger = None + + +def _write_cred_file(path, data): + path.write_text(yaml.safe_dump(data), encoding="utf-8") + return path + + +# -- auto-selection --------------------------------------------------------- +def test_auto_select_from_configured_domain(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + + assert config.get_active_iri() == "wiki.example.org" + assert config.get_active_domain() == "wiki.example.org" + + +def test_auto_select_single_iri_cred_file(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki-dev.open-semantic-lab.org": {"username": "a", "password": "b"}}, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + assert config.get_active_iri() == "wiki-dev.open-semantic-lab.org" + assert config.get_active_domain() == "wiki-dev.open-semantic-lab.org" + + +def test_no_auto_select_with_multiple_iris(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + assert config.get_active_iri() is None + assert config.get_active_domain() is None + + +# -- set_active_instance / select_instance ---------------------------------- +def test_set_active_instance_valid(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + config.set_active_instance("wiki-b.example.org") + + assert config.get_active_iri() == "wiki-b.example.org" + assert config.get_active_domain() == "wiki-b.example.org" + + +def test_set_active_instance_unknown_iri_raises(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki-a.example.org": {"username": "a", "password": "b"}}, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + with pytest.raises(ValueError) as exc: + config.set_active_instance("does-not-exist.example.org") + assert "wiki-a.example.org" in str(exc.value) + + +def test_select_instance_tool_sets_active(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + fake = FakeMCP() + instances.register(fake) + + result = fake.tools["select_instance"](iri="wiki-b.example.org") + + assert result["active_iri"] == "wiki-b.example.org" + assert result["active_domain"] == "wiki-b.example.org" + assert config.get_active_iri() == "wiki-b.example.org" + + +def test_select_instance_tool_unknown_iri_returns_error(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki-a.example.org": {"username": "a", "password": "b"}}, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + fake = FakeMCP() + instances.register(fake) + + result = fake.tools["select_instance"](iri="nope.example.org") + + assert result["type"] == "UnknownInstance" + assert "wiki-a.example.org" in result["error"] + + +# -- list_instances never leaks credentials --------------------------------- +def test_list_instances_never_leaks_credentials(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "alice", "password": "supersecret"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + fake = FakeMCP() + instances.register(fake) + + result = fake.tools["list_instances"]() + + assert result["iris"] == ["wiki-a.example.org"] + assert result["active_iri"] == "wiki-a.example.org" + assert "supersecret" not in str(result) + assert "alice" not in str(result) + + +# -- get_osw() / run_guarded without an active instance ---------------------- +def test_get_osw_raises_when_no_instance_selected(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + with pytest.raises(RuntimeError) as exc: + connection.get_osw() + assert "No OSL instance selected" in str(exc.value) + assert "wiki-a.example.org" in str(exc.value) + assert "wiki-b.example.org" in str(exc.value) + + +def test_run_guarded_surfaces_no_instance_selected_as_structured_dict( + monkeypatch, tmp_path +): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + result = connection.run_guarded(lambda osw: {"ok": True}) + + assert result["type"] == "RuntimeError" + assert "No OSL instance selected" in result["error"] + + +# -- domain derivation helper ------------------------------------------------- +def test_derive_domain_from_bare_domain(): + assert ( + config._derive_domain("wiki-dev.open-semantic-lab.org") + == "wiki-dev.open-semantic-lab.org" + ) + + +def test_derive_domain_from_full_url(): + assert ( + config._derive_domain("https://wiki-dev.open-semantic-lab.org/w/") + == "wiki-dev.open-semantic-lab.org" + ) + + +# -- connection.reset() drops the ledger ------------------------------------- +def test_reset_drops_ledger_for_new_domain_after_switching(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + monkeypatch.setenv("OSW_MCP_STATE_DIR", str(tmp_path / "state")) + config.set_active_instance("wiki-a.example.org") + + ledger_a = connection.get_ledger() + assert "wiki-a.example.org" in str(ledger_a.path) + + config.set_active_instance("wiki-b.example.org") + connection.reset() + ledger_b = connection.get_ledger() + + assert "wiki-b.example.org" in str(ledger_b.path) + assert ledger_a.path != ledger_b.path + + +# -- get_active_credentials --------------------------------------------------- +def test_get_active_credentials_from_cred_file(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki-a.example.org": {"username": "alice", "password": "s3cret"}}, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + + assert config.get_active_iri() == "wiki-a.example.org" + assert config.get_active_credentials() == ("alice", "s3cret") + + +def test_get_active_credentials_follows_instance_switch(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + { + "wiki-a.example.org": {"username": "alice", "password": "a-pw"}, + "wiki-b.example.org": {"username": "bob", "password": "b-pw"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + config.set_active_instance("wiki-a.example.org") + assert config.get_active_credentials() == ("alice", "a-pw") + + config.set_active_instance("wiki-b.example.org") + + assert config.get_active_credentials() == ("bob", "b-pw") + + +def test_get_active_credentials_falls_back_to_env(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + + assert config.get_active_credentials() == ("alice", "secret") + + +def test_get_active_credentials_returns_none_none_without_raising(monkeypatch): + # A domain-only, cred-file-less, credential-less settings object cannot be + # produced through config.load() itself (it would raise); construct it + # directly to exercise the "nothing resolves" path of get_active_credentials. + monkeypatch.setattr( + config, "get_settings", lambda: config.Settings(domain="wiki.example.org") + ) + + assert config.get_active_credentials() == (None, None) diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index 9050058..cfa65de 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock import pytest +import yaml pytest.importorskip("mcp", reason="requires the osw[mcp] extra") pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") @@ -185,6 +186,46 @@ def test_sparql_without_endpoint_reports_not_configured(env, monkeypatch): assert result["type"] == "NotConfigured" +def test_create_or_update_entity_uses_active_domain(env, monkeypatch, tmp_path): + """The response urls use the active domain, not a stale/static one.""" + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({ + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }), + encoding="utf-8", + ) + monkeypatch.delenv("OSW_DOMAIN", raising=False) + monkeypatch.delenv("OSW_USERNAME", raising=False) + monkeypatch.delenv("OSW_PASSWORD", raising=False) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + config.reset() + connection._osw = None + connection._ledger = None + config.set_active_instance("wiki-b.example.org") + + osw = MagicMock() + osw.fetch_schema.return_value = MagicMock(error_messages=[]) + osw.store_entity.return_value = MagicMock( + pages={"Item:OSW1": MagicMock()}, change_id="c1" + ) + monkeypatch.setattr(connection, "get_osw", lambda: osw) + monkeypatch.setattr( + entities, + "_resolve_category_class", + lambda category: entities.model_entity.Entity, + ) + fake = FakeMCP() + entities.register(fake, include_writes=True) + + result = fake.tools["create_or_update_entity"]( + category="Category:Item", jsondata={"label": [{"text": "Test"}]} + ) + + assert result["urls"] == ["https://wiki-b.example.org/wiki/Item:OSW1"] + + def test_run_guarded_converts_exceptions(env, monkeypatch): osw = MagicMock() osw.site.get_page.side_effect = RuntimeError("boom") From ec54bcd5e3e32558b5418d983ac4065e92317130 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 25 Aug 2026 15:42:47 +0200 Subject: [PATCH 05/28] refactor: extract SDK-free osw.service core from osw.mcp - move config/ledger/serialization from osw.mcp to osw.service - add errors.py (OpError), context.py (Context+Policy), registry.py - Operation validator rejects path-like params on the mcp surface - canonical OSW_* env names, OSW_MCP_*/OSL_* kept as aliases - config/ledger/serialization tests now run without the mcp extra --- pyproject.toml | 6 +- src/osw/mcp/connection.py | 9 +- src/osw/mcp/server.py | 6 +- src/osw/mcp/tools/entities.py | 4 +- src/osw/mcp/tools/instances.py | 6 +- src/osw/mcp/tools/schema.py | 4 +- src/osw/mcp/tools/search.py | 4 +- src/osw/mcp/tools/slots.py | 4 +- src/osw/mcp/tools/status.py | 3 +- src/osw/service/__init__.py | 6 + src/osw/{mcp => service}/config.py | 120 +++++-- src/osw/service/context.py | 161 +++++++++ src/osw/service/errors.py | 118 +++++++ src/osw/{mcp => service}/ledger.py | 17 + src/osw/service/registry.py | 181 ++++++++++ src/osw/{mcp => service}/serialization.py | 0 tests/integration/test_mcp_server.py | 3 +- tests/test_mcp_instances.py | 3 +- tests/test_mcp_tools.py | 3 +- ...t_mcp_config.py => test_service_config.py} | 201 ++++++++++- tests/test_service_context.py | 189 ++++++++++ tests/test_service_errors.py | 128 +++++++ ...t_mcp_ledger.py => test_service_ledger.py} | 4 +- tests/test_service_registry.py | 334 ++++++++++++++++++ ...ation.py => test_service_serialization.py} | 4 +- uv.lock | 4 + 26 files changed, 1456 insertions(+), 66 deletions(-) create mode 100644 src/osw/service/__init__.py rename src/osw/{mcp => service}/config.py (71%) create mode 100644 src/osw/service/context.py create mode 100644 src/osw/service/errors.py rename src/osw/{mcp => service}/ledger.py (91%) create mode 100644 src/osw/service/registry.py rename src/osw/{mcp => service}/serialization.py (100%) rename tests/{test_mcp_config.py => test_service_config.py} (50%) create mode 100644 tests/test_service_context.py create mode 100644 tests/test_service_errors.py rename tests/{test_mcp_ledger.py => test_service_ledger.py} (96%) create mode 100644 tests/test_service_registry.py rename tests/{test_mcp_serialization.py => test_service_serialization.py} (92%) diff --git a/pyproject.toml b/pyproject.toml index e1f52ca..8a324a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,8 @@ build-backend = "hatchling.build" [dependency-groups] # pytest stack in its own group so it can be installed alongside the mcp # extra, which conflicts with the dev group (see [tool.uv] below). -# Run the MCP tests with: +# tests/test_service_*.py run in a plain `uv sync` dev env (no mcp extra +# needed). Run the MCP-extra tests (tests/test_mcp_*.py) with: # uv sync --extra mcp --group test --no-dev # uv run --extra mcp --group test --no-dev --no-sync python -m pytest tests/test_mcp_*.py test = [ @@ -110,6 +111,9 @@ test = [ "pytest-cov", "pytest-mock", "pytest-asyncio", + # not part of the mcp conflict set, so the shared service layer (which reads + # .env lazily) stays importable and testable in a plain `uv sync` env. + "python-dotenv>=1.0", ] dev = [ { include-group = "test" }, diff --git a/src/osw/mcp/connection.py b/src/osw/mcp/connection.py index 97ce89a..3005696 100644 --- a/src/osw/mcp/connection.py +++ b/src/osw/mcp/connection.py @@ -20,9 +20,8 @@ from osw.auth import CredentialManager from osw.express import OswExpress - -from . import config -from .ledger import Ledger +from osw.service import config +from osw.service.ledger import Ledger _LOCK = threading.RLock() _osw: Optional[OswExpress] = None @@ -45,7 +44,7 @@ def get_osw() -> OswExpress: """Return the shared ``OswExpress``, connecting on first use. Credentials come from either of two sources, both already validated by - :func:`osw.mcp.config.load`: + :func:`osw.service.config.load`: * ``OSW_USERNAME`` / ``OSW_PASSWORD`` (or their ``OSL_*`` aliases), read by osw from the environment; or @@ -53,7 +52,7 @@ def get_osw() -> OswExpress: ``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH``, wrapped in a ``CredentialManager`` and passed to ``OswExpress`` explicitly. - Connects to the active instance (see :mod:`osw.mcp.config`); raises if + Connects to the active instance (see :mod:`osw.service.config`); raises if none is selected. """ global _osw diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py index 93b8ed8..0dceead 100644 --- a/src/osw/mcp/server.py +++ b/src/osw/mcp/server.py @@ -2,7 +2,7 @@ Run via the ``osw-mcp`` console script or ``python -m osw.mcp``. Connection credentials come from the environment / a ``.env`` file (see -:mod:`osw.mcp.config`). +:mod:`osw.service.config`). """ from __future__ import annotations @@ -12,7 +12,9 @@ from mcp.server import MCPServer -from . import config, connection +from osw.service import config + +from . import connection from .tools import register_all diff --git a/src/osw/mcp/tools/entities.py b/src/osw/mcp/tools/entities.py index eec41c9..16feb36 100644 --- a/src/osw/mcp/tools/entities.py +++ b/src/osw/mcp/tools/entities.py @@ -7,11 +7,11 @@ import osw.model.entity as model_entity from osw.core import OSW, AddOverwriteClassOptions, OverwriteOptions +from osw.service import config +from osw.service.serialization import maybe_truncate, to_jsonable from osw.wtsite import WtSite -from .. import config from ..connection import get_ledger, run_guarded -from ..serialization import maybe_truncate, to_jsonable _OVERWRITE = { "true": OverwriteOptions.true, diff --git a/src/osw/mcp/tools/instances.py b/src/osw/mcp/tools/instances.py index d022686..b271524 100644 --- a/src/osw/mcp/tools/instances.py +++ b/src/osw/mcp/tools/instances.py @@ -1,7 +1,7 @@ """Instance selection tools: list and switch between configured OSL instances. A server can be configured with several candidate instances (an env-configured -domain and/or the iris in a credential file, see :mod:`osw.mcp.config`). These +domain and/or the iris in a credential file, see :mod:`osw.service.config`). These tools let the model discover the available instances and pick which one subsequent tool calls talk to. Registered unconditionally, not gated on ``include_writes``: they change server-local state, not wiki content. @@ -9,7 +9,9 @@ from __future__ import annotations -from .. import config, connection +from osw.service import config + +from .. import connection def register(mcp) -> None: diff --git a/src/osw/mcp/tools/schema.py b/src/osw/mcp/tools/schema.py index ef8e8dd..283e0fe 100644 --- a/src/osw/mcp/tools/schema.py +++ b/src/osw/mcp/tools/schema.py @@ -3,11 +3,11 @@ from __future__ import annotations +from osw.service import config +from osw.service.serialization import maybe_truncate from osw.wtsite import WtSite -from .. import config from ..connection import run_guarded -from ..serialization import maybe_truncate def register(mcp) -> None: diff --git a/src/osw/mcp/tools/search.py b/src/osw/mcp/tools/search.py index 03282a3..416e0d2 100644 --- a/src/osw/mcp/tools/search.py +++ b/src/osw/mcp/tools/search.py @@ -5,12 +5,12 @@ from typing import Optional from osw.core import OSW +from osw.service import config +from osw.service.serialization import cap_list, to_jsonable from osw.sparql_client_smw import SmwSparqlClient from osw.wtsite import WtSite -from .. import config from ..connection import run_guarded -from ..serialization import cap_list, to_jsonable def register(mcp) -> None: diff --git a/src/osw/mcp/tools/slots.py b/src/osw/mcp/tools/slots.py index c6cb271..319357b 100644 --- a/src/osw/mcp/tools/slots.py +++ b/src/osw/mcp/tools/slots.py @@ -10,11 +10,11 @@ from typing import Optional, Union +from osw.service import config +from osw.service.serialization import maybe_truncate from osw.wtsite import SLOTS, WtSite -from .. import config from ..connection import get_ledger, run_guarded -from ..serialization import maybe_truncate def _invalid_slot(slot: str) -> dict: diff --git a/src/osw/mcp/tools/status.py b/src/osw/mcp/tools/status.py index d299124..cfbcd2d 100644 --- a/src/osw/mcp/tools/status.py +++ b/src/osw/mcp/tools/status.py @@ -4,7 +4,8 @@ import sys -from .. import config +from osw.service import config + from ..connection import get_ledger, osw_guard diff --git a/src/osw/service/__init__.py b/src/osw/service/__init__.py new file mode 100644 index 0000000..33dac85 --- /dev/null +++ b/src/osw/service/__init__.py @@ -0,0 +1,6 @@ +"""osw.service: SDK-free shared core used by both ``osw-mcp`` and the ``osw`` CLI. + +Nothing in this package may import the ``mcp`` SDK or ``osw.cli``. +""" + +from __future__ import annotations diff --git a/src/osw/mcp/config.py b/src/osw/service/config.py similarity index 71% rename from src/osw/mcp/config.py rename to src/osw/service/config.py index 21a47fe..f093542 100644 --- a/src/osw/mcp/config.py +++ b/src/osw/service/config.py @@ -18,19 +18,27 @@ import yaml -# python-dotenv is part of the [mcp] extra -from dotenv import load_dotenv - from osw.auth import CredentialManager _TRUTHY = {"1", "true", "yes", "on"} -# Environment variable names (OSL_* are accepted as fallbacks, matching osw). +# Environment variable names. Each tuple lists the canonical ``OSW_*`` name +# first, followed by every alias that must keep working. ``OSW_CRED_FILEPATH`` +# is canonical (rather than an ``OSW_MCP_``-prefixed name) because +# ``osw.express`` already reads that exact name (see ``src/osw/express.py``, +# search for ``CRED_FILEPATH``); the ``OSW_MCP_`` prefix used elsewhere was a +# gratuitous divergence from that. ``OSL_*`` names are accepted as legacy +# fallbacks, matching osw itself. ENV_DOMAIN = ("OSW_DOMAIN", "OSL_DOMAIN") ENV_USERNAME = ("OSW_USERNAME", "OSL_USERNAME") ENV_PASSWORD = ("OSW_PASSWORD", "OSL_PASSWORD") -# OSL_CRED_FILEPATH is accepted because existing osw deployments already set it. -ENV_CRED_FILEPATH = ("OSW_MCP_CRED_FILEPATH", "OSL_CRED_FILEPATH") +ENV_CRED_FILEPATH = ("OSW_CRED_FILEPATH", "OSW_MCP_CRED_FILEPATH", "OSL_CRED_FILEPATH") +ENV_SPARQL_ENDPOINT = ("OSW_SPARQL_ENDPOINT",) +ENV_READ_ONLY = ("OSW_READ_ONLY", "OSW_MCP_READ_ONLY") +ENV_STATE_DIR = ("OSW_STATE_DIR", "OSW_MCP_STATE_DIR") +ENV_MAX_RESULTS = ("OSW_MAX_RESULTS", "OSW_MCP_MAX_RESULTS") +ENV_MAX_CHARS = ("OSW_MAX_CHARS", "OSW_MCP_MAX_CHARS") +ENV_FILE = ("OSW_ENV_FILE", "OSW_MCP_ENV_FILE") def _first_env(names: tuple[str, ...]) -> Optional[str]: @@ -73,13 +81,16 @@ def redacted(self) -> dict: } -def _int_env(name: str, default: int) -> int: - raw = os.getenv(name) +def _int_env(names: tuple[str, ...], default: int) -> int: + raw = _first_env(names) if raw is None or raw.strip() == "": return default try: return int(raw) except ValueError: + # Name the variable that was actually set (not necessarily the + # canonical one), so the operator can find what to fix. + name = next((n for n in names if os.getenv(n) == raw), names[0]) raise RuntimeError( f"Environment variable {name}={raw!r} is not a valid integer." ) @@ -139,33 +150,70 @@ def _verify_cred_file_has_domain(cred_filepath: str, domain: str) -> None: ) -def load() -> Settings: +def _load_env_file() -> None: + """Load a .env file if one is configured or discoverable. + + dotenv is optional (it ships with the ``mcp`` extra). An *explicitly* + configured env file with dotenv missing is an error, because the operator + asked for something that cannot happen. An implicit search is skipped + silently. + """ + path = _first_env(ENV_FILE) + try: + import dotenv + except ImportError: + if path is None: + return + name = next((n for n in ENV_FILE if os.getenv(n) == path), ENV_FILE[0]) + raise RuntimeError( + f"{name} is set (to '{path}') but python-dotenv is not installed. " + "Install the osw[mcp] extra, or the `test` dependency group, to " + "use an env file." + ) + if path: + dotenv.load_dotenv(path) + else: + dotenv.load_dotenv() + + +def load(strict: bool = True) -> Settings: """Load and validate settings from the environment. - Loads a ``.env`` file first: the path in ``OSW_MCP_ENV_FILE`` if set, - otherwise dotenv's default search from the current working directory upward. + Loads a ``.env`` file first: the path in ``OSW_ENV_FILE`` (or its + ``OSW_MCP_ENV_FILE`` alias) if set, otherwise dotenv's default search from + the current working directory upward. Credentials can come from either ``OSW_USERNAME``/``OSW_PASSWORD`` (or their ``OSL_*`` aliases) or from a credential file configured via - ``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH``. When a credential file - is configured, it is validated here to actually contain an entry for the - configured domain. + ``OSW_CRED_FILEPATH`` (or its ``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH`` + aliases). When a credential file is configured, it is validated here to + actually contain an entry for the configured domain. + + Parameters + ---------- + strict: + When ``True`` (the default), missing required credentials (no domain + + username/password and no usable credential file) raise + ``RuntimeError``. When ``False``, that specific check is skipped and a + best-effort ``Settings`` is returned instead, with whatever was found + (fields may be ``None``) -- useful for a status command that wants to + report "not configured" rather than crash. Every other error still + raises regardless of ``strict``: a configured credential file that + does not exist, a configured credential file with no entry matching a + configured domain, an unparseable integer environment variable, and a + missing ``python-dotenv`` for an explicitly configured env file. Raises ------ RuntimeError If domain is missing and no usable credential file is configured, if neither a usable credential file nor username/password are - configured, if a configured credential file does not exist, or if a - configured credential file has no entry matching a configured domain. - This keeps the osw interactive credential prompt from ever being - reached. + configured (only when ``strict`` is ``True``), if a configured + credential file does not exist, or if a configured credential file has + no entry matching a configured domain. This keeps the osw interactive + credential prompt from ever being reached. """ - env_file = os.getenv("OSW_MCP_ENV_FILE") - if env_file: - load_dotenv(env_file) - else: - load_dotenv() + _load_env_file() domain = _first_env(ENV_DOMAIN) username = _first_env(ENV_USERNAME) @@ -177,8 +225,9 @@ def load() -> Settings: if not Path(cred_filepath).is_file(): raise RuntimeError( f"Configured credential file '{cred_filepath}' does not exist. " - "Set OSW_MCP_CRED_FILEPATH / OSL_CRED_FILEPATH to a valid path, " - "or remove it and configure OSW_USERNAME/OSW_PASSWORD instead." + "Set OSW_CRED_FILEPATH (or its OSW_MCP_CRED_FILEPATH / " + "OSL_CRED_FILEPATH aliases) to a valid path, or remove it and " + "configure OSW_USERNAME/OSW_PASSWORD instead." ) cred_file_usable = True @@ -190,15 +239,16 @@ def load() -> Settings: checks.append((ENV_USERNAME, username)) checks.append((ENV_PASSWORD, password)) missing = [names[0] for names, value in checks if not value] - if missing: + if missing and strict: raise RuntimeError( "Missing required OSW credential environment variables: " + ", ".join(missing) + ". Set them in your environment or a .env file " - "(pointed to by OSW_MCP_ENV_FILE), or configure a credential file " - "via OSW_MCP_CRED_FILEPATH / OSL_CRED_FILEPATH. The server refuses " - "to start without them to avoid an interactive credential prompt " - "that would hang the stdio transport." + "(pointed to by OSW_ENV_FILE / OSW_MCP_ENV_FILE), or configure a " + "credential file via OSW_CRED_FILEPATH (or its OSW_MCP_CRED_FILEPATH " + "/ OSL_CRED_FILEPATH aliases). The server refuses to start without " + "them to avoid an interactive credential prompt that would hang " + "the stdio transport." ) if cred_file_usable and domain: @@ -209,11 +259,11 @@ def load() -> Settings: username=username, password=password, cred_filepath=cred_filepath, - sparql_endpoint=os.getenv("OSW_SPARQL_ENDPOINT") or None, - read_only=(os.getenv("OSW_MCP_READ_ONLY", "").lower() in _TRUTHY), - state_dir=os.getenv("OSW_MCP_STATE_DIR") or None, - max_results=_int_env("OSW_MCP_MAX_RESULTS", 100), - max_chars=_int_env("OSW_MCP_MAX_CHARS", 100_000), + sparql_endpoint=_first_env(ENV_SPARQL_ENDPOINT), + read_only=(_first_env(ENV_READ_ONLY) or "").lower() in _TRUTHY, + state_dir=_first_env(ENV_STATE_DIR), + max_results=_int_env(ENV_MAX_RESULTS, 100), + max_chars=_int_env(ENV_MAX_CHARS, 100_000), ) diff --git a/src/osw/service/context.py b/src/osw/service/context.py new file mode 100644 index 0000000..d0d7162 --- /dev/null +++ b/src/osw/service/context.py @@ -0,0 +1,161 @@ +"""Per-instance execution context shared by every osw.service adapter. + +Replaces the module-level globals in :mod:`osw.mcp.connection` (``_osw``, +``_ledger``, ``_LOCK``) with an object, so a single process can hold more than +one connected instance and tests can inject a fake ``osw``/``ledger`` instead +of monkeypatching a module. + +The osw library prints progress to ``stdout`` (e.g. "Connecting to ..."). On +the MCP stdio transport ``stdout`` is the JSON-RPC channel, so +:meth:`Context.guard` redirects it to ``stderr`` for the duration of each osw +call -- but only when ``policy.capture_stdout`` is set. A plain CLI run wants +that progress output visible, so its policy leaves stdout alone. +""" + +from __future__ import annotations + +import sys +import threading +from contextlib import contextmanager, redirect_stdout +from dataclasses import dataclass +from typing import Optional + +from osw.auth import CredentialManager +from osw.express import OswExpress +from osw.service import config, errors +from osw.service.config import Settings +from osw.service.ledger import Ledger +from osw.wtsite import WtSite + + +@dataclass(frozen=True) +class Policy: + """How an adapter wants operations to behave.""" + + capture_stdout: bool = False # stdout is the JSON-RPC channel (MCP) or --json + errors_as_dicts: bool = False # a model needs a result; a shell needs an exit code + allow_writes: bool = True + allow_interactive: bool = False # a prompt would eat the JSON-RPC stream + + +class Context: + """Everything a bound operation needs to run against one OSL instance. + + ``osw`` and ``ledger`` are built lazily on first access; tests may instead + pre-set them (via the constructor or by assigning the attribute directly) + to inject a fake without monkeypatching a module. + """ + + def __init__( + self, + settings: Settings, + policy: Optional[Policy] = None, + *, + osw: Optional[OswExpress] = None, + ledger: Optional[Ledger] = None, + ) -> None: + self.settings = settings + self.policy = policy if policy is not None else Policy() + self._osw = osw + self._ledger = ledger + self._lock = threading.RLock() + + def _require_active_domain(self) -> str: + """Return the active instance's domain, or raise a clear, actionable error.""" + domain = config.get_active_domain() + if domain is None: + available = ", ".join(config.available_iris()) or "(none)" + raise errors.NotConfigured( + "No OSL instance selected. Call select_instance first; " + f"available: {available}." + ) + return domain + + @property + def osw(self) -> OswExpress: + """The shared ``OswExpress``, connecting on first use. + + Credentials come from either of two sources, both already validated + by :func:`osw.service.config.load`: + + * ``OSW_USERNAME`` / ``OSW_PASSWORD`` (or their ``OSL_*`` aliases), + read by osw from the environment; or + * a credential file (``settings.cred_filepath``), wrapped in a + ``CredentialManager`` and passed to ``OswExpress`` explicitly. + """ + if self._osw is None: + domain = self._require_active_domain() + if self.settings.cred_filepath: + cred_mngr = CredentialManager(cred_filepath=self.settings.cred_filepath) + self._osw = OswExpress(domain=domain, cred_mngr=cred_mngr) + else: + self._osw = OswExpress(domain=domain) + return self._osw + + @osw.setter + def osw(self, value: Optional[OswExpress]) -> None: + self._osw = value + + @property + def ledger(self) -> Ledger: + """The shared provenance ledger, keyed on the active instance's domain.""" + if self._ledger is None: + domain = self._require_active_domain() + self._ledger = Ledger(domain=domain, state_dir=self.settings.state_dir) + return self._ledger + + @ledger.setter + def ledger(self, value: Optional[Ledger]) -> None: + self._ledger = value + + @contextmanager + def guard(self): + """Serialize access to this context's instance for the call's duration. + + Redirects ``stdout`` to ``stderr`` only when ``policy.capture_stdout`` + is set (a plain CLI run wants osw's progress output visible). + """ + with self._lock: + if self.policy.capture_stdout: + with redirect_stdout(sys.stderr): + yield + else: + yield + + def limit(self, n: Optional[int]) -> int: + """Return ``n`` if given and truthy, else the configured default.""" + return n or self.settings.max_results + + def page(self, title: str): + """Return the page for ``title``. + + Raises :class:`osw.service.errors.NotFound` if it does not exist. + """ + page = self.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + raise errors.NotFound(f"Page '{title}' does not exist.") + return page + + def require_write(self, op_name: str) -> None: + """Raise if this context's policy disallows writes.""" + if not self.policy.allow_writes: + raise errors.ReadOnly( + f"Operation '{op_name}' is not permitted: writes are disabled " + "(set OSW_READ_ONLY=false to allow)." + ) + + def reset(self) -> None: + """Drop the held connection and ledger so the next access rebuilds them.""" + with self._lock: + if self._osw is not None: + try: + with redirect_stdout(sys.stderr): + self._osw.close_connection() + except Exception as exc: + print(f"[osw] error closing connection: {exc!r}", file=sys.stderr) + self._osw = None + self._ledger = None + + def close(self) -> None: + """Close the connection (e.g. on adapter shutdown).""" + self.reset() diff --git a/src/osw/service/errors.py b/src/osw/service/errors.py new file mode 100644 index 0000000..790ed5b --- /dev/null +++ b/src/osw/service/errors.py @@ -0,0 +1,118 @@ +"""Stable-shaped operation errors shared by every osw.service adapter. + +Every operation failure is an :class:`OpError` subclass carrying a wire +``type`` string (the shape an MCP client sees, unchanged from the +hand-written error dicts the tool bodies returned before this module +existed) and an ``exit_code`` (the process exit status a CLI adapter uses). + +Exit codes are grouped by category, not unique per subclass: + +* ``1`` -- generic / unexpected error (the ``OpError`` base default). +* ``2`` -- not found: a page/entity expected to exist does not + (:class:`NotFound`). +* ``3`` -- invalid input: an argument is malformed, does not validate, or + does not resolve (:class:`SchemaError`, :class:`ClassNotFound`, + :class:`ValidationError`, :class:`UnknownInstance`, :class:`InvalidSlot`, + :class:`InvalidContent`, :class:`SlotMissing`). +* ``4`` -- refused/blocked: disallowed by a provenance or safety guard + (:class:`ExternalDeleteBlocked`, :class:`ReadOnly`). +* ``5`` -- not configured: required configuration is missing + (:class:`NotConfigured`). +""" + +from __future__ import annotations + +from typing import Optional + + +class OpError(Exception): + """Base for operation failures with a stable wire shape and a CLI exit code.""" + + type: str = "Error" + exit_code: int = 1 + + def __init__(self, message: str, *, extra: Optional[dict] = None) -> None: + super().__init__(message) + self.extra: dict = dict(extra) if extra else {} + + def payload(self) -> dict: + """The dict an MCP client receives. Must match today's shape exactly.""" + return {**self.extra, "error": str(self), "type": self.type} + + +class NotFound(OpError): + """A page or entity that was expected to exist does not.""" + + type = "NotFound" + exit_code = 2 + + +class SchemaError(OpError): + """A category's schema could not be fetched.""" + + type = "SchemaError" + exit_code = 3 + + +class ClassNotFound(OpError): + """No generated model class could be resolved for a category.""" + + type = "ClassNotFound" + exit_code = 3 + + +class ValidationError(OpError): + """A ``jsondata`` payload does not validate against its category.""" + + type = "ValidationError" + exit_code = 3 + + +class ExternalDeleteBlocked(OpError): + """A delete was refused because the page was not created by this server.""" + + type = "ExternalDeleteBlocked" + exit_code = 4 + + +class ReadOnly(OpError): + """A write was refused because writes are disabled for this context.""" + + type = "ReadOnly" + exit_code = 4 + + +class UnknownInstance(OpError): + """A requested instance iri is not among the configured/available ones.""" + + type = "UnknownInstance" + exit_code = 3 + + +class NotConfigured(OpError): + """Required configuration is missing (e.g. an active instance, a SPARQL + endpoint).""" + + type = "NotConfigured" + exit_code = 5 + + +class InvalidSlot(OpError): + """A slot key is not one of the valid ``osw.wtsite.SLOTS`` keys.""" + + type = "InvalidSlot" + exit_code = 3 + + +class InvalidContent(OpError): + """A slot's content does not match its content model (json/wikitext).""" + + type = "InvalidContent" + exit_code = 3 + + +class SlotMissing(OpError): + """A slot does not exist on a page and ``create_if_missing`` is false.""" + + type = "SlotMissing" + exit_code = 3 diff --git a/src/osw/mcp/ledger.py b/src/osw/service/ledger.py similarity index 91% rename from src/osw/mcp/ledger.py rename to src/osw/service/ledger.py index 86f2795..94ec49b 100644 --- a/src/osw/mcp/ledger.py +++ b/src/osw/service/ledger.py @@ -17,6 +17,8 @@ from pathlib import Path from typing import List, Optional +from pydantic import BaseModel + LEDGER_VERSION = 1 @@ -40,6 +42,21 @@ def _safe_domain(domain: str) -> str: return "".join(c if c.isalnum() or c in "-._" else "_" for c in domain) +class LedgerRecord(BaseModel): + """One ledger entry an operation wants written after a successful write. + + Mirrors the keyword arguments of :meth:`Ledger.record`, minus ``tool``, + which ``bind()`` fills in from the operation name. + """ + + title: str + op: str # the verb: "create", "update", "create_or_update" + change_id: Optional[str] = None + slots: Optional[List[str]] = None + uuid: Optional[str] = None + namespace: Optional[str] = None + + class Ledger: """A JSON-backed record of pages created/modified by this server.""" diff --git a/src/osw/service/registry.py b/src/osw/service/registry.py new file mode 100644 index 0000000..4bcc40e --- /dev/null +++ b/src/osw/service/registry.py @@ -0,0 +1,181 @@ +"""Operation registry: one decorated function exposed identically by every +osw.service adapter (MCP, CLI, ...). + +An :class:`Operation` pairs a plain function -- whose first parameter is a +:class:`~osw.service.context.Context` and whose remaining parameters are its +public parameter surface -- with the metadata each adapter needs (MCP tool +annotations, CLI grouping, ledger recording). Adding an operation means +writing one decorated function; no adapter needs editing. + +This module imports nothing from the ``mcp`` SDK, ``typer``, or ``osw.cli``. +""" + +from __future__ import annotations + +import inspect +import sys +from typing import Any, Callable, Iterator, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from osw.service.context import Context +from osw.service.errors import OpError +from osw.service.ledger import LedgerRecord + +PATH_LIKE_NAMES = frozenset({ + "path", + "paths", + "filepath", + "file_path", + "dir", + "directory", + "target_dir", + "target_path", + "source_path", + "dest", + "destination", + "output_path", + "outfile", + "local_path", +}) + + +class Operation(BaseModel): + """One osw operation, exposed identically by every adapter. + + ``fn``'s first parameter is a Context; its remaining parameters *are* the + public parameter surface. The MCP SDK derives its JSON schema from them and + typer derives its CLI options from them, so adding an operation means + writing one decorated function and editing no adapter. + """ + + model_config = ConfigDict(frozen=True, extra="forbid", arbitrary_types_allowed=True) + + name: str + fn: Callable[..., dict] + group: Optional[str] = None # CLI first level, e.g. "entity" + cli_name: Optional[str] = None # CLI second level; defaults to name + summary: str = "" + writes: bool = False + surfaces: frozenset[Literal["mcp", "cli"]] = frozenset({"mcp", "cli"}) + # ledger hook: given the fn's result, returns the entries to record after + # a successful write. ``tool`` is not part of ``LedgerRecord``; ``bind()`` + # fills it in from the operation name. + records: Optional[Callable[[dict], list[LedgerRecord]]] = None + + # MCP tool annotations: the spec's four hints, explicit and typed rather + # than a dict. The adapter maps these onto ToolAnnotations, so this + # module still imports nothing from the mcp SDK. + read_only_hint: Optional[bool] = None + destructive_hint: Optional[bool] = None + idempotent_hint: Optional[bool] = None + open_world_hint: Optional[bool] = None + + # MCP _meta: open-ended by spec, so the two keys we use are typed and + # anything else goes through the escape hatch. + requires_user_interaction: bool = False + max_result_size_chars: Optional[int] = None + extra_meta: dict[str, Any] = Field(default_factory=dict) + + @property + def command(self) -> str: + """The CLI second-level command name.""" + return self.cli_name or self.name + + @model_validator(mode="after") + def _validate(self) -> Operation: + params = list(inspect.signature(self.fn).parameters.values()) + if not params: + raise ValueError(f"{self.name}: fn must take at least one parameter (ctx).") + if params[0].name != "ctx": + raise ValueError( + f"{self.name}: fn's first parameter must be named 'ctx', got " + f"{params[0].name!r}." + ) + if self.records is not None and not self.writes: + raise ValueError( + f"{self.name}: records is set but writes is False; it would never fire." + ) + if not (self.fn.__doc__ and self.fn.__doc__.strip()): + raise ValueError( + f"{self.name}: fn must have a non-empty docstring; it becomes " + "the MCP tool description and the CLI help." + ) + if "mcp" in self.surfaces: + offending = [p.name for p in params[1:] if p.name in PATH_LIKE_NAMES] + if offending: + raise ValueError( + f"{self.name}: parameter(s) {', '.join(offending)} look " + "like filesystem paths and may not be exposed on the mcp " + "surface; no path may reach an MCP client." + ) + return self + + +REGISTRY: dict[str, Operation] = {} + + +def operation(**kwargs: Any) -> Callable[[Callable[..., dict]], Callable[..., dict]]: + """Decorate ``fn`` as an :class:`Operation`, registering it in :data:`REGISTRY`. + + Returns ``fn`` unchanged so it stays directly callable and unit-testable. + """ + + def deco(fn: Callable[..., dict]) -> Callable[..., dict]: + name = kwargs.get("name") or fn.__name__ + if name in REGISTRY: + raise ValueError( + f"{name}: an operation with this name is already registered." + ) + fields = {**kwargs, "name": name, "fn": fn} + REGISTRY[name] = Operation(**fields) + return fn + + return deco + + +def iter_operations( + *, surface: str, include_writes: bool = True +) -> Iterator[Operation]: + """Yield registered operations available on ``surface``, in registration order.""" + for op in REGISTRY.values(): + if surface not in op.surfaces: + continue + if op.writes and not include_writes: + continue + yield op + + +def bind(op: Operation, ctx: Context) -> Callable[..., dict]: + """Apply ``ctx`` to ``op.fn`` and hide it from the resulting signature.""" + + def bound(*args: Any, **kwargs: Any) -> dict: + try: + if op.writes: + ctx.require_write(op.name) + with ctx.guard(): + result = op.fn(ctx, *args, **kwargs) + if op.writes and op.records is not None: + for rec in op.records(result): + ctx.ledger.record( + rec.title, tool=op.name, **rec.model_dump(exclude={"title"}) + ) + return result + except Exception as exc: + if not ctx.policy.errors_as_dicts: + raise + print(f"[osw] {op.name} failed: {exc!r}", file=sys.stderr) + if isinstance(exc, OpError): + return exc.payload() + return {"error": str(exc), "type": type(exc).__name__} + + sig = inspect.signature(op.fn) + params = list(sig.parameters.values())[1:] # drop ctx + bound.__name__ = op.fn.__name__ + bound.__qualname__ = op.fn.__qualname__ + bound.__doc__ = op.fn.__doc__ + bound.__signature__ = sig.replace(parameters=params) + annotations = dict(getattr(op.fn, "__annotations__", {})) + annotations.pop("ctx", None) + bound.__annotations__ = annotations + return bound diff --git a/src/osw/mcp/serialization.py b/src/osw/service/serialization.py similarity index 100% rename from src/osw/mcp/serialization.py rename to src/osw/service/serialization.py diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py index f9b8a6b..b74ecf1 100644 --- a/tests/integration/test_mcp_server.py +++ b/tests/integration/test_mcp_server.py @@ -14,8 +14,9 @@ pytest.importorskip("mcp", reason="requires the osw[mcp] extra") pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") -from osw.mcp import config, connection +from osw.mcp import connection from osw.mcp.tools import entities, schema, search, slots, status +from osw.service import config class _Collector: diff --git a/tests/test_mcp_instances.py b/tests/test_mcp_instances.py index 3846f10..1f8c05b 100644 --- a/tests/test_mcp_instances.py +++ b/tests/test_mcp_instances.py @@ -9,8 +9,9 @@ pytest.importorskip("mcp", reason="requires the osw[mcp] extra") pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") -from osw.mcp import config, connection +from osw.mcp import connection from osw.mcp.tools import instances +from osw.service import config _ALL_VARS = [ "OSW_DOMAIN", diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index cfa65de..1ba0fb8 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -11,8 +11,9 @@ pytest.importorskip("mcp", reason="requires the osw[mcp] extra") pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") -from osw.mcp import config, connection +from osw.mcp import connection from osw.mcp.tools import entities, search, slots +from osw.service import config class FakeMCP: diff --git a/tests/test_mcp_config.py b/tests/test_service_config.py similarity index 50% rename from tests/test_mcp_config.py rename to tests/test_service_config.py index 5c8ba8a..4df8a16 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_service_config.py @@ -1,12 +1,11 @@ -"""Unit tests for osw.mcp.config (fail-fast credential validation).""" +"""Unit tests for osw.service.config (fail-fast credential validation).""" + +import sys import pytest import yaml -pytest.importorskip("mcp", reason="requires the osw[mcp] extra") -pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") - -from osw.mcp import config +from osw.service import config _ALL_VARS = [ "OSW_DOMAIN", @@ -15,13 +14,19 @@ "OSL_USERNAME", "OSW_PASSWORD", "OSL_PASSWORD", + "OSW_CRED_FILEPATH", "OSW_MCP_CRED_FILEPATH", "OSL_CRED_FILEPATH", "OSW_SPARQL_ENDPOINT", + "OSW_READ_ONLY", "OSW_MCP_READ_ONLY", + "OSW_STATE_DIR", "OSW_MCP_STATE_DIR", + "OSW_MAX_RESULTS", "OSW_MCP_MAX_RESULTS", + "OSW_MAX_CHARS", "OSW_MCP_MAX_CHARS", + "OSW_ENV_FILE", "OSW_MCP_ENV_FILE", ] @@ -207,3 +212,189 @@ def test_cred_file_without_domain_skips_domain_verification(monkeypatch, tmp_pat monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) settings = config.load() assert settings.domain is None + + +# -- canonical OSW_* names -------------------------------------------------- + + +def test_canonical_cred_filepath(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + settings = config.load() + assert settings.cred_filepath == str(cred_file) + + +def test_canonical_read_only(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_READ_ONLY", "true") + settings = config.load() + assert settings.read_only is True + + +def test_canonical_state_dir(monkeypatch, tmp_path): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + state_dir = str(tmp_path / "state") + monkeypatch.setenv("OSW_STATE_DIR", state_dir) + settings = config.load() + assert settings.state_dir == state_dir + + +def test_canonical_max_results(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MAX_RESULTS", "7") + settings = config.load() + assert settings.max_results == 7 + + +def test_canonical_max_chars(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MAX_CHARS", "12345") + settings = config.load() + assert settings.max_chars == 12345 + + +def test_canonical_env_file(monkeypatch, tmp_path): + env = tmp_path / "creds.env" + env.write_text( + "OSW_DOMAIN=fromfile.example.org\nOSW_USERNAME=fileuser\nOSW_PASSWORD=filepw\n", + encoding="utf-8", + ) + monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False) + monkeypatch.setenv("OSW_ENV_FILE", str(env)) + settings = config.load() + assert settings.domain == "fromfile.example.org" + assert settings.username == "fileuser" + + +# -- OSW_MCP_* aliases not already covered above ---------------------------- + + +def test_alias_state_dir(monkeypatch, tmp_path): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + state_dir = str(tmp_path / "state") + monkeypatch.setenv("OSW_MCP_STATE_DIR", state_dir) + settings = config.load() + assert settings.state_dir == state_dir + + +def test_alias_max_chars(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MCP_MAX_CHARS", "54321") + settings = config.load() + assert settings.max_chars == 54321 + + +# -- canonical wins when both canonical and alias are set -------------------- + + +def test_canonical_wins_over_alias(monkeypatch, tmp_path): + cred_file = _write_cred_file( + tmp_path / "accounts.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + other_cred_file = _write_cred_file( + tmp_path / "other.yaml", + {"wiki.example.org": {"username": "alice", "password": "secret"}}, + ) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(other_cred_file)) + monkeypatch.setenv("OSW_READ_ONLY", "true") + monkeypatch.setenv("OSW_MCP_READ_ONLY", "false") + monkeypatch.setenv("OSW_MAX_RESULTS", "1") + monkeypatch.setenv("OSW_MCP_MAX_RESULTS", "2") + monkeypatch.setenv("OSW_MAX_CHARS", "10") + monkeypatch.setenv("OSW_MCP_MAX_CHARS", "20") + state_dir = str(tmp_path / "state") + other_state_dir = str(tmp_path / "other-state") + monkeypatch.setenv("OSW_STATE_DIR", state_dir) + monkeypatch.setenv("OSW_MCP_STATE_DIR", other_state_dir) + + settings = config.load() + + assert settings.cred_filepath == str(cred_file) + assert settings.read_only is True + assert settings.max_results == 1 + assert settings.max_chars == 10 + assert settings.state_dir == state_dir + + +def test_canonical_env_file_wins_over_alias(monkeypatch, tmp_path): + canonical_env = tmp_path / "canonical.env" + canonical_env.write_text("OSW_DOMAIN=canonical.example.org\n", encoding="utf-8") + alias_env = tmp_path / "alias.env" + alias_env.write_text("OSW_DOMAIN=alias.example.org\n", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(canonical_env)) + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(alias_env)) + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + + settings = config.load() + + assert settings.domain == "canonical.example.org" + + +# -- strict=False ------------------------------------------------------------ + + +def test_load_not_strict_returns_settings_without_raising(monkeypatch): + settings = config.load(strict=False) + assert settings.domain is None + assert settings.username is None + assert settings.password is None + + +def test_load_not_strict_still_raises_on_invalid_int(monkeypatch): + monkeypatch.setenv("OSW_MAX_RESULTS", "notanumber") + with pytest.raises(RuntimeError): + config.load(strict=False) + + +def test_load_not_strict_still_raises_on_missing_cred_file(monkeypatch, tmp_path): + missing = tmp_path / "does-not-exist.yaml" + monkeypatch.setenv("OSW_CRED_FILEPATH", str(missing)) + with pytest.raises(RuntimeError) as exc: + config.load(strict=False) + assert str(missing) in str(exc.value) + + +# -- _load_env_file / optional dotenv ---------------------------------------- + + +def test_load_env_file_raises_when_configured_and_dotenv_missing(monkeypatch, tmp_path): + env = tmp_path / "creds.env" + env.write_text("OSW_DOMAIN=wiki.example.org\n", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(env)) + monkeypatch.setitem(sys.modules, "dotenv", None) + with pytest.raises(RuntimeError) as exc: + config._load_env_file() + assert "OSW_ENV_FILE" in str(exc.value) + assert "python-dotenv" in str(exc.value) + + +def test_load_env_file_silent_when_not_configured_and_dotenv_missing( + monkeypatch, +): + monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False) + monkeypatch.delenv("OSW_ENV_FILE", raising=False) + monkeypatch.setitem(sys.modules, "dotenv", None) + # must not raise + config._load_env_file() diff --git a/tests/test_service_context.py b/tests/test_service_context.py new file mode 100644 index 0000000..c089b56 --- /dev/null +++ b/tests/test_service_context.py @@ -0,0 +1,189 @@ +"""Unit tests for osw.service.context (Policy defaults and Context helpers). + +A fake ``osw`` object is injected directly into ``Context`` so these tests +never touch the network. +""" + +import sys +from unittest.mock import MagicMock + +import pytest +import yaml + +from osw.service import config, errors +from osw.service.config import Settings +from osw.service.context import Context, Policy + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_CRED_FILEPATH", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", +] + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch, tmp_path): + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) + config.reset() + yield + config.reset() + + +def _settings(**overrides) -> Settings: + defaults = dict(domain="wiki.example.org", username="u", password="p") + defaults.update(overrides) + return Settings(**defaults) + + +def _osw_with_page(exists: bool): + page = MagicMock() + page.exists = exists + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + return osw, page + + +# -- Policy ------------------------------------------------------------- +def test_policy_defaults(): + policy = Policy() + assert policy.capture_stdout is False + assert policy.errors_as_dicts is False + assert policy.allow_writes is True + assert policy.allow_interactive is False + + +# -- osw / ledger injection ---------------------------------------------- +def test_osw_can_be_preset_via_constructor(): + fake = object() + ctx = Context(_settings(), osw=fake) + assert ctx.osw is fake + + +def test_osw_can_be_preset_via_attribute(): + ctx = Context(_settings()) + fake = object() + ctx.osw = fake + assert ctx.osw is fake + + +def test_ledger_can_be_preset_via_constructor(): + fake = object() + ctx = Context(_settings(), ledger=fake) + assert ctx.ledger is fake + + +def test_ledger_can_be_preset_via_attribute(): + ctx = Context(_settings()) + fake = object() + ctx.ledger = fake + assert ctx.ledger is fake + + +def test_osw_property_raises_not_configured_when_no_active_domain( + monkeypatch, tmp_path +): + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({ + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + config.reset() # two iris in the file: no auto-selection is possible + + ctx = Context(_settings(domain=None, username=None, password=None)) + + with pytest.raises(errors.NotConfigured) as exc_info: + _ = ctx.osw + assert "select_instance" in str(exc_info.value) + + +# -- limit ---------------------------------------------------------------- +def test_limit_falls_back_to_settings_max_results(): + ctx = Context(_settings(), osw=object()) + assert ctx.limit(None) == ctx.settings.max_results + assert ctx.limit(5) == 5 + + +# -- page ------------------------------------------------------------------- +def test_page_returns_existing_page(): + osw, page = _osw_with_page(True) + ctx = Context(_settings(), osw=osw) + assert ctx.page("Item:OSW1") is page + + +def test_page_raises_not_found_for_missing_page(): + osw, _page = _osw_with_page(False) + ctx = Context(_settings(), osw=osw) + with pytest.raises(errors.NotFound): + ctx.page("Item:OSW1") + + +# -- require_write ------------------------------------------------------ +def test_require_write_raises_when_writes_disallowed(): + ctx = Context(_settings(), Policy(allow_writes=False), osw=object()) + with pytest.raises(errors.ReadOnly) as exc_info: + ctx.require_write("create_or_update_entity") + assert "create_or_update_entity" in str(exc_info.value) + assert "OSW_READ_ONLY" in str(exc_info.value) + assert exc_info.value.type == "ReadOnly" + assert exc_info.value.exit_code == 4 + + +def test_require_write_allows_when_writes_allowed(): + ctx = Context(_settings(), Policy(allow_writes=True), osw=object()) + ctx.require_write("create_or_update_entity") # must not raise + + +# -- guard ------------------------------------------------------------------ +def test_guard_redirects_stdout_when_capture_stdout_true(): + ctx = Context(_settings(), Policy(capture_stdout=True), osw=object()) + original_stdout = sys.stdout + with ctx.guard(): + assert sys.stdout is sys.stderr + assert sys.stdout is not original_stdout + assert sys.stdout is original_stdout + + +def test_guard_leaves_stdout_alone_when_capture_stdout_false(): + ctx = Context(_settings(), Policy(capture_stdout=False), osw=object()) + original_stdout = sys.stdout + with ctx.guard(): + assert sys.stdout is original_stdout + + +# -- reset / close -------------------------------------------------------- +def test_reset_closes_connection_and_drops_osw_and_ledger(): + fake_osw = MagicMock() + ctx = Context(_settings(), osw=fake_osw, ledger=MagicMock()) + ctx.reset() + fake_osw.close_connection.assert_called_once() + assert ctx._osw is None + assert ctx._ledger is None + + +def test_reset_survives_close_connection_error(): + fake_osw = MagicMock() + fake_osw.close_connection.side_effect = RuntimeError("boom") + ctx = Context(_settings(), osw=fake_osw) + ctx.reset() # must not raise + assert ctx._osw is None + + +def test_close_calls_reset(): + fake_osw = MagicMock() + ctx = Context(_settings(), osw=fake_osw) + ctx.close() + fake_osw.close_connection.assert_called_once() diff --git a/tests/test_service_errors.py b/tests/test_service_errors.py new file mode 100644 index 0000000..94ca07a --- /dev/null +++ b/tests/test_service_errors.py @@ -0,0 +1,128 @@ +"""Unit tests for osw.service.errors. + +Each ``OpError`` subclass must reproduce, key-for-key and value-for-value, the +dict a tool body in ``osw.mcp.tools`` returns today. +""" + +from osw.service import errors + + +def test_not_found_matches_export_entity_jsonld_shape(): + title = "Item:OSW1" + exc = errors.NotFound(f"Entity '{title}' not found.") + assert exc.payload() == { + "error": f"Entity '{title}' not found.", + "type": "NotFound", + } + assert exc.exit_code == 2 + + +def test_not_found_matches_delete_entity_hybrid_shape(): + title = "Item:OSW1" + exc = errors.NotFound( + f"Page '{title}' does not exist.", + extra={"title": title, "deleted": False}, + ) + assert exc.payload() == { + "title": title, + "deleted": False, + "error": f"Page '{title}' does not exist.", + "type": "NotFound", + } + + +def test_external_delete_blocked_matches_delete_entity_shape(): + title = "Item:OSWx" + message = ( + f"Refusing to delete '{title}': it was not created by this " + "MCP server. Re-run with confirm_external_delete=true to override." + ) + exc = errors.ExternalDeleteBlocked(message, extra={"title": title}) + assert exc.payload() == { + "title": title, + "error": message, + "type": "ExternalDeleteBlocked", + } + assert exc.exit_code == 4 + + +def test_schema_error_matches_create_or_update_entity_shape(): + exc = errors.SchemaError("boom1; boom2") + assert exc.payload() == {"error": "boom1; boom2", "type": "SchemaError"} + assert exc.exit_code == 3 + + +def test_class_not_found_matches_create_or_update_entity_shape(): + category = "Category:Item" + message = ( + f"Could not resolve a model class for '{category}' after " + "fetching its schema. Check the category page name." + ) + exc = errors.ClassNotFound(message) + assert exc.payload() == {"error": message, "type": "ClassNotFound"} + assert exc.exit_code == 3 + + +def test_validation_error_matches_create_or_update_entity_shape(): + category = "Category:Item" + message = f"jsondata does not validate against {category}: bad field" + exc = errors.ValidationError(message) + assert exc.payload() == {"error": message, "type": "ValidationError"} + assert exc.exit_code == 3 + + +def test_unknown_instance_matches_select_instance_shape(): + message = "Unknown instance 'bogus'. Available: wiki.example.org" + exc = errors.UnknownInstance(message) + assert exc.payload() == {"error": message, "type": "UnknownInstance"} + assert exc.exit_code == 3 + + +def test_not_configured_matches_sparql_query_shape(): + message = ( + "SPARQL endpoint not configured. Set OSW_SPARQL_ENDPOINT " + "or pass the 'endpoint' argument." + ) + exc = errors.NotConfigured(message) + assert exc.payload() == {"error": message, "type": "NotConfigured"} + assert exc.exit_code == 5 + + +def test_invalid_slot_matches_slots_shape(): + valid = ["main", "jsondata"] + message = f"Unknown slot 'bogus'. Valid slots: {valid}" + exc = errors.InvalidSlot(message) + assert exc.payload() == {"error": message, "type": "InvalidSlot"} + assert exc.exit_code == 3 + + +def test_invalid_content_matches_set_slot_shape(): + message = "Slot 'jsondata' is JSON; content must be an object or array." + exc = errors.InvalidContent(message) + assert exc.payload() == {"error": message, "type": "InvalidContent"} + assert exc.exit_code == 3 + + +def test_slot_missing_matches_set_slot_shape(): + message = ( + "Slot 'header' does not exist on 'Item:OSW1' and create_if_missing is false." + ) + exc = errors.SlotMissing(message) + assert exc.payload() == {"error": message, "type": "SlotMissing"} + assert exc.exit_code == 3 + + +def test_read_only_matches_require_write_shape(): + message = ( + "Operation 'create_or_update_entity' is not permitted: writes are " + "disabled (set OSW_READ_ONLY=false to allow)." + ) + exc = errors.ReadOnly(message) + assert exc.payload() == {"error": message, "type": "ReadOnly"} + assert exc.exit_code == 4 + + +def test_base_op_error_defaults(): + exc = errors.OpError("generic failure") + assert exc.payload() == {"error": "generic failure", "type": "Error"} + assert exc.exit_code == 1 diff --git a/tests/test_mcp_ledger.py b/tests/test_service_ledger.py similarity index 96% rename from tests/test_mcp_ledger.py rename to tests/test_service_ledger.py index 2e4db82..d284bc7 100644 --- a/tests/test_mcp_ledger.py +++ b/tests/test_service_ledger.py @@ -1,6 +1,6 @@ -"""Unit tests for the osw.mcp provenance ledger.""" +"""Unit tests for the osw.service provenance ledger.""" -from osw.mcp.ledger import Ledger +from osw.service.ledger import Ledger def _ledger(tmp_path): diff --git a/tests/test_service_registry.py b/tests/test_service_registry.py new file mode 100644 index 0000000..23feb4d --- /dev/null +++ b/tests/test_service_registry.py @@ -0,0 +1,334 @@ +"""Unit tests for osw.service.registry (Operation validation, bind()). + +Registers test operations against a snapshot/restore of the global +``REGISTRY`` so this file cannot pollute other test modules. A fake +``osw``/``ledger`` is injected into ``Context`` so nothing here touches the +network. +""" + +import inspect +from unittest.mock import MagicMock + +import pytest + +from osw.service import errors, registry +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ledger import LedgerRecord + + +@pytest.fixture(autouse=True) +def _clean_registry(): + original = dict(registry.REGISTRY) + registry.REGISTRY.clear() + yield + registry.REGISTRY.clear() + registry.REGISTRY.update(original) + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def _underlying_message(exc_info) -> str: + """Pydantic wraps our ``raise ValueError`` in its own message; unwrap it.""" + return str(exc_info.value.errors()[0]["ctx"]["error"]) + + +def _valid_fn(ctx, title: str) -> dict: + """Do a thing.""" + return {"title": title} + + +# -- Operation.command ------------------------------------------------------ +def test_command_defaults_to_name(): + op = registry.Operation(name="foo", fn=_valid_fn) + assert op.command == "foo" + + +def test_command_uses_cli_name_override(): + op = registry.Operation(name="foo", fn=_valid_fn, cli_name="bar") + assert op.command == "bar" + + +# -- validator ---------------------------------------------------------- +def test_validator_rejects_missing_ctx_param(): + def fn(): + """Doc.""" + return {} + + with pytest.raises(ValueError) as exc_info: + registry.Operation(name="no_params", fn=fn) + assert _underlying_message(exc_info).startswith("no_params:") + + +def test_validator_rejects_first_param_not_named_ctx(): + def fn(x): + """Doc.""" + return {} + + with pytest.raises(ValueError) as exc_info: + registry.Operation(name="bad_ctx", fn=fn) + assert _underlying_message(exc_info).startswith("bad_ctx:") + + +def test_validator_rejects_records_without_writes(): + with pytest.raises(ValueError) as exc_info: + registry.Operation(name="bad_records", fn=_valid_fn, records=lambda r: []) + assert _underlying_message(exc_info).startswith("bad_records:") + + +def test_validator_requires_docstring(): + def fn(ctx, title: str) -> dict: + return {} + + with pytest.raises(ValueError) as exc_info: + registry.Operation(name="no_doc", fn=fn) + assert _underlying_message(exc_info).startswith("no_doc:") + + +def test_validator_rejects_path_like_param_on_mcp_surface(): + def fn(ctx, source_path: str) -> dict: + """Doc.""" + return {} + + with pytest.raises(ValueError) as exc_info: + registry.Operation(name="bad_path", fn=fn) + msg = _underlying_message(exc_info) + assert msg.startswith("bad_path:") + assert "source_path" in msg + + +def test_validator_allows_path_like_param_on_cli_only_surface(): + def fn(ctx, source_path: str) -> dict: + """Doc.""" + return {} + + op = registry.Operation(name="cli_only", fn=fn, surfaces=frozenset({"cli"})) + assert "source_path" in inspect.signature(op.fn).parameters + + +def test_extra_forbid_rejects_misspelled_kwarg(): + with pytest.raises(ValueError): + registry.Operation(name="typo", fn=_valid_fn, sumary="oops") + + +# -- operation() decorator / REGISTRY ---------------------------------------- +def test_operation_decorator_registers_and_returns_fn_unchanged(): + @registry.operation() + def my_op(ctx, title: str) -> dict: + """Do a thing.""" + return {"title": title} + + assert "my_op" in registry.REGISTRY + assert registry.REGISTRY["my_op"].fn is my_op + assert my_op(None, title="x") == {"title": "x"} + + +def test_operation_decorator_name_override(): + @registry.operation(name="custom_name") + def my_op(ctx, title: str) -> dict: + """Do a thing.""" + return {} + + assert "custom_name" in registry.REGISTRY + assert "my_op" not in registry.REGISTRY + + +def test_operation_decorator_rejects_duplicate_name(): + @registry.operation() + def dup(ctx, title: str) -> dict: + """Do a thing.""" + return {} + + with pytest.raises(ValueError): + + @registry.operation(name="dup") + def other(ctx, title: str) -> dict: + """Do a thing.""" + return {} + + +# -- iter_operations ---------------------------------------------------- +def _register(name, **kwargs): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + return {"title": title} + + kwargs.setdefault("surfaces", frozenset({"mcp", "cli"})) + registry.REGISTRY[name] = registry.Operation(name=name, fn=fn, **kwargs) + + +def test_iter_operations_filters_by_surface(): + _register("mcp_only", surfaces=frozenset({"mcp"})) + _register("cli_only", surfaces=frozenset({"cli"})) + names_mcp = {op.name for op in registry.iter_operations(surface="mcp")} + names_cli = {op.name for op in registry.iter_operations(surface="cli")} + assert "mcp_only" in names_mcp and "mcp_only" not in names_cli + assert "cli_only" in names_cli and "cli_only" not in names_mcp + + +def test_iter_operations_filters_writes(): + _register("reader", writes=False) + _register("writer", writes=True) + with_writes = {op.name for op in registry.iter_operations(surface="mcp")} + without_writes = { + op.name for op in registry.iter_operations(surface="mcp", include_writes=False) + } + assert "writer" in with_writes + assert "writer" not in without_writes + assert "reader" in without_writes + + +def test_iter_operations_preserves_registration_order(): + _register("first") + _register("second") + _register("third") + names = [op.name for op in registry.iter_operations(surface="mcp")] + assert names.index("first") < names.index("second") < names.index("third") + + +# -- bind(): signature / annotations / doc preservation ---------------------- +def test_bind_signature_excludes_ctx(): + def fn(ctx, title: str, limit: int = 5) -> dict: + """Do a thing.""" + return {} + + op = registry.Operation(name="op1", fn=fn) + ctx = Context(_settings(), osw=object()) + bound = registry.bind(op, ctx) + + sig = inspect.signature(bound) + assert list(sig.parameters) == ["title", "limit"] + assert "ctx" not in bound.__annotations__ + assert bound.__doc__ == fn.__doc__ + assert bound.__name__ == fn.__name__ + + +# -- bind(): error handling -------------------------------------------------- +def test_bind_errors_as_dicts_true_returns_payload(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + raise errors.NotFound(f"Page '{title}' does not exist.") + + op = registry.Operation(name="op_err", fn=fn) + ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=object()) + bound = registry.bind(op, ctx) + + result = bound(title="Item:X") + + assert result == {"error": "Page 'Item:X' does not exist.", "type": "NotFound"} + + +def test_bind_errors_as_dicts_false_reraises(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + raise errors.NotFound(f"Page '{title}' does not exist.") + + op = registry.Operation(name="op_err2", fn=fn) + ctx = Context(_settings(), Policy(errors_as_dicts=False), osw=object()) + bound = registry.bind(op, ctx) + + with pytest.raises(errors.NotFound): + bound(title="Item:X") + + +def test_bind_non_operror_exception_becomes_generic_dict(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + raise RuntimeError("boom") + + op = registry.Operation(name="op_err3", fn=fn) + ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=object()) + bound = registry.bind(op, ctx) + + result = bound(title="x") + + assert result == {"error": "boom", "type": "RuntimeError"} + + +def test_bind_calls_require_write_for_writing_ops(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + return {"title": title} + + op = registry.Operation(name="writer_op", fn=fn, writes=True) + ctx = Context( + _settings(), + Policy(allow_writes=False, errors_as_dicts=True), + osw=object(), + ) + bound = registry.bind(op, ctx) + + result = bound(title="x") + + assert result["type"] == "ReadOnly" # the ReadOnly OpError require_write raises + + +# -- bind(): ledger recording ------------------------------------------- +def test_bind_invokes_ledger_once_per_returned_record_with_full_arguments(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + return {"titles": [title, title + "-2"]} + + def _records(result: dict) -> list: + return [ + LedgerRecord( + title=result["titles"][0], + op="create", + change_id="c1", + slots=["jsondata"], + ), + LedgerRecord(title=result["titles"][1], op="update", slots=["main"]), + ] + + op = registry.Operation( + name="writer_records", + fn=fn, + writes=True, + records=_records, + ) + fake_ledger = MagicMock() + ctx = Context(_settings(), osw=object(), ledger=fake_ledger) + bound = registry.bind(op, ctx) + + bound(title="Item:A") + + assert fake_ledger.record.call_count == 2 + + first = fake_ledger.record.call_args_list[0] + assert first.args == ("Item:A",) + assert first.kwargs == { + "tool": "writer_records", + "op": "create", + "change_id": "c1", + "slots": ["jsondata"], + "uuid": None, + "namespace": None, + } + + second = fake_ledger.record.call_args_list[1] + assert second.args == ("Item:A-2",) + assert second.kwargs == { + "tool": "writer_records", + "op": "update", + "change_id": None, + "slots": ["main"], + "uuid": None, + "namespace": None, + } + + +def test_bind_does_not_invoke_ledger_when_op_does_not_write(): + def fn(ctx, title: str) -> dict: + """Do a thing.""" + return {"titles": [title]} + + op = registry.Operation(name="reader_op", fn=fn, writes=False) + fake_ledger = MagicMock() + ctx = Context(_settings(), osw=object(), ledger=fake_ledger) + bound = registry.bind(op, ctx) + + bound(title="Item:A") + + fake_ledger.record.assert_not_called() diff --git a/tests/test_mcp_serialization.py b/tests/test_service_serialization.py similarity index 92% rename from tests/test_mcp_serialization.py rename to tests/test_service_serialization.py index 5e4e974..08e3872 100644 --- a/tests/test_mcp_serialization.py +++ b/tests/test_service_serialization.py @@ -1,8 +1,8 @@ -"""Unit tests for osw.mcp.serialization.""" +"""Unit tests for osw.service.serialization.""" from pathlib import Path -from osw.mcp.serialization import cap_list, maybe_truncate, to_jsonable +from osw.service.serialization import cap_list, maybe_truncate, to_jsonable def test_cap_list_under_limit(): diff --git a/uv.lock b/uv.lock index c9e8356..3c52eb9 100644 --- a/uv.lock +++ b/uv.lock @@ -2182,6 +2182,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "python-dotenv" }, { name = "python-semantic-release" }, { name = "ruff" }, { name = "sqlalchemy" }, @@ -2193,6 +2194,7 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "python-dotenv" }, ] [package.metadata] @@ -2255,6 +2257,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "python-dotenv", specifier = ">=1.0" }, { name = "python-semantic-release", specifier = ">=10.0.0" }, { name = "ruff", specifier = ">=0.15.7" }, { name = "sqlalchemy" }, @@ -2266,6 +2269,7 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "python-dotenv", specifier = ">=1.0" }, ] [[package]] From 431f33f1cccf3c734ab63109a1144bf5c7f3b98f Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 25 Aug 2026 15:48:39 +0200 Subject: [PATCH 06/28] refactor(service): lift search tools into osw.service.ops - add osw/service/ops/ with the search group as @operation functions - mcp/tools/search.py becomes a registry loop over bind() - add transitional legacy_context() so existing tests keep passing - bind() resolves annotations against the op module, not registry.py --- src/osw/mcp/connection.py | 40 ++++++++++++ src/osw/mcp/tools/search.py | 106 +++--------------------------- src/osw/service/ops/__init__.py | 10 +++ src/osw/service/ops/search.py | 107 +++++++++++++++++++++++++++++++ src/osw/service/registry.py | 27 ++++++-- tests/test_service_ops_search.py | 63 ++++++++++++++++++ tests/test_service_registry.py | 29 +++++++++ 7 files changed, 280 insertions(+), 102 deletions(-) create mode 100644 src/osw/service/ops/__init__.py create mode 100644 src/osw/service/ops/search.py create mode 100644 tests/test_service_ops_search.py diff --git a/src/osw/mcp/connection.py b/src/osw/mcp/connection.py index 3005696..7b80e8f 100644 --- a/src/osw/mcp/connection.py +++ b/src/osw/mcp/connection.py @@ -21,6 +21,7 @@ from osw.auth import CredentialManager from osw.express import OswExpress from osw.service import config +from osw.service.context import Context, Policy from osw.service.ledger import Ledger _LOCK = threading.RLock() @@ -99,6 +100,45 @@ def run_guarded(fn: Callable[[OswExpress], dict]) -> dict: return {"error": str(exc), "type": type(exc).__name__} +class _LegacyContext(Context): + """Transitional :class:`Context` backed by this module's process globals. + + Operations have moved to :mod:`osw.service.ops`, but ``register()`` still + runs against the globals above and the existing tests monkeypatch + ``connection.get_osw`` / ``connection.get_ledger``. Resolving both through + the module functions on every access keeps that working. Deleted together + with the rest of this module once ``server.py`` builds a real Context. + """ + + def __init__(self, settings, policy=None) -> None: + super().__init__(settings, policy) + self._lock = _LOCK # share the lock with any remaining run_guarded call + + @property + def osw(self) -> OswExpress: + return get_osw() + + @property + def ledger(self) -> Ledger: + return get_ledger() + + def reset(self) -> None: + reset() + + +def legacy_context(*, include_writes: bool = True) -> _LegacyContext: + """Build the transitional context the tool groups bind their operations to.""" + return _LegacyContext( + config.get_settings(), + Policy( + capture_stdout=True, + errors_as_dicts=True, + allow_writes=include_writes, + allow_interactive=False, + ), + ) + + def reset() -> None: """Drop the shared connection and ledger so the next call rebuilds them. diff --git a/src/osw/mcp/tools/search.py b/src/osw/mcp/tools/search.py index 416e0d2..6adfd2a 100644 --- a/src/osw/mcp/tools/search.py +++ b/src/osw/mcp/tools/search.py @@ -2,104 +2,16 @@ from __future__ import annotations -from typing import Optional +from osw.service.ops import search as _ops # noqa: F401 (registers the operations) +from osw.service.registry import bind, iter_operations -from osw.core import OSW -from osw.service import config -from osw.service.serialization import cap_list, to_jsonable -from osw.sparql_client_smw import SmwSparqlClient -from osw.wtsite import WtSite - -from ..connection import run_guarded +from .. import connection def register(mcp) -> None: - """Register read-only search/query tools on ``mcp``.""" - settings = config.get_settings() - - @mcp.tool() - def search_entities(ask_query: str, limit: Optional[int] = None) -> dict: - """Run a Semantic MediaWiki 'ask' query and return matching page titles. - - The query uses SMW ask syntax, e.g. ``[[Category:Item]]`` or - ``[[Category:Item]][[Keyword::sensor]]``. Returns full page titles. - """ - lim = limit or settings.max_results - - def _run(osw): - titles = osw.site.semantic_search( - WtSite.SearchParam(query=ask_query, limit=lim) - ) - capped, total, truncated = cap_list(titles, lim) - return {"titles": capped, "count": total, "truncated": truncated} - - return run_guarded(_run) - - @mcp.tool() - def full_text_search(text: str, limit: Optional[int] = None) -> dict: - """Prefix/full-text search for pages whose title matches ``text``.""" - lim = limit or settings.max_results - - def _run(osw): - titles = osw.site.prefix_search(WtSite.SearchParam(query=text, limit=lim)) - capped, total, truncated = cap_list(titles, lim) - return {"titles": capped, "count": total, "truncated": truncated} - - return run_guarded(_run) - - @mcp.tool() - def list_instances_of_category(category: str, limit: Optional[int] = None) -> dict: - """List full page titles of all instances of a category. - - ``category`` is a full category page name, e.g. ``Category:Item``. - """ - lim = limit or settings.max_results - - def _run(osw): - titles = osw.query_instances( - OSW.QueryInstancesParam(categories=category, limit=lim) - ) - capped, total, truncated = cap_list(titles, lim) - return {"titles": capped, "count": total, "truncated": truncated} - - return run_guarded(_run) - - @mcp.tool() - def sparql_query( - query: str, endpoint: Optional[str] = None, limit: int = 500 - ) -> dict: - """Run a raw SPARQL query against the instance's SPARQL endpoint. - - The endpoint defaults to ``OSW_SPARQL_ENDPOINT``; pass ``endpoint`` to - override. Returns ``{vars, bindings, count, truncated}``. - """ - ep = endpoint or settings.sparql_endpoint - if not ep: - return { - "error": ( - "SPARQL endpoint not configured. Set OSW_SPARQL_ENDPOINT " - "or pass the 'endpoint' argument." - ), - "type": "NotConfigured", - } - - def _run(_osw): - username, password = config.get_active_credentials() - client = SmwSparqlClient( - endpoint=ep, - domain=config.get_active_domain(), - auth="basic", - user=username, - password=password, - ) - raw = client.sparqlQuery(query) - bindings = raw.get("results", {}).get("bindings", []) - capped, total, truncated = cap_list(bindings, limit) - return { - "vars": raw.get("head", {}).get("vars", []), - "bindings": to_jsonable(capped), - "count": total, - "truncated": truncated, - } - - return run_guarded(_run) + """Register the search tools on ``mcp``.""" + ctx = connection.legacy_context() + for op in iter_operations(surface="mcp"): + if op.group != "search": + continue + mcp.tool()(bind(op, ctx)) diff --git a/src/osw/service/ops/__init__.py b/src/osw/service/ops/__init__.py new file mode 100644 index 0000000..4b0e455 --- /dev/null +++ b/src/osw/service/ops/__init__.py @@ -0,0 +1,10 @@ +"""Operation implementations, one module per group. + +Importing this package registers every operation in +:data:`osw.service.registry.REGISTRY`. Imports nothing from ``osw.mcp``, +``osw.cli``, the ``mcp`` SDK or ``typer``. +""" + +from __future__ import annotations + +from . import search diff --git a/src/osw/service/ops/search.py b/src/osw/service/ops/search.py new file mode 100644 index 0000000..cc3c8cc --- /dev/null +++ b/src/osw/service/ops/search.py @@ -0,0 +1,107 @@ +"""Search and query operations: semantic (SMW ask), full-text, instances, SPARQL.""" + +from __future__ import annotations + +from typing import Optional + +from osw.core import OSW +from osw.service import config, errors +from osw.service.context import Context +from osw.service.registry import operation +from osw.service.serialization import cap_list, to_jsonable +from osw.sparql_client_smw import SmwSparqlClient +from osw.wtsite import WtSite + + +@operation( + group="search", + cli_name="ask", + read_only_hint=True, + idempotent_hint=True, +) +def search_entities(ctx: Context, ask_query: str, limit: Optional[int] = None) -> dict: + """Run a Semantic MediaWiki 'ask' query and return matching page titles. + + The query uses SMW ask syntax, e.g. ``[[Category:Item]]`` or + ``[[Category:Item]][[Keyword::sensor]]``. Returns full page titles. + """ + lim = ctx.limit(limit) + titles = ctx.osw.site.semantic_search( + WtSite.SearchParam(query=ask_query, limit=lim) + ) + capped, total, truncated = cap_list(titles, lim) + return {"titles": capped, "count": total, "truncated": truncated} + + +@operation( + group="search", + read_only_hint=True, + idempotent_hint=True, +) +def full_text_search(ctx: Context, text: str, limit: Optional[int] = None) -> dict: + """Prefix/full-text search for pages whose title matches ``text``.""" + lim = ctx.limit(limit) + titles = ctx.osw.site.prefix_search(WtSite.SearchParam(query=text, limit=lim)) + capped, total, truncated = cap_list(titles, lim) + return {"titles": capped, "count": total, "truncated": truncated} + + +@operation( + group="search", + read_only_hint=True, + idempotent_hint=True, +) +def list_instances_of_category( + ctx: Context, category: str, limit: Optional[int] = None +) -> dict: + """List full page titles of all instances of a category. + + ``category`` is a full category page name, e.g. ``Category:Item``. + """ + lim = ctx.limit(limit) + titles = ctx.osw.query_instances( + OSW.QueryInstancesParam(categories=category, limit=lim) + ) + capped, total, truncated = cap_list(titles, lim) + return {"titles": capped, "count": total, "truncated": truncated} + + +@operation( + group="search", + read_only_hint=True, + idempotent_hint=True, + open_world_hint=True, + max_result_size_chars=200_000, +) +def sparql_query( + ctx: Context, query: str, endpoint: Optional[str] = None, limit: int = 500 +) -> dict: + """Run a raw SPARQL query against the instance's SPARQL endpoint. + + The endpoint defaults to ``OSW_SPARQL_ENDPOINT``; pass ``endpoint`` to + override. Returns ``{vars, bindings, count, truncated}``. + """ + ep = endpoint or ctx.settings.sparql_endpoint + if not ep: + raise errors.NotConfigured( + "SPARQL endpoint not configured. Set OSW_SPARQL_ENDPOINT " + "or pass the 'endpoint' argument." + ) + + username, password = config.get_active_credentials() + client = SmwSparqlClient( + endpoint=ep, + domain=config.get_active_domain(), + auth="basic", + user=username, + password=password, + ) + raw = client.sparqlQuery(query) + bindings = raw.get("results", {}).get("bindings", []) + capped, total, truncated = cap_list(bindings, limit) + return { + "vars": raw.get("head", {}).get("vars", []), + "bindings": to_jsonable(capped), + "count": total, + "truncated": truncated, + } diff --git a/src/osw/service/registry.py b/src/osw/service/registry.py index 4bcc40e..81fe473 100644 --- a/src/osw/service/registry.py +++ b/src/osw/service/registry.py @@ -14,7 +14,7 @@ import inspect import sys -from typing import Any, Callable, Iterator, Literal, Optional +from typing import Any, Callable, Iterator, Literal, Optional, get_type_hints from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -169,13 +169,30 @@ def bound(*args: Any, **kwargs: Any) -> dict: return exc.payload() return {"error": str(exc), "type": type(exc).__name__} + # Resolve annotations here, against the op module's globals. `bound` lives in + # this module, so a consumer calling get_type_hints() on it would otherwise + # try to resolve `from __future__ import annotations` strings against the + # wrong namespace. include_extras keeps Annotated[...] metadata intact. + try: + hints = get_type_hints(op.fn, include_extras=True) + except Exception: # unresolvable forward ref: leave the strings in place + hints = {} + sig = inspect.signature(op.fn) - params = list(sig.parameters.values())[1:] # drop ctx + params = [ + p.replace(annotation=hints.get(p.name, p.annotation)) + for p in list(sig.parameters.values())[1:] # drop ctx + ] + annotations = dict(getattr(op.fn, "__annotations__", {})) + annotations.update(hints) + annotations.pop("ctx", None) + bound.__name__ = op.fn.__name__ bound.__qualname__ = op.fn.__qualname__ bound.__doc__ = op.fn.__doc__ - bound.__signature__ = sig.replace(parameters=params) - annotations = dict(getattr(op.fn, "__annotations__", {})) - annotations.pop("ctx", None) + bound.__signature__ = sig.replace( + parameters=params, + return_annotation=hints.get("return", sig.return_annotation), + ) bound.__annotations__ = annotations return bound diff --git a/tests/test_service_ops_search.py b/tests/test_service_ops_search.py new file mode 100644 index 0000000..e69c690 --- /dev/null +++ b/tests/test_service_ops_search.py @@ -0,0 +1,63 @@ +"""Unit tests for osw.service.ops.search (Operation.fn called directly). + +Importing ``osw.service.ops.search`` registers its operations in +``osw.service.registry.REGISTRY`` at import time, so this module must not +clear the registry the way ``test_service_registry.py`` does. +""" + +from unittest.mock import MagicMock + +import pytest + +from osw.service import errors +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ops import search + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def test_search_entities_calls_semantic_search(): + osw = MagicMock() + osw.site.semantic_search.return_value = ["Item:OSW1", "Item:OSW2"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.search_entities(ctx, ask_query="[[Category:Item]]") + + assert result["titles"] == ["Item:OSW1", "Item:OSW2"] + assert result["count"] == 2 + osw.site.semantic_search.assert_called_once() + + +def test_full_text_search_calls_prefix_search(): + osw = MagicMock() + osw.site.prefix_search.return_value = ["Item:OSW1"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.full_text_search(ctx, text="OSW") + + assert result["titles"] == ["Item:OSW1"] + assert result["count"] == 1 + assert result["truncated"] is False + osw.site.prefix_search.assert_called_once() + + +def test_list_instances_of_category_calls_query_instances(): + osw = MagicMock() + osw.query_instances.return_value = ["Item:OSW1", "Item:OSW2"] + ctx = Context(_settings(), Policy(), osw=osw) + + result = search.list_instances_of_category(ctx, category="Category:Item") + + assert result["titles"] == ["Item:OSW1", "Item:OSW2"] + assert result["count"] == 2 + osw.query_instances.assert_called_once() + + +def test_sparql_query_without_endpoint_raises_not_configured(): + ctx = Context(_settings(), Policy(), osw=MagicMock()) + + with pytest.raises(errors.NotConfigured): + search.sparql_query(ctx, query="SELECT * WHERE {?s ?p ?o}") diff --git a/tests/test_service_registry.py b/tests/test_service_registry.py index 23feb4d..06ad68d 100644 --- a/tests/test_service_registry.py +++ b/tests/test_service_registry.py @@ -7,6 +7,7 @@ """ import inspect +import typing from unittest.mock import MagicMock import pytest @@ -205,6 +206,34 @@ def fn(ctx, title: str, limit: int = 5) -> dict: assert bound.__name__ == fn.__name__ +def test_bind_resolves_string_annotations_against_the_op_module(): + """An op module using ``from __future__ import annotations`` stores its + annotations as strings. ``bound`` lives in registry.py, so a consumer calling + get_type_hints() on it would resolve them against the wrong globals; bind() + must therefore resolve them eagerly.""" + ns: dict = {} + exec( + "from __future__ import annotations\n" + "from typing import Optional\n" + "class Marker: pass\n" + "def fn(ctx, thing: Optional[Marker] = None) -> dict:\n" + " '''Do a thing.'''\n" + " return {}\n", + ns, + ) + fn, marker = ns["fn"], ns["Marker"] + assert fn.__annotations__["thing"] == "Optional[Marker]" + + op = registry.Operation(name="op1", fn=fn) + bound = registry.bind(op, Context(_settings(), osw=object())) + + expected = typing.Optional[marker] + assert bound.__annotations__["thing"] == expected + assert inspect.signature(bound).parameters["thing"].annotation == expected + # Marker is not in registry.py's globals, so this raised NameError before. + assert typing.get_type_hints(bound)["thing"] == expected + + # -- bind(): error handling -------------------------------------------------- def test_bind_errors_as_dicts_true_returns_payload(): def fn(ctx, title: str) -> dict: From 1959876cc0a9aad41415b25b53e9f2dc42e5da53 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 25 Aug 2026 16:02:08 +0200 Subject: [PATCH 07/28] refactor(service): lift remaining tool bodies into osw.service.ops - schema, status, entities and slots lifted as @operation functions - mcp/tools/*.py collapse to registry loops over bind() - error dicts become raised errors.*; ledger calls become records= hooks - normalize cli_name so every group reads as `osw ` - 172 passed in the dev env, 29 in the mcp extra env --- src/osw/mcp/tools/entities.py | 229 +--------------------------- src/osw/mcp/tools/schema.py | 39 +---- src/osw/mcp/tools/slots.py | 134 +--------------- src/osw/mcp/tools/status.py | 60 ++------ src/osw/service/ops/__init__.py | 5 +- src/osw/service/ops/entities.py | 205 +++++++++++++++++++++++++ src/osw/service/ops/schema.py | 38 +++++ src/osw/service/ops/search.py | 3 + src/osw/service/ops/slots.py | 135 ++++++++++++++++ src/osw/service/ops/status.py | 59 +++++++ tests/test_mcp_tools.py | 5 +- tests/test_service_ops_entities.py | 237 +++++++++++++++++++++++++++++ tests/test_service_ops_schema.py | 51 +++++++ tests/test_service_ops_slots.py | 229 ++++++++++++++++++++++++++++ tests/test_service_ops_status.py | 85 +++++++++++ 15 files changed, 1082 insertions(+), 432 deletions(-) create mode 100644 src/osw/service/ops/entities.py create mode 100644 src/osw/service/ops/schema.py create mode 100644 src/osw/service/ops/slots.py create mode 100644 src/osw/service/ops/status.py create mode 100644 tests/test_service_ops_entities.py create mode 100644 tests/test_service_ops_schema.py create mode 100644 tests/test_service_ops_slots.py create mode 100644 tests/test_service_ops_status.py diff --git a/src/osw/mcp/tools/entities.py b/src/osw/mcp/tools/entities.py index 16feb36..b09aa58 100644 --- a/src/osw/mcp/tools/entities.py +++ b/src/osw/mcp/tools/entities.py @@ -2,229 +2,16 @@ from __future__ import annotations -import sys -from typing import Optional +from osw.service.ops import entities as _ops # noqa: F401 (registers the operations) +from osw.service.registry import bind, iter_operations -import osw.model.entity as model_entity -from osw.core import OSW, AddOverwriteClassOptions, OverwriteOptions -from osw.service import config -from osw.service.serialization import maybe_truncate, to_jsonable -from osw.wtsite import WtSite - -from ..connection import get_ledger, run_guarded - -_OVERWRITE = { - "true": OverwriteOptions.true, - "false": OverwriteOptions.false, - "only empty": OverwriteOptions.only_empty, - "replace remote": AddOverwriteClassOptions.replace_remote, - "keep existing": AddOverwriteClassOptions.keep_existing, -} - - -def _parse_overwrite(value: str): - key = str(value).lower().strip() - if key not in _OVERWRITE: - raise ValueError( - f"Invalid overwrite '{value}'. Valid options: {list(_OVERWRITE)}" - ) - return _OVERWRITE[key] - - -def _resolve_category_class(category: str): - """Find the generated model class whose ``type`` default targets ``category``. - - Avoids guessing the datamodel-code-generator class name; matches on the - ``type`` default (e.g. ``["Category:OSW..."]``) instead. - """ - for obj in vars(model_entity).values(): - if not isinstance(obj, type) or not hasattr(obj, "__fields__"): - continue - field = obj.__fields__.get("type") - default = getattr(field, "default", None) if field is not None else None - if default and category in default: - return obj - return None +from .. import connection def register(mcp, *, include_writes: bool) -> None: """Register entity tools; mutating ones only when ``include_writes``.""" - settings = config.get_settings() - - @mcp.tool() - def get_entity(title: str) -> dict: - """Return an entity's stored JSON data (its ``jsondata`` slot). - - ``title`` is a full page name, e.g. ``Item:OSW123...``. Reading the slot - directly does not modify any local files. - """ - - def _run(osw): - page = osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] - if not page.exists: - return {"title": title, "exists": False, "jsondata": None} - content, truncated = maybe_truncate( - page.get_slot_content("jsondata"), settings.max_chars - ) - return { - "title": title, - "exists": True, - "jsondata": content, - "url": page.get_url(), - "truncated": truncated, - } - - return run_guarded(_run) - - @mcp.tool() - def export_entity_jsonld( - title: str, mode: str = "expand", build_rdf: bool = False - ) -> dict: - """Export an entity as JSON-LD (and optionally RDF/Turtle). - - ``mode`` is one of expand | flatten | compact | frame. Note: this loads - the entity with schema auto-fetch, which regenerates the local generated - model module as a side effect. - """ - - def _run(osw): - result = osw.load_entity( - OSW.LoadEntityParam(titles=[title], autofetch_schema=True) - ) - entities = result.entities - if not isinstance(entities, list): - entities = [entities] - if not entities: - return {"error": f"Entity '{title}' not found.", "type": "NotFound"} - export = osw.export_jsonld( - OSW.ExportJsonLdParams( - entities=entities, mode=mode, build_rdf_graph=build_rdf - ) - ) - out = { - "jsonld": to_jsonable(export.documents[0]) if export.documents else None - } - if build_rdf and export.graph is not None: - out["rdf_turtle"] = export.graph.serialize(format="turtle") - return out - - return run_guarded(_run) - - if not include_writes: - return - - @mcp.tool() - def create_or_update_entity( - category: str, - jsondata: dict, - namespace: Optional[str] = None, - overwrite: str = "keep existing", - comment: Optional[str] = None, - ) -> dict: - """Create or update an entity of ``category`` from a ``jsondata`` payload. - - ``category`` is a full category page name (e.g. ``Category:Item``); use - ``get_category_schema`` to learn the valid fields first. ``overwrite`` - controls update behavior: one of true | false | only empty | - replace remote | keep existing. Records the resulting page(s) in the - provenance ledger so they can be deleted without extra confirmation. - """ - ledger = get_ledger() - - def _run(osw): - fetch = osw.fetch_schema( - OSW.FetchSchemaParam(schema_title=category, mode="append") - ) - if fetch.error_messages: - return { - "error": "; ".join(fetch.error_messages), - "type": "SchemaError", - } - cls = _resolve_category_class(category) - if cls is None: - return { - "error": ( - f"Could not resolve a model class for '{category}' after " - "fetching its schema. Check the category page name." - ), - "type": "ClassNotFound", - } - try: - entity = cls(**jsondata) - except Exception as exc: - return { - "error": f"jsondata does not validate against {category}: {exc}", - "type": "ValidationError", - } - store = osw.store_entity( - OSW.StoreEntityParam( - entities=[entity], - namespace=namespace, - overwrite=_parse_overwrite(overwrite), - edit_comment=comment, - bot_edit=True, - ) - ) - titles = list(store.pages.keys()) - for page_title in titles: - ledger.record( - page_title, - op="create_or_update", - tool="create_or_update_entity", - change_id=store.change_id, - slots=["jsondata"], - ) - domain = config.get_active_domain() - return { - "titles": titles, - "change_id": store.change_id, - "urls": [f"https://{domain}/wiki/{t}" for t in titles], - } - - return run_guarded(_run) - - @mcp.tool() - def delete_entity( - title: str, - confirm_external_delete: bool = False, - comment: Optional[str] = None, - ) -> dict: - """Delete a page by full title, guarded by provenance. - - Pages this server created/modified (tracked in the ledger) are deleted - without extra confirmation. Deleting any other page requires - ``confirm_external_delete=true``. - """ - ledger = get_ledger() - - def _run(osw): - tracked = ledger.is_tracked(title) - if not tracked and not confirm_external_delete: - return { - "error": ( - f"Refusing to delete '{title}': it was not created by this " - "MCP server. Re-run with confirm_external_delete=true to " - "override." - ), - "type": "ExternalDeleteBlocked", - "title": title, - } - page = osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] - if not page.exists: - return { - "title": title, - "deleted": False, - "error": f"Page '{title}' does not exist.", - "type": "NotFound", - } - if not tracked: - print( - f"[osw-mcp] WARNING: deleting externally-created page " - f"'{title}' (confirm_external_delete=True)", - file=sys.stderr, - ) - page.delete(comment or "[osw-mcp] delete") - ledger.mark_deleted(title) - return {"title": title, "deleted": True} - - return run_guarded(_run) + ctx = connection.legacy_context(include_writes=include_writes) + for op in iter_operations(surface="mcp", include_writes=include_writes): + if op.group != "entity": + continue + mcp.tool()(bind(op, ctx)) diff --git a/src/osw/mcp/tools/schema.py b/src/osw/mcp/tools/schema.py index 283e0fe..e6d64b0 100644 --- a/src/osw/mcp/tools/schema.py +++ b/src/osw/mcp/tools/schema.py @@ -3,39 +3,16 @@ from __future__ import annotations -from osw.service import config -from osw.service.serialization import maybe_truncate -from osw.wtsite import WtSite +from osw.service.ops import schema as _ops # noqa: F401 (registers the operations) +from osw.service.registry import bind, iter_operations -from ..connection import run_guarded +from .. import connection def register(mcp) -> None: """Register the read-only schema tool on ``mcp``.""" - settings = config.get_settings() - - @mcp.tool() - def get_category_schema(category: str) -> dict: - """Return the JSON Schema of a category (its ``jsonschema`` slot). - - ``category`` is a full category page name, e.g. ``Category:Item``. The - schema is read directly from the page slot, which - unlike fetching and - generating models - does not modify any local files. Use the returned - schema to construct a valid ``jsondata`` payload for - ``create_or_update_entity``. - """ - - def _run(osw): - page = osw.site.get_page(WtSite.GetPageParam(titles=[category])).pages[0] - if not page.exists: - return {"category": category, "exists": False, "schema": None} - schema = page.get_slot_content("jsonschema") - content, truncated = maybe_truncate(schema, settings.max_chars) - return { - "category": category, - "exists": True, - "schema": content, - "truncated": truncated, - } - - return run_guarded(_run) + ctx = connection.legacy_context() + for op in iter_operations(surface="mcp"): + if op.group != "schema": + continue + mcp.tool()(bind(op, ctx)) diff --git a/src/osw/mcp/tools/slots.py b/src/osw/mcp/tools/slots.py index 319357b..6baa02e 100644 --- a/src/osw/mcp/tools/slots.py +++ b/src/osw/mcp/tools/slots.py @@ -8,134 +8,16 @@ from __future__ import annotations -from typing import Optional, Union +from osw.service.ops import slots as _ops # noqa: F401 (registers the operations) +from osw.service.registry import bind, iter_operations -from osw.service import config -from osw.service.serialization import maybe_truncate -from osw.wtsite import SLOTS, WtSite - -from ..connection import get_ledger, run_guarded - - -def _invalid_slot(slot: str) -> dict: - return { - "error": f"Unknown slot '{slot}'. Valid slots: {list(SLOTS)}", - "type": "InvalidSlot", - } +from .. import connection def register(mcp, *, include_writes: bool) -> None: """Register slot tools; the writer only when ``include_writes``.""" - settings = config.get_settings() - - @mcp.tool() - def list_page_slots(title: str) -> dict: - """List the slots present on a page with their content models.""" - - def _run(osw): - page = osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] - if not page.exists: - return { - "title": title, - "exists": False, - "slots": [], - "valid_slot_keys": list(SLOTS), - } - slots = [] - for key in page._slots: - content = page.get_slot_content(key) - slots.append({ - "key": key, - "content_model": page.get_slot_content_model(key), - "empty": content in (None, "", {}, []), - }) - return { - "title": title, - "exists": True, - "slots": slots, - "valid_slot_keys": list(SLOTS), - } - - return run_guarded(_run) - - @mcp.tool() - def get_slot(title: str, slot: str) -> dict: - """Return the content of a single slot of a page. - - ``slot`` must be one of the valid slot keys (see ``list_page_slots``). - """ - if slot not in SLOTS: - return _invalid_slot(slot) - - def _run(osw): - page = osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] - if not page.exists or slot not in page._slots: - return {"title": title, "slot": slot, "exists": False, "content": None} - content, truncated = maybe_truncate( - page.get_slot_content(slot), settings.max_chars - ) - return { - "title": title, - "slot": slot, - "exists": True, - "content_model": page.get_slot_content_model(slot), - "content": content, - "truncated": truncated, - } - - return run_guarded(_run) - - if not include_writes: - return - - @mcp.tool() - def set_slot( - title: str, - slot: str, - content: Union[str, dict, list], - comment: Optional[str] = None, - create_if_missing: bool = True, - ) -> dict: - """Write the content of a single slot and save the page. - - JSON slots (jsondata, jsonschema) require an object/array; wikitext slots - require a string. Records the page in the provenance ledger. - """ - if slot not in SLOTS: - return _invalid_slot(slot) - content_model = SLOTS[slot]["content_model"] - if content_model == "json" and not isinstance(content, (dict, list)): - return { - "error": f"Slot '{slot}' is JSON; content must be an object or array.", - "type": "InvalidContent", - } - if content_model == "wikitext" and not isinstance(content, str): - return { - "error": f"Slot '{slot}' is wikitext; content must be a string.", - "type": "InvalidContent", - } - ledger = get_ledger() - - def _run(osw): - page = osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] - if slot not in page._slots: - if not create_if_missing: - return { - "error": ( - f"Slot '{slot}' does not exist on '{title}' and " - "create_if_missing is false." - ), - "type": "SlotMissing", - } - page.create_slot(slot, content_model) - page.set_slot_content(slot, content) - page.edit(comment=comment or f"[osw-mcp] set_slot {slot}", bot_edit=True) - ledger.record(title, op="update", tool="set_slot", slots=[slot]) - return { - "title": title, - "slot": slot, - "changed": True, - "url": page.get_url(), - } - - return run_guarded(_run) + ctx = connection.legacy_context(include_writes=include_writes) + for op in iter_operations(surface="mcp", include_writes=include_writes): + if op.group != "slot": + continue + mcp.tool()(bind(op, ctx)) diff --git a/src/osw/mcp/tools/status.py b/src/osw/mcp/tools/status.py index cfbcd2d..e758b7b 100644 --- a/src/osw/mcp/tools/status.py +++ b/src/osw/mcp/tools/status.py @@ -2,60 +2,18 @@ from __future__ import annotations -import sys +from osw.service.ops import status as _ops # noqa: F401 (registers the operations) +from osw.service.registry import bind, iter_operations -from osw.service import config +from .. import connection -from ..connection import get_ledger, osw_guard - - -def _osw_version(): - try: - from importlib.metadata import version - - return version("osw") - except Exception: - return None +_NAMES = ("status",) def register(mcp) -> None: """Register the read-only status tool on ``mcp``.""" - - @mcp.tool() - def status() -> dict: - """Report the active instance, user, mode and ledger info. - - Performs a light connectivity check, but only when an instance is - selected. Never returns the password. - """ - settings = config.get_settings() - active_iri = config.get_active_iri() - active_domain = config.get_active_domain() - info = { - **settings.redacted(), - "active_iri": active_iri, - "active_domain": active_domain, - } - if active_iri is None: - available = ", ".join(config.available_iris()) or "(none)" - info["connected"] = False - info["message"] = ( - "No OSL instance selected. Call select_instance to choose " - f"one; available: {available}." - ) - return info - ledger = get_ledger() - info["ledger_path"] = str(ledger.path) - info["ledger_entry_count"] = ledger.entry_count() - info["osw_version"] = _osw_version() - try: - with osw_guard(): - info["connected"] = True - except Exception as exc: - print( - f"[osw-mcp] status connection check failed: {exc!r}", - file=sys.stderr, - ) - info["connected"] = False - info["connection_error"] = str(exc) - return info + ctx = connection.legacy_context() + for op in iter_operations(surface="mcp"): + if op.name not in _NAMES: + continue + mcp.tool()(bind(op, ctx)) diff --git a/src/osw/service/ops/__init__.py b/src/osw/service/ops/__init__.py index 4b0e455..c27b7aa 100644 --- a/src/osw/service/ops/__init__.py +++ b/src/osw/service/ops/__init__.py @@ -3,8 +3,11 @@ Importing this package registers every operation in :data:`osw.service.registry.REGISTRY`. Imports nothing from ``osw.mcp``, ``osw.cli``, the ``mcp`` SDK or ``typer``. + +Import order fixes the order adapters see, so it is also the order tools are +registered on the MCP server and commands are listed in ``osw --help``. """ from __future__ import annotations -from . import search +from . import entities, schema, search, slots, status diff --git a/src/osw/service/ops/entities.py b/src/osw/service/ops/entities.py new file mode 100644 index 0000000..eff9e57 --- /dev/null +++ b/src/osw/service/ops/entities.py @@ -0,0 +1,205 @@ +"""Entity operations: read entity JSON, export JSON-LD, create/update, delete.""" + +from __future__ import annotations + +import sys +from typing import Optional + +import osw.model.entity as model_entity +from osw.core import OSW, AddOverwriteClassOptions, OverwriteOptions +from osw.service import config, errors +from osw.service.context import Context +from osw.service.ledger import LedgerRecord +from osw.service.registry import operation +from osw.service.serialization import maybe_truncate, to_jsonable +from osw.wtsite import WtSite + +_OVERWRITE = { + "true": OverwriteOptions.true, + "false": OverwriteOptions.false, + "only empty": OverwriteOptions.only_empty, + "replace remote": AddOverwriteClassOptions.replace_remote, + "keep existing": AddOverwriteClassOptions.keep_existing, +} + + +def _parse_overwrite(value: str): + key = str(value).lower().strip() + if key not in _OVERWRITE: + raise ValueError( + f"Invalid overwrite '{value}'. Valid options: {list(_OVERWRITE)}" + ) + return _OVERWRITE[key] + + +def _resolve_category_class(category: str): + """Find the generated model class whose ``type`` default targets ``category``. + + Avoids guessing the datamodel-code-generator class name; matches on the + ``type`` default (e.g. ``["Category:OSW..."]``) instead. + """ + for obj in vars(model_entity).values(): + if not isinstance(obj, type) or not hasattr(obj, "__fields__"): + continue + field = obj.__fields__.get("type") + default = getattr(field, "default", None) if field is not None else None + if default and category in default: + return obj + return None + + +@operation(group="entity", cli_name="get", read_only_hint=True, idempotent_hint=True) +def get_entity(ctx: Context, title: str) -> dict: + """Return an entity's stored JSON data (its ``jsondata`` slot). + + ``title`` is a full page name, e.g. ``Item:OSW123...``. Reading the slot + directly does not modify any local files. + """ + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + return {"title": title, "exists": False, "jsondata": None} + content, truncated = maybe_truncate( + page.get_slot_content("jsondata"), ctx.settings.max_chars + ) + return { + "title": title, + "exists": True, + "jsondata": content, + "url": page.get_url(), + "truncated": truncated, + } + + +@operation(group="entity", cli_name="export", read_only_hint=True, idempotent_hint=True) +def export_entity_jsonld( + ctx: Context, title: str, mode: str = "expand", build_rdf: bool = False +) -> dict: + """Export an entity as JSON-LD (and optionally RDF/Turtle). + + ``mode`` is one of expand | flatten | compact | frame. Note: this loads + the entity with schema auto-fetch, which regenerates the local generated + model module as a side effect. + """ + result = ctx.osw.load_entity( + OSW.LoadEntityParam(titles=[title], autofetch_schema=True) + ) + entities = result.entities + if not isinstance(entities, list): + entities = [entities] + if not entities: + raise errors.NotFound(f"Entity '{title}' not found.") + export = ctx.osw.export_jsonld( + OSW.ExportJsonLdParams(entities=entities, mode=mode, build_rdf_graph=build_rdf) + ) + out = {"jsonld": to_jsonable(export.documents[0]) if export.documents else None} + if build_rdf and export.graph is not None: + out["rdf_turtle"] = export.graph.serialize(format="turtle") + return out + + +@operation( + group="entity", + cli_name="put", + writes=True, + destructive_hint=False, + idempotent_hint=True, + records=lambda r: [ + LedgerRecord( + title=t, op="create_or_update", change_id=r["change_id"], slots=["jsondata"] + ) + for t in r["titles"] + ], +) +def create_or_update_entity( + ctx: Context, + category: str, + jsondata: dict, + namespace: Optional[str] = None, + overwrite: str = "keep existing", + comment: Optional[str] = None, +) -> dict: + """Create or update an entity of ``category`` from a ``jsondata`` payload. + + ``category`` is a full category page name (e.g. ``Category:Item``); use + ``get_category_schema`` to learn the valid fields first. ``overwrite`` + controls update behavior: one of true | false | only empty | + replace remote | keep existing. Records the resulting page(s) in the + provenance ledger so they can be deleted without extra confirmation. + """ + fetch = ctx.osw.fetch_schema( + OSW.FetchSchemaParam(schema_title=category, mode="append") + ) + if fetch.error_messages: + raise errors.SchemaError("; ".join(fetch.error_messages)) + cls = _resolve_category_class(category) + if cls is None: + raise errors.ClassNotFound( + f"Could not resolve a model class for '{category}' after " + "fetching its schema. Check the category page name." + ) + try: + entity = cls(**jsondata) + except Exception as exc: + raise errors.ValidationError( + f"jsondata does not validate against {category}: {exc}" + ) + store = ctx.osw.store_entity( + OSW.StoreEntityParam( + entities=[entity], + namespace=namespace, + overwrite=_parse_overwrite(overwrite), + edit_comment=comment, + bot_edit=True, + ) + ) + titles = list(store.pages.keys()) + domain = config.get_active_domain() + return { + "titles": titles, + "change_id": store.change_id, + "urls": [f"https://{domain}/wiki/{t}" for t in titles], + } + + +@operation( + group="entity", + cli_name="delete", + writes=True, + destructive_hint=True, + requires_user_interaction=True, +) +def delete_entity( + ctx: Context, + title: str, + confirm_external_delete: bool = False, + comment: Optional[str] = None, +) -> dict: + """Delete a page by full title, guarded by provenance. + + Pages this server created/modified (tracked in the ledger) are deleted + without extra confirmation. Deleting any other page requires + ``confirm_external_delete=true``. + """ + tracked = ctx.ledger.is_tracked(title) + if not tracked and not confirm_external_delete: + raise errors.ExternalDeleteBlocked( + f"Refusing to delete '{title}': it was not created by this " + "MCP server. Re-run with confirm_external_delete=true to " + "override.", + extra={"title": title}, + ) + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + raise errors.NotFound( + f"Page '{title}' does not exist.", + extra={"title": title, "deleted": False}, + ) + if not tracked: + print( + f"[osw-mcp] WARNING: deleting externally-created page " + f"'{title}' (confirm_external_delete=True)", + file=sys.stderr, + ) + page.delete(comment or "[osw-mcp] delete") + ctx.ledger.mark_deleted(title) + return {"title": title, "deleted": True} diff --git a/src/osw/service/ops/schema.py b/src/osw/service/ops/schema.py new file mode 100644 index 0000000..363feb1 --- /dev/null +++ b/src/osw/service/ops/schema.py @@ -0,0 +1,38 @@ +"""Schema introspection: fetch a category's JSON Schema so the model can build +valid entities before writing them.""" + +from __future__ import annotations + +from osw.service.context import Context +from osw.service.registry import operation +from osw.service.serialization import maybe_truncate +from osw.wtsite import WtSite + + +@operation( + group="schema", + cli_name="get", + read_only_hint=True, + idempotent_hint=True, + max_result_size_chars=200_000, +) +def get_category_schema(ctx: Context, category: str) -> dict: + """Return the JSON Schema of a category (its ``jsonschema`` slot). + + ``category`` is a full category page name, e.g. ``Category:Item``. The + schema is read directly from the page slot, which - unlike fetching and + generating models - does not modify any local files. Use the returned + schema to construct a valid ``jsondata`` payload for + ``create_or_update_entity``. + """ + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[category])).pages[0] + if not page.exists: + return {"category": category, "exists": False, "schema": None} + schema = page.get_slot_content("jsonschema") + content, truncated = maybe_truncate(schema, ctx.settings.max_chars) + return { + "category": category, + "exists": True, + "schema": content, + "truncated": truncated, + } diff --git a/src/osw/service/ops/search.py b/src/osw/service/ops/search.py index cc3c8cc..09ae11a 100644 --- a/src/osw/service/ops/search.py +++ b/src/osw/service/ops/search.py @@ -35,6 +35,7 @@ def search_entities(ctx: Context, ask_query: str, limit: Optional[int] = None) - @operation( group="search", + cli_name="text", read_only_hint=True, idempotent_hint=True, ) @@ -48,6 +49,7 @@ def full_text_search(ctx: Context, text: str, limit: Optional[int] = None) -> di @operation( group="search", + cli_name="instances", read_only_hint=True, idempotent_hint=True, ) @@ -68,6 +70,7 @@ def list_instances_of_category( @operation( group="search", + cli_name="sparql", read_only_hint=True, idempotent_hint=True, open_world_hint=True, diff --git a/src/osw/service/ops/slots.py b/src/osw/service/ops/slots.py new file mode 100644 index 0000000..da08c12 --- /dev/null +++ b/src/osw/service/ops/slots.py @@ -0,0 +1,135 @@ +"""Full multi-slot page access: list slots, read a slot, write a slot. + +OSW pages are multi-slot MediaWiki pages. The valid slot keys and their content +models come from :data:`osw.wtsite.SLOTS` (main, jsondata, jsonschema, header, +footer, template, header_template, footer_template, data_template, +schema_template). +""" + +from __future__ import annotations + +from typing import Optional, Union + +from osw.service import errors +from osw.service.context import Context +from osw.service.ledger import LedgerRecord +from osw.service.registry import operation +from osw.service.serialization import maybe_truncate +from osw.wtsite import SLOTS, WtSite + + +@operation( + group="slot", + cli_name="list", + read_only_hint=True, + idempotent_hint=True, +) +def list_page_slots(ctx: Context, title: str) -> dict: + """List the slots present on a page with their content models.""" + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + return { + "title": title, + "exists": False, + "slots": [], + "valid_slot_keys": list(SLOTS), + } + slots = [] + for key in page._slots: + content = page.get_slot_content(key) + slots.append({ + "key": key, + "content_model": page.get_slot_content_model(key), + "empty": content in (None, "", {}, []), + }) + return { + "title": title, + "exists": True, + "slots": slots, + "valid_slot_keys": list(SLOTS), + } + + +@operation( + group="slot", + cli_name="get", + read_only_hint=True, + idempotent_hint=True, +) +def get_slot(ctx: Context, title: str, slot: str) -> dict: + """Return the content of a single slot of a page. + + ``slot`` must be one of the valid slot keys (see ``list_page_slots``). + """ + if slot not in SLOTS: + raise errors.InvalidSlot(f"Unknown slot '{slot}'. Valid slots: {list(SLOTS)}") + + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists or slot not in page._slots: + return {"title": title, "slot": slot, "exists": False, "content": None} + content, truncated = maybe_truncate( + page.get_slot_content(slot), ctx.settings.max_chars + ) + return { + "title": title, + "slot": slot, + "exists": True, + "content_model": page.get_slot_content_model(slot), + "content": content, + "truncated": truncated, + } + + +@operation( + group="slot", + cli_name="set", + writes=True, + destructive_hint=False, + idempotent_hint=True, + records=lambda r: ( + [LedgerRecord(title=r["title"], op="update", slots=[r["slot"]])] + if r.get("changed") + else [] + ), +) +def set_slot( + ctx: Context, + title: str, + slot: str, + content: Union[str, dict, list], + comment: Optional[str] = None, + create_if_missing: bool = True, +) -> dict: + """Write the content of a single slot and save the page. + + JSON slots (jsondata, jsonschema) require an object/array; wikitext slots + require a string. Records the page in the provenance ledger. + """ + if slot not in SLOTS: + raise errors.InvalidSlot(f"Unknown slot '{slot}'. Valid slots: {list(SLOTS)}") + content_model = SLOTS[slot]["content_model"] + if content_model == "json" and not isinstance(content, (dict, list)): + raise errors.InvalidContent( + f"Slot '{slot}' is JSON; content must be an object or array." + ) + if content_model == "wikitext" and not isinstance(content, str): + raise errors.InvalidContent( + f"Slot '{slot}' is wikitext; content must be a string." + ) + + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if slot not in page._slots: + if not create_if_missing: + raise errors.SlotMissing( + f"Slot '{slot}' does not exist on '{title}' and " + "create_if_missing is false." + ) + page.create_slot(slot, content_model) + page.set_slot_content(slot, content) + page.edit(comment=comment or f"[osw-mcp] set_slot {slot}", bot_edit=True) + return { + "title": title, + "slot": slot, + "changed": True, + "url": page.get_url(), + } diff --git a/src/osw/service/ops/status.py b/src/osw/service/ops/status.py new file mode 100644 index 0000000..618f036 --- /dev/null +++ b/src/osw/service/ops/status.py @@ -0,0 +1,59 @@ +"""Status / whoami operation: report connection and configuration (no secrets).""" + +from __future__ import annotations + +import sys + +from osw.service import config +from osw.service.context import Context +from osw.service.registry import operation + + +def _osw_version(): + try: + from importlib.metadata import version + + return version("osw") + except Exception: + return None + + +@operation(group=None, read_only_hint=True, idempotent_hint=True) +def status(ctx: Context) -> dict: + """Report the active instance, user, mode and ledger info. + + Performs a light connectivity check, but only when an instance is + selected. Never returns the password. + """ + settings = ctx.settings + active_iri = config.get_active_iri() + active_domain = config.get_active_domain() + info = { + **settings.redacted(), + "active_iri": active_iri, + "active_domain": active_domain, + } + if active_iri is None: + available = ", ".join(config.available_iris()) or "(none)" + info["connected"] = False + info["message"] = ( + "No OSL instance selected. Call select_instance to choose " + f"one; available: {available}." + ) + return info + ledger = ctx.ledger + info["ledger_path"] = str(ledger.path) + info["ledger_entry_count"] = ledger.entry_count() + info["osw_version"] = _osw_version() + try: + with ctx.guard(): + _ = ctx.osw + info["connected"] = True + except Exception as exc: + print( + f"[osw-mcp] status connection check failed: {exc!r}", + file=sys.stderr, + ) + info["connected"] = False + info["connection_error"] = str(exc) + return info diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index 1ba0fb8..5fbca66 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -14,6 +14,7 @@ from osw.mcp import connection from osw.mcp.tools import entities, search, slots from osw.service import config +from osw.service.ops import entities as entity_ops class FakeMCP: @@ -213,9 +214,9 @@ def test_create_or_update_entity_uses_active_domain(env, monkeypatch, tmp_path): ) monkeypatch.setattr(connection, "get_osw", lambda: osw) monkeypatch.setattr( - entities, + entity_ops, "_resolve_category_class", - lambda category: entities.model_entity.Entity, + lambda category: entity_ops.model_entity.Entity, ) fake = FakeMCP() entities.register(fake, include_writes=True) diff --git a/tests/test_service_ops_entities.py b/tests/test_service_ops_entities.py new file mode 100644 index 0000000..f099f4b --- /dev/null +++ b/tests/test_service_ops_entities.py @@ -0,0 +1,237 @@ +"""Unit tests for osw.service.ops.entities (Operation.fn called directly). + +Importing ``osw.service.ops.entities`` registers its operations in +``osw.service.registry.REGISTRY`` at import time, so this module must not +clear the registry the way ``test_service_registry.py`` does. +""" + +from unittest.mock import MagicMock + +import pytest + +from osw.service import errors, registry +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ledger import LedgerRecord +from osw.service.ops import entities + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def _osw_with_page(exists=True): + page = MagicMock() + page.exists = exists + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + return osw, page + + +# -- get_entity -------------------------------------------------------------- +def test_get_entity_missing_page_returns_not_exists(): + osw, _ = _osw_with_page(exists=False) + ctx = Context(_settings(), Policy(), osw=osw) + + result = entities.get_entity(ctx, title="Item:OSW1") + + assert result == {"title": "Item:OSW1", "exists": False, "jsondata": None} + + +def test_get_entity_reads_jsondata_slot(): + osw, page = _osw_with_page() + page.get_slot_content.return_value = {"label": [{"text": "X"}]} + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + ctx = Context(_settings(), Policy(), osw=osw) + + result = entities.get_entity(ctx, title="Item:OSW1") + + assert result["exists"] is True + assert result["jsondata"] == {"label": [{"text": "X"}]} + page.get_slot_content.assert_called_with("jsondata") + + +# -- export_entity_jsonld ----------------------------------------------------- +def test_export_entity_jsonld_returns_jsonld(): + osw = MagicMock() + osw.load_entity.return_value = MagicMock(entities=[MagicMock()]) + osw.export_jsonld.return_value = MagicMock( + documents=[{"@id": "Item:OSW1"}], graph=None + ) + ctx = Context(_settings(), Policy(), osw=osw) + + result = entities.export_entity_jsonld(ctx, title="Item:OSW1") + + assert result == {"jsonld": {"@id": "Item:OSW1"}} + + +def test_export_entity_jsonld_not_found_raises(): + osw = MagicMock() + osw.load_entity.return_value = MagicMock(entities=[]) + ctx = Context(_settings(), Policy(), osw=osw) + + with pytest.raises(errors.NotFound): + entities.export_entity_jsonld(ctx, title="Item:OSW404") + + +# -- create_or_update_entity --------------------------------------------------- +def test_create_or_update_entity_uses_active_domain(monkeypatch): + osw = MagicMock() + osw.fetch_schema.return_value = MagicMock(error_messages=[]) + osw.store_entity.return_value = MagicMock( + pages={"Item:OSW1": MagicMock()}, change_id="c1" + ) + monkeypatch.setattr( + entities, "_resolve_category_class", lambda category: entities.model_entity.Item + ) + monkeypatch.setattr( + entities.config, "get_active_domain", lambda: "wiki-b.example.org" + ) + ctx = Context(_settings(), Policy(), osw=osw) + + result = entities.create_or_update_entity( + ctx, category="Category:Item", jsondata={"label": [{"text": "Test"}]} + ) + + assert result["titles"] == ["Item:OSW1"] + assert result["change_id"] == "c1" + assert result["urls"] == ["https://wiki-b.example.org/wiki/Item:OSW1"] + + +def test_create_or_update_entity_schema_error_raises(): + osw = MagicMock() + osw.fetch_schema.return_value = MagicMock(error_messages=["bad schema"]) + ctx = Context(_settings(), Policy(), osw=osw) + + with pytest.raises(errors.SchemaError): + entities.create_or_update_entity(ctx, category="Category:Item", jsondata={}) + + +def test_create_or_update_entity_class_not_found_raises(monkeypatch): + osw = MagicMock() + osw.fetch_schema.return_value = MagicMock(error_messages=[]) + monkeypatch.setattr(entities, "_resolve_category_class", lambda category: None) + ctx = Context(_settings(), Policy(), osw=osw) + + with pytest.raises(errors.ClassNotFound): + entities.create_or_update_entity(ctx, category="Category:Bogus", jsondata={}) + + +def test_create_or_update_entity_validation_error_raises(monkeypatch): + osw = MagicMock() + osw.fetch_schema.return_value = MagicMock(error_messages=[]) + + class _Boom: + def __init__(self, **kwargs): + raise ValueError("nope") + + monkeypatch.setattr(entities, "_resolve_category_class", lambda category: _Boom) + ctx = Context(_settings(), Policy(), osw=osw) + + with pytest.raises(errors.ValidationError): + entities.create_or_update_entity(ctx, category="Category:Item", jsondata={}) + + +# -- records= (ledger hook) ---------------------------------------------------- +def test_create_or_update_entity_records_matches_old_inline_ledger_call(): + op = registry.REGISTRY["create_or_update_entity"] + + result = { + "titles": ["Item:OSW1", "Item:OSW2"], + "change_id": "c1", + "urls": [ + "https://wiki.example.org/wiki/Item:OSW1", + "https://wiki.example.org/wiki/Item:OSW2", + ], + } + + assert op.records(result) == [ + LedgerRecord( + title="Item:OSW1", op="create_or_update", change_id="c1", slots=["jsondata"] + ), + LedgerRecord( + title="Item:OSW2", op="create_or_update", change_id="c1", slots=["jsondata"] + ), + ] + + +def test_create_or_update_entity_records_empty_when_no_titles(): + op = registry.REGISTRY["create_or_update_entity"] + + assert op.records({"titles": [], "change_id": "c1", "urls": []}) == [] + + +def test_create_or_update_entity_schema_error_does_not_reach_bind_records(): + op = registry.REGISTRY["create_or_update_entity"] + osw = MagicMock() + osw.fetch_schema.return_value = MagicMock(error_messages=["boom"]) + fake_ledger = MagicMock() + ctx = Context( + _settings(), Policy(errors_as_dicts=True), osw=osw, ledger=fake_ledger + ) + bound = registry.bind(op, ctx) + + result = bound(category="Category:Item", jsondata={"label": [{"text": "Test"}]}) + + assert result["type"] == "SchemaError" + fake_ledger.record.assert_not_called() + + +# -- delete_entity -------------------------------------------------------- +def test_delete_untracked_is_blocked(): + osw, page = _osw_with_page() + ledger = MagicMock() + ledger.is_tracked.return_value = False + ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger) + + with pytest.raises(errors.ExternalDeleteBlocked) as exc_info: + entities.delete_entity(ctx, title="Item:OSWx") + + assert exc_info.value.payload()["title"] == "Item:OSWx" + osw.site.get_page.assert_not_called() # never even fetched the page + page.delete.assert_not_called() + + +def test_delete_tracked_is_allowed(): + osw, page = _osw_with_page() + ledger = MagicMock() + ledger.is_tracked.return_value = True + ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger) + + result = entities.delete_entity(ctx, title="Item:OSWx") + + assert result == {"title": "Item:OSWx", "deleted": True} + page.delete.assert_called_once() + ledger.mark_deleted.assert_called_once_with("Item:OSWx") + + +def test_delete_external_with_confirm(): + osw, page = _osw_with_page() + ledger = MagicMock() + ledger.is_tracked.return_value = False + ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger) + + result = entities.delete_entity( + ctx, title="Item:OSWy", confirm_external_delete=True + ) + + assert result == {"title": "Item:OSWy", "deleted": True} + page.delete.assert_called_once() + + +def test_delete_nonexistent_page_raises(): + osw, page = _osw_with_page(exists=False) + ledger = MagicMock() + ledger.is_tracked.return_value = True + ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger) + + with pytest.raises(errors.NotFound) as exc_info: + entities.delete_entity(ctx, title="Item:OSWz") + + assert exc_info.value.payload() == { + "title": "Item:OSWz", + "deleted": False, + "error": "Page 'Item:OSWz' does not exist.", + "type": "NotFound", + } + page.delete.assert_not_called() diff --git a/tests/test_service_ops_schema.py b/tests/test_service_ops_schema.py new file mode 100644 index 0000000..fbe7d83 --- /dev/null +++ b/tests/test_service_ops_schema.py @@ -0,0 +1,51 @@ +"""Unit tests for osw.service.ops.schema (Operation.fn called directly). + +Importing ``osw.service.ops.schema`` registers its operations in +``osw.service.registry.REGISTRY`` at import time, so this module must not +clear the registry the way ``test_service_registry.py`` does. +""" + +from unittest.mock import MagicMock + +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ops import schema + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def test_get_category_schema_returns_schema_when_page_exists(): + page = MagicMock() + page.exists = True + page.get_slot_content.return_value = {"type": "object"} + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + ctx = Context(_settings(), Policy(), osw=osw) + + result = schema.get_category_schema(ctx, category="Category:Item") + + assert result == { + "category": "Category:Item", + "exists": True, + "schema": {"type": "object"}, + "truncated": False, + } + page.get_slot_content.assert_called_with("jsonschema") + + +def test_get_category_schema_returns_not_exists_for_missing_page(): + page = MagicMock() + page.exists = False + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + ctx = Context(_settings(), Policy(), osw=osw) + + result = schema.get_category_schema(ctx, category="Category:Missing") + + assert result == { + "category": "Category:Missing", + "exists": False, + "schema": None, + } diff --git a/tests/test_service_ops_slots.py b/tests/test_service_ops_slots.py new file mode 100644 index 0000000..5116f27 --- /dev/null +++ b/tests/test_service_ops_slots.py @@ -0,0 +1,229 @@ +"""Unit tests for osw.service.ops.slots (Operation.fn called directly). + +Importing ``osw.service.ops.slots`` registers its operations in +``osw.service.registry.REGISTRY`` at import time, so this module must not +clear the registry the way ``test_service_registry.py`` does. +""" + +from unittest.mock import MagicMock + +import pytest + +from osw.service import errors, registry +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ledger import LedgerRecord +from osw.service.ops import slots + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def _osw_with_page(exists=True, present_slots=()): + page = MagicMock() + page.exists = exists + page._slots = list(present_slots) + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + return osw, page + + +# -- list_page_slots -------------------------------------------------------- +def test_list_page_slots_missing_page(): + osw, _ = _osw_with_page(exists=False) + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.list_page_slots(ctx, title="Item:OSW1") + + assert result == { + "title": "Item:OSW1", + "exists": False, + "slots": [], + "valid_slot_keys": list(slots.SLOTS), + } + + +def test_list_page_slots_existing_page(): + osw, page = _osw_with_page(present_slots=["main", "jsondata"]) + page.get_slot_content.side_effect = lambda key: "" if key == "main" else {"a": 1} + page.get_slot_content_model.side_effect = lambda key: ( + "wikitext" if key == "main" else "json" + ) + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.list_page_slots(ctx, title="Item:OSW1") + + assert result["title"] == "Item:OSW1" + assert result["exists"] is True + assert result["slots"] == [ + {"key": "main", "content_model": "wikitext", "empty": True}, + {"key": "jsondata", "content_model": "json", "empty": False}, + ] + assert result["valid_slot_keys"] == list(slots.SLOTS) + + +# -- get_slot ---------------------------------------------------------------- +def test_get_slot_rejects_unknown_slot(): + ctx = Context(_settings(), Policy(), osw=MagicMock()) + + with pytest.raises(errors.InvalidSlot): + slots.get_slot(ctx, title="Item:OSW1", slot="bogus") + + +def test_get_slot_missing_page_returns_not_exists(): + osw, _ = _osw_with_page(exists=False) + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.get_slot(ctx, title="Item:OSW1", slot="jsondata") + + assert result == { + "title": "Item:OSW1", + "slot": "jsondata", + "exists": False, + "content": None, + } + + +def test_get_slot_missing_slot_returns_not_exists(): + osw, _page = _osw_with_page(present_slots=["main"]) + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.get_slot(ctx, title="Item:OSW1", slot="jsondata") + + assert result == { + "title": "Item:OSW1", + "slot": "jsondata", + "exists": False, + "content": None, + } + + +def test_get_slot_existing_slot(): + osw, page = _osw_with_page(present_slots=["jsondata"]) + page.get_slot_content.return_value = {"label": [{"text": "X"}]} + page.get_slot_content_model.return_value = "json" + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.get_slot(ctx, title="Item:OSW1", slot="jsondata") + + assert result["exists"] is True + assert result["content_model"] == "json" + assert result["content"] == {"label": [{"text": "X"}]} + assert result["truncated"] is False + page.get_slot_content.assert_called_with("jsondata") + + +# -- set_slot ------------------------------------------------------------ +def test_set_slot_rejects_unknown_slot(): + ctx = Context(_settings(), Policy(), osw=MagicMock()) + + with pytest.raises(errors.InvalidSlot): + slots.set_slot(ctx, title="Item:OSW1", slot="bogus", content="x") + + +def test_set_slot_rejects_wrong_content_type_json(): + ctx = Context(_settings(), Policy(), osw=MagicMock()) + + with pytest.raises(errors.InvalidContent): + slots.set_slot(ctx, title="Item:OSW1", slot="jsondata", content="not-json") + + +def test_set_slot_rejects_wrong_content_type_wikitext(): + ctx = Context(_settings(), Policy(), osw=MagicMock()) + + with pytest.raises(errors.InvalidContent): + slots.set_slot(ctx, title="Item:OSW1", slot="main", content={"not": "a string"}) + + +def test_set_slot_missing_slot_without_create_raises_slot_missing(): + osw, page = _osw_with_page(present_slots=[]) + ctx = Context(_settings(), Policy(), osw=osw) + + with pytest.raises(errors.SlotMissing): + slots.set_slot( + ctx, + title="Item:OSW1", + slot="jsondata", + content={"a": 1}, + create_if_missing=False, + ) + page.create_slot.assert_not_called() + page.set_slot_content.assert_not_called() + + +def test_set_slot_creates_missing_slot_when_allowed(): + osw, page = _osw_with_page(present_slots=[]) + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.set_slot(ctx, title="Item:OSW1", slot="jsondata", content={"a": 1}) + + page.create_slot.assert_called_once_with("jsondata", "json") + page.set_slot_content.assert_called_once_with("jsondata", {"a": 1}) + page.edit.assert_called_once() + assert result == { + "title": "Item:OSW1", + "slot": "jsondata", + "changed": True, + "url": "https://wiki.example.org/wiki/Item:OSW1", + } + + +def test_set_slot_existing_slot_skips_create(): + osw, page = _osw_with_page(present_slots=["jsondata"]) + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + ctx = Context(_settings(), Policy(), osw=osw) + + result = slots.set_slot(ctx, title="Item:OSW1", slot="jsondata", content={"a": 1}) + + page.create_slot.assert_not_called() + page.set_slot_content.assert_called_once_with("jsondata", {"a": 1}) + assert result["changed"] is True + + +# -- records= (ledger hook) ------------------------------------------------- +def test_set_slot_records_matches_old_inline_ledger_call(): + op = registry.REGISTRY["set_slot"] + + result = { + "title": "Item:OSW1", + "slot": "jsondata", + "changed": True, + "url": "https://wiki.example.org/wiki/Item:OSW1", + } + + assert op.records(result) == [ + LedgerRecord(title="Item:OSW1", op="update", slots=["jsondata"]) + ] + + +def test_set_slot_records_empty_when_not_changed(): + op = registry.REGISTRY["set_slot"] + + assert ( + op.records({"title": "Item:OSW1", "slot": "jsondata", "changed": False}) == [] + ) + + +def test_set_slot_records_empty_when_changed_key_absent(): + op = registry.REGISTRY["set_slot"] + + assert op.records({"title": "Item:OSW1", "slot": "jsondata"}) == [] + + +def test_set_slot_error_paths_do_not_reach_bind_records(): + """The invalid-input/slot-missing paths raise, so bind() never calls + op.records for them -- matching the old code, which returned before + reaching ``ledger.record``.""" + op = registry.REGISTRY["set_slot"] + fake_ledger = MagicMock() + ctx = Context( + _settings(), Policy(errors_as_dicts=True), osw=MagicMock(), ledger=fake_ledger + ) + bound = registry.bind(op, ctx) + + result = bound(title="Item:OSW1", slot="bogus", content="x") + + assert result["type"] == "InvalidSlot" + fake_ledger.record.assert_not_called() diff --git a/tests/test_service_ops_status.py b/tests/test_service_ops_status.py new file mode 100644 index 0000000..40380e4 --- /dev/null +++ b/tests/test_service_ops_status.py @@ -0,0 +1,85 @@ +"""Unit tests for osw.service.ops.status (Operation.fn called directly). + +Importing ``osw.service.ops.status`` registers its operation in +``osw.service.registry.REGISTRY`` at import time, so this module must not +clear the registry the way ``test_service_registry.py`` does. +""" + +from unittest.mock import MagicMock + +from osw.service import config +from osw.service.context import Context, Policy +from osw.service.ops import status + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_CRED_FILEPATH", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", +] + + +def _clean_env(monkeypatch): + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + + +def test_status_reports_active_instance_and_connects(monkeypatch): + _clean_env(monkeypatch) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + config.reset() + ledger = MagicMock() + ledger.path = "/tmp/ledger.json" + ledger.entry_count.return_value = 3 + ctx = Context(config.get_settings(), Policy(), osw=MagicMock(), ledger=ledger) + + result = status.status(ctx) + + assert result["connected"] is True + assert "password" not in result + assert result["active_iri"] == "wiki.example.org" + assert result["ledger_entry_count"] == 3 + config.reset() + + +def test_status_no_active_instance_reports_message(monkeypatch): + _clean_env(monkeypatch) + config.reset() + monkeypatch.setattr(config, "get_settings", lambda: config.Settings(domain=None)) + ctx = Context(config.get_settings(), Policy(), osw=MagicMock(), ledger=MagicMock()) + + result = status.status(ctx) + + assert result["connected"] is False + assert result["active_iri"] is None + assert "message" in result + config.reset() + + +def test_status_connection_failure_reports_connection_error(monkeypatch): + _clean_env(monkeypatch) + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + config.reset() + + from osw.service import context as context_module + + def _raise(*args, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(context_module, "OswExpress", _raise) + ctx = Context(config.get_settings(), Policy(), ledger=MagicMock()) + + result = status.status(ctx) + + assert result["connected"] is False + assert "boom" in result["connection_error"] + config.reset() From c1cede3bcca2ff5e1f8861d6af794b542c1ad6a9 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 25 Aug 2026 16:26:09 +0200 Subject: [PATCH 08/28] feat(cli): add typer CLI assembled from the operation registry - typer as a base dependency; osw = "osw.cli.main:app" console script - commands built from iter_operations(surface="cli"); Context built lazily - OpError.exit_code becomes the process exit status, no traceback - json_value parser lives in osw.service.params, so core never imports cli - set_slot coerces content per the sibling slot's content model --- pyproject.toml | 6 + src/osw/cli/__init__.py | 9 ++ src/osw/cli/main.py | 160 ++++++++++++++++++++ src/osw/cli/render.py | 64 ++++++++ src/osw/service/ops/__init__.py | 9 +- src/osw/service/ops/entities.py | 7 +- src/osw/service/ops/slots.py | 5 + src/osw/service/params.py | 49 +++++++ tests/test_cli.py | 251 ++++++++++++++++++++++++++++++++ tests/test_mcp_tools.py | 24 +++ uv.lock | 2 + 11 files changed, 582 insertions(+), 4 deletions(-) create mode 100644 src/osw/cli/__init__.py create mode 100644 src/osw/cli/main.py create mode 100644 src/osw/cli/render.py create mode 100644 src/osw/service/params.py create mode 100644 tests/test_cli.py diff --git a/pyproject.toml b/pyproject.toml index 8a324a4..2254ab4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ dependencies = [ "dask", "tqdm", "pybars3-wheel", + # the osw CLI (src/osw/cli); a base dependency, not an extra, so `pip + # install osw` never ships a broken `osw` console script + "typer", ] [project.urls] @@ -92,6 +95,9 @@ tutorial = ["osw[dataimport]"] all = ["osw[dataimport,DB,UI,S3,wikitext]"] [project.scripts] +# command-line access to a live OSL instance, built from the same +# osw.service.registry the MCP server uses +osw = "osw.cli.main:app" # stdio MCP server exposing a live OSL instance to MCP clients (e.g. Claude Code) osw-mcp = "osw.mcp.server:main" diff --git a/src/osw/cli/__init__.py b/src/osw/cli/__init__.py new file mode 100644 index 0000000..0a237f9 --- /dev/null +++ b/src/osw/cli/__init__.py @@ -0,0 +1,9 @@ +"""osw: a command-line client assembled from the same ``osw.service.registry`` +that ``osw-mcp`` uses. + +Every operation is registered once (see :mod:`osw.service.ops`) and exposed +identically by every adapter; this package's only job is to turn that +registry into a ``typer`` command tree. +""" + +from __future__ import annotations diff --git a/src/osw/cli/main.py b/src/osw/cli/main.py new file mode 100644 index 0000000..4b8e0c8 --- /dev/null +++ b/src/osw/cli/main.py @@ -0,0 +1,160 @@ +"""Entry point for the ``osw`` CLI. + +Run via the ``osw`` console script or ``python -m osw.cli.main``. The command +tree is assembled once, at import time, by looping over +:func:`osw.service.registry.iter_operations`; building it never touches +credentials or the network. The :class:`~osw.service.context.Context` for a +given invocation is built lazily, inside each command's callback, so +``osw --help`` (and friends) work with no configuration present at all. +""" + +from __future__ import annotations + +import inspect +from typing import Any, get_type_hints + +import typer + +# Registers every operation in osw.service.registry.REGISTRY as a side effect. +import osw.service.ops # noqa: F401 +from osw.service import config +from osw.service.context import Context, Policy +from osw.service.errors import OpError +from osw.service.params import json_value +from osw.service.registry import Operation, bind, iter_operations +from osw.wtsite import SLOTS + +from .render import render + +app = typer.Typer(no_args_is_help=True, add_completion=False) + + +@app.callback() +def _callback( + ctx: typer.Context, + as_json: bool = typer.Option( + False, "--json", "-j", help="Emit machine-readable JSON on stdout." + ), + read_only: bool = typer.Option( + False, "--read-only", help="Refuse write operations." + ), + verbose: bool = typer.Option( + False, "--verbose", "-v", help="Show full tracebacks on unexpected errors." + ), +) -> None: + """osw: command-line access to an OpenSemanticLab (OSW) instance. + + Connection settings and credentials come from the environment or a + .env file (see ``osw.service.config``); no instance selection option is + exposed here yet. + """ + ctx.obj = {"as_json": as_json, "read_only": read_only, "verbose": verbose} + + +def _op_params(op: Operation) -> list[inspect.Parameter]: + """The op's CLI-facing parameters (its signature, minus ``ctx``). + + Mirrors :func:`osw.service.registry.bind`'s annotation resolution, but + only needs ``op.fn`` -- no ``Context`` -- so it is safe to call at + app-build time. + """ + try: + hints = get_type_hints(op.fn, include_extras=True) + except Exception: + hints = {} + sig = inspect.signature(op.fn) + params = [ + p.replace(annotation=hints.get(p.name, p.annotation)) + for p in list(sig.parameters.values())[1:] # drop ctx + ] + + if op.name == "set_slot": + # set_slot's `content: Union[str, dict, list]` is left unmarked in + # the core (osw.service.ops.slots): typer has no support for + # arbitrary Union types (verified empirically -- building a command + # with this annotation raises AssertionError at app-build time). The + # CLI instead takes `content` as a plain string and coerces it to + # JSON at invocation time in `_run`, but only when the sibling + # `slot` argument's content model is "json" (see SLOTS); a blanket + # JSON parser would silently turn plain-text content like "123" + # into an int. + params = [ + p.replace(annotation=str) if p.name == "content" else p for p in params + ] + + return params + + +def _run(op: Operation, typer_ctx: typer.Context, kwargs: dict[str, Any]) -> None: + opts = typer_ctx.obj or {} + + if op.name == "set_slot": + slot = kwargs.get("slot") + content_model = SLOTS.get(slot, {}).get("content_model") + content = kwargs.get("content") + if content_model == "json" and isinstance(content, str): + kwargs["content"] = json_value(content) + + settings = config.load(strict=False) + policy = Policy( + capture_stdout=bool(opts.get("as_json")), + errors_as_dicts=False, + allow_writes=not opts.get("read_only"), + allow_interactive=True, + ) + context = Context(settings, policy) + bound = bind(op, context) + + try: + result = bound(**kwargs) + except OpError as exc: + typer.echo(f"{exc.type}: {exc}", err=True) + raise typer.Exit(exc.exit_code) + except Exception as exc: + if opts.get("verbose"): + raise + typer.echo(f"{type(exc).__name__}: {exc}", err=True) + raise typer.Exit(1) + + typer.echo(render(result, as_json=bool(opts.get("as_json")))) + + +def _make_command(op: Operation): + """Build the typer command callable for ``op``.""" + op_params = _op_params(op) + ctx_param = inspect.Parameter( + "typer_ctx", + inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=typer.Context, + ) + + def command(**kwargs: Any) -> None: + typer_ctx = kwargs.pop("typer_ctx") + _run(op, typer_ctx, kwargs) + + command.__name__ = op.fn.__name__ + command.__doc__ = inspect.getdoc(op.fn) + command.__signature__ = inspect.Signature(parameters=[ctx_param, *op_params]) + annotations = {p.name: p.annotation for p in op_params} + annotations["typer_ctx"] = typer.Context + command.__annotations__ = annotations + return command + + +_groups: dict[str, typer.Typer] = {} + +for _op in iter_operations(surface="cli"): + _command = _make_command(_op) + if _op.group is None: + app.command(name=_op.command)(_command) + else: + _sub = _groups.get(_op.group) + if _sub is None: + _sub = typer.Typer() + _groups[_op.group] = _sub + app.add_typer(_sub, name=_op.group) + _sub.command(name=_op.command)(_command) + + +if __name__ == "__main__": + app() diff --git a/src/osw/cli/render.py b/src/osw/cli/render.py new file mode 100644 index 0000000..5787f22 --- /dev/null +++ b/src/osw/cli/render.py @@ -0,0 +1,64 @@ +"""Rendering helpers for the ``osw`` CLI. + +Kept deliberately simple: this is not a table library, just enough structure +to make operation results readable on a terminal (or, with ``--json``, +machine-parseable). + +The matching input-side helper, ``json_value``, lives in +:mod:`osw.service.params`: it is referenced from operation signatures, which +must not import an adapter. +""" + +from __future__ import annotations + +import json + + +def render(result: dict, *, as_json: bool) -> str: + """Render an operation's result for the CLI. + + With ``as_json``, a plain ``json.dumps``. Otherwise a compact + human-readable rendering: a ``{"titles": [...], "count": n, "truncated": + bool}``-shaped result prints one title per line plus a count/truncation + footer; any other dict renders as aligned ``key: value`` lines, with + nested structures (dicts/lists) dumped as indented JSON. + """ + if as_json: + return json.dumps(result, indent=2, ensure_ascii=False) + if _is_title_list(result): + return _render_title_list(result) + return _render_dict(result) + + +def _is_title_list(result: dict) -> bool: + return ( + isinstance(result, dict) + and isinstance(result.get("titles"), list) + and "count" in result + ) + + +def _render_title_list(result: dict) -> str: + lines = [str(title) for title in result["titles"]] + count = result.get("count", len(result["titles"])) + footer = f"{count} result{'s' if count != 1 else ''}" + if result.get("truncated"): + footer += " (truncated)" + lines.append(footer) + return "\n".join(lines) + + +def _render_dict(result: dict) -> str: + if not isinstance(result, dict): + return json.dumps(result, indent=2, ensure_ascii=False) + width = max((len(str(key)) for key in result), default=0) + lines = [] + for key, value in result.items(): + label = str(key).ljust(width) + if isinstance(value, (dict, list)): + nested = json.dumps(value, indent=2, ensure_ascii=False) + indented = "\n".join(f" {line}" for line in nested.splitlines()) + lines.append(f"{label}:\n{indented}") + else: + lines.append(f"{label}: {value}") + return "\n".join(lines) diff --git a/src/osw/service/ops/__init__.py b/src/osw/service/ops/__init__.py index c27b7aa..a43776f 100644 --- a/src/osw/service/ops/__init__.py +++ b/src/osw/service/ops/__init__.py @@ -1,8 +1,13 @@ """Operation implementations, one module per group. Importing this package registers every operation in -:data:`osw.service.registry.REGISTRY`. Imports nothing from ``osw.mcp``, -``osw.cli``, the ``mcp`` SDK or ``typer``. +:data:`osw.service.registry.REGISTRY`. It imports nothing from ``osw.mcp``, +``osw.cli`` or the ``mcp`` SDK, so this package (and by extension +``osw.service``) stays importable without the optional ``mcp`` extra and never +depends on an adapter. ``typer`` is a base dependency, so op modules may import +it directly to mark up a parameter's CLI form (see ``create_or_update_entity``'s +``jsondata`` and :mod:`osw.service.params`); pydantic ignores ``Annotated`` +metadata it does not recognise, so the MCP JSON schema is unaffected. Import order fixes the order adapters see, so it is also the order tools are registered on the MCP server and commands are listed in ``osw --help``. diff --git a/src/osw/service/ops/entities.py b/src/osw/service/ops/entities.py index eff9e57..a6229d6 100644 --- a/src/osw/service/ops/entities.py +++ b/src/osw/service/ops/entities.py @@ -3,13 +3,16 @@ from __future__ import annotations import sys -from typing import Optional +from typing import Annotated, Optional + +import typer import osw.model.entity as model_entity from osw.core import OSW, AddOverwriteClassOptions, OverwriteOptions from osw.service import config, errors from osw.service.context import Context from osw.service.ledger import LedgerRecord +from osw.service.params import json_value from osw.service.registry import operation from osw.service.serialization import maybe_truncate, to_jsonable from osw.wtsite import WtSite @@ -113,7 +116,7 @@ def export_entity_jsonld( def create_or_update_entity( ctx: Context, category: str, - jsondata: dict, + jsondata: Annotated[dict, typer.Option(parser=json_value)], namespace: Optional[str] = None, overwrite: str = "keep existing", comment: Optional[str] = None, diff --git a/src/osw/service/ops/slots.py b/src/osw/service/ops/slots.py index da08c12..bd05512 100644 --- a/src/osw/service/ops/slots.py +++ b/src/osw/service/ops/slots.py @@ -96,6 +96,11 @@ def set_slot( ctx: Context, title: str, slot: str, + # Deliberately left without a typer marker: typer has no support for + # arbitrary Union types (verified empirically), and whether this is JSON + # depends on the sibling `slot` argument's content model, so a single + # static parser would be wrong. osw.cli.main handles the CLI coercion + # explicitly, after both arguments are known. content: Union[str, dict, list], comment: Optional[str] = None, create_if_missing: bool = True, diff --git a/src/osw/service/params.py b/src/osw/service/params.py new file mode 100644 index 0000000..92eaf44 --- /dev/null +++ b/src/osw/service/params.py @@ -0,0 +1,49 @@ +"""Parsers for operation parameters whose CLI form differs from their Python type. + +An operation declares its parameter surface once, so a parameter typed ``dict`` +needs a way to say how a shell should spell it. typer reads that from +``Annotated[..., typer.Option(parser=...)]`` metadata on the parameter, and +pydantic ignores metadata it does not recognise, so attaching a parser here +leaves the MCP JSON schema untouched. + +This module lives in ``osw.service`` rather than ``osw.cli`` so the dependency +runs adapter -> core: an op module must never import an adapter. typer is a base +dependency, so importing it here costs nothing extra. ``typer.BadParameter`` is +used deliberately -- click discards the message of a plain ``ValueError`` raised +from a ``parser=`` callback and reports only the offending value. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +import typer + + +def json_value(raw: str) -> Any: + """Typer parser for structured (JSON) CLI parameters. + + Accepts a JSON literal, ``@path/to/file.json`` (read the file's + contents), or ``-`` (read from stdin). + """ + if raw == "-": + source = "stdin" + text = sys.stdin.read() + elif raw.startswith("@"): + path = raw[1:] + source = path + try: + text = Path(path).read_text(encoding="utf-8") + except OSError as exc: + raise typer.BadParameter(f"Could not read '{path}': {exc}") + else: + source = "argument" + text = raw + + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise typer.BadParameter(f"Invalid JSON ({source}): {exc}") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b3e1a2a --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,251 @@ +"""Unit tests for the osw CLI (src/osw/cli). + +Runs in the plain dev env (no mcp extra needed): the CLI never imports the +mcp SDK. No network is touched -- ``osw.service.context.OswExpress`` is +patched wherever a test actually reaches a command's body. +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest +import typer +from typer.testing import CliRunner + +import osw.cli.main as cli_main +from osw.cli.main import app +from osw.cli.render import render +from osw.service import config +from osw.service.params import json_value +from osw.service.registry import iter_operations + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_CRED_FILEPATH", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", + "OSW_SPARQL_ENDPOINT", + "OSW_READ_ONLY", + "OSW_MCP_READ_ONLY", + "OSW_STATE_DIR", + "OSW_MCP_STATE_DIR", + "OSW_MAX_RESULTS", + "OSW_MCP_MAX_RESULTS", + "OSW_MAX_CHARS", + "OSW_MCP_MAX_CHARS", + "OSW_ENV_FILE", + "OSW_MCP_ENV_FILE", +] + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch, tmp_path): + """No real credentials, no real .env file, no leaked active instance.""" + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(empty)) + config.reset() + yield + config.reset() + + +@pytest.fixture +def configured_env(monkeypatch, tmp_path): + """Just enough configuration for config.load(strict=False) to succeed.""" + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + monkeypatch.setenv("OSW_STATE_DIR", str(tmp_path / "state")) + config.reset() + + +@pytest.fixture +def runner(): + return CliRunner(mix_stderr=False) + + +def _fake_osw_with_page(exists=True): + page = MagicMock() + page.exists = exists + fake_osw = MagicMock() + fake_osw.site.get_page.return_value.pages = [page] + return fake_osw, page + + +# -- help works with no configuration present -------------------------------- +@pytest.mark.parametrize( + "args", + [["--help"], ["entity", "--help"], ["entity", "get", "--help"]], +) +def test_help_works_with_no_config_present(runner, args): + result = runner.invoke(app, args) + assert result.exit_code == 0, result.stderr + + +# -- lazy Context ------------------------------------------------------------- +def test_context_is_not_built_at_import_or_help_time(monkeypatch, runner): + """Building the app / answering --help must never construct a Context.""" + calls = [] + orig_init = cli_main.Context.__init__ + + def spy_init(self, *args, **kwargs): + calls.append((args, kwargs)) + return orig_init(self, *args, **kwargs) + + monkeypatch.setattr(cli_main.Context, "__init__", spy_init) + + result = runner.invoke(app, ["entity", "get", "--help"]) + + assert result.exit_code == 0 + assert calls == [] + + +# -- command tree --------------------------------------------------------------- +def test_every_cli_operation_is_registered_at_its_expected_path(): + click_app = typer.main.get_command(app) + for op in iter_operations(surface="cli"): + if op.group is None: + assert op.command in click_app.commands, op.command + else: + assert op.group in click_app.commands, op.group + group_cmd = click_app.commands[op.group] + assert op.command in group_cmd.commands, (op.group, op.command) + + +# -- successful command / rendering -------------------------------------------- +def test_successful_command_renders_to_stdout(runner, configured_env, monkeypatch): + fake_osw, page = _fake_osw_with_page() + page.get_slot_content.return_value = {"label": [{"text": "X"}]} + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + result = runner.invoke(app, ["entity", "get", "Item:OSW1"]) + + assert result.exit_code == 0, result.stderr + assert "Item:OSW1" in result.stdout + assert "exists" in result.stdout + + +def test_json_flag_emits_parseable_json(runner, configured_env, monkeypatch): + fake_osw, page = _fake_osw_with_page() + page.get_slot_content.return_value = {"label": [{"text": "X"}]} + page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + result = runner.invoke(app, ["--json", "entity", "get", "Item:OSW1"]) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload == { + "title": "Item:OSW1", + "exists": True, + "jsondata": {"label": [{"text": "X"}]}, + "url": "https://wiki.example.org/wiki/Item:OSW1", + "truncated": False, + } + + +# -- OpError exit codes / clean error output ------------------------------------ +def test_op_error_exits_with_its_exit_code_and_no_traceback(runner, configured_env): + result = runner.invoke(app, ["search", "sparql", "SELECT * WHERE {?s ?p ?o}"]) + + assert result.exit_code == 5 + assert result.stderr.strip() == ( + "NotConfigured: SPARQL endpoint not configured. Set " + "OSW_SPARQL_ENDPOINT or pass the 'endpoint' argument." + ) + assert "Traceback" not in result.stderr + assert "Traceback" not in result.stdout + + +# -- --read-only ---------------------------------------------------------------- +def test_read_only_blocks_a_write_command(runner, configured_env): + result = runner.invoke( + app, + [ + "--read-only", + "entity", + "put", + "Category:Item", + "--jsondata", + '{"label": [{"text": "x"}]}', + ], + ) + + assert result.exit_code == 4 + assert result.stderr.strip().startswith("ReadOnly:") + assert "Traceback" not in result.stderr + + +# -- set_slot's slot-dependent content coercion --------------------------------- +# `content` is typed Union[str, dict, list] in the core and typer cannot express +# a Union, so osw.cli.main coerces it after both arguments are known, consulting +# the sibling `slot` argument's content model. Both directions matter: a JSON +# slot given a raw string fails with InvalidContent, and a wikitext slot must not +# have "123" silently parsed into an int. +def test_set_slot_parses_content_for_a_json_slot(runner, configured_env, monkeypatch): + fake_osw, page = _fake_osw_with_page() + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + result = runner.invoke(app, ["slot", "set", "Item:OSW1", "jsondata", '{"a": 1}']) + + assert result.exit_code == 0, result.stderr + page.set_slot_content.assert_called_once_with("jsondata", {"a": 1}) + + +def test_set_slot_leaves_wikitext_content_a_string(runner, configured_env, monkeypatch): + fake_osw, page = _fake_osw_with_page() + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + result = runner.invoke(app, ["slot", "set", "Item:OSW1", "main", "123"]) + + assert result.exit_code == 0, result.stderr + page.set_slot_content.assert_called_once_with("main", "123") + + +# -- json_value ----------------------------------------------------------------- +def test_json_value_parses_a_literal(): + assert json_value('{"a": 1}') == {"a": 1} + + +def test_json_value_reads_a_file(tmp_path): + path = tmp_path / "data.json" + path.write_text('{"a": 1}', encoding="utf-8") + assert json_value(f"@{path}") == {"a": 1} + + +def test_json_value_rejects_malformed_json(): + with pytest.raises(typer.BadParameter): + json_value("not-json") + + +# -- render ----------------------------------------------------------------- +def test_render_json_is_parseable(): + result = {"a": 1, "b": [1, 2]} + assert json.loads(render(result, as_json=True)) == result + + +def test_render_title_list_prints_titles_and_footer(): + result = {"titles": ["Item:OSW1", "Item:OSW2"], "count": 2, "truncated": False} + rendered = render(result, as_json=False) + lines = rendered.splitlines() + assert lines[0] == "Item:OSW1" + assert lines[1] == "Item:OSW2" + assert "2" in lines[2] + + +def test_render_dict_shows_key_value_lines(): + result = {"title": "Item:OSW1", "exists": True} + rendered = render(result, as_json=False) + assert "title" in rendered + assert "Item:OSW1" in rendered + assert "exists" in rendered diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index 5fbca66..8ae2e60 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -3,6 +3,7 @@ These mock the shared connection so no network is required. """ +import asyncio from unittest.mock import MagicMock import pytest @@ -11,10 +12,15 @@ pytest.importorskip("mcp", reason="requires the osw[mcp] extra") pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") +from mcp.server import MCPServer + from osw.mcp import connection from osw.mcp.tools import entities, search, slots from osw.service import config +from osw.service.config import Settings +from osw.service.context import Context, Policy from osw.service.ops import entities as entity_ops +from osw.service.registry import REGISTRY, bind class FakeMCP: @@ -228,6 +234,24 @@ def test_create_or_update_entity_uses_active_domain(env, monkeypatch, tmp_path): assert result["urls"] == ["https://wiki-b.example.org/wiki/Item:OSW1"] +# -- jsondata's typer marker does not change the MCP schema ------------------ +def test_jsondata_schema_unchanged_by_cli_typer_marker(env): + """create_or_update_entity's jsondata carries a typer.Option marker (for + the CLI's JSON parser) since step 6 of the MCP/CLI migration; pydantic + ignores Annotated metadata it does not recognise, so the schema the MCP + SDK derives for it must still be a plain JSON-object schema.""" + op = REGISTRY["create_or_update_entity"] + ctx = Context(Settings(domain=None), Policy()) + mcp = MCPServer("test") + mcp.tool()(bind(op, ctx)) + + tools = asyncio.run(mcp.list_tools()) + tool = next(t for t in tools if t.name == "create_or_update_entity") + jsondata_schema = tool.input_schema["properties"]["jsondata"] + + assert jsondata_schema["type"] == "object" + + def test_run_guarded_converts_exceptions(env, monkeypatch): osw = MagicMock() osw.site.get_page.side_effect = RuntimeError("boom") diff --git a/uv.lock b/uv.lock index 3c52eb9..8bce090 100644 --- a/uv.lock +++ b/uv.lock @@ -2117,6 +2117,7 @@ dependencies = [ { name = "requests" }, { name = "sparqlwrapper" }, { name = "tqdm" }, + { name = "typer" }, { name = "typing-extensions" }, ] @@ -2234,6 +2235,7 @@ requires-dist = [ { name = "sparqlwrapper" }, { name = "sqlalchemy", marker = "extra == 'db'" }, { name = "tqdm" }, + { name = "typer" }, { name = "typing-extensions" }, ] provides-extras = ["wikitext", "db", "s3", "dataimport", "ui", "mcp", "workflow", "tutorial", "all"] From 4ed8c7cc4110da6eb591708a594c9255f9c1257a Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 25 Aug 2026 16:47:55 +0200 Subject: [PATCH 09/28] feat: remove filesystem paths from the MCP surface - add path-free get_file_info/read_file_text/write_file_text built on WikiFileController.get()/.put(), never touching the local filesystem - move file download/upload and ledger path to osw.cli.ops, the only module allowed to name a path (surfaces={"cli"}); drop status's ledger_path - guard tests: no MCP-surfaced op names a path, and osw.mcp.server never imports osw.cli - read_file_text decodes incrementally so a byte cap splitting a multi-byte character is not misreported as binary content --- src/osw/cli/main.py | 5 + src/osw/cli/ops.py | 145 +++++++++++++++++ src/osw/mcp/tools/files.py | 95 ++--------- src/osw/service/errors.py | 13 +- src/osw/service/ops/__init__.py | 2 +- src/osw/service/ops/files.py | 153 ++++++++++++++++++ src/osw/service/ops/status.py | 1 - tests/test_cli.py | 111 +++++++++++++ tests/test_no_paths_on_mcp_surface.py | 97 ++++++++++++ tests/test_service_ops_files.py | 219 ++++++++++++++++++++++++++ 10 files changed, 758 insertions(+), 83 deletions(-) create mode 100644 src/osw/cli/ops.py create mode 100644 src/osw/service/ops/files.py create mode 100644 tests/test_no_paths_on_mcp_surface.py create mode 100644 tests/test_service_ops_files.py diff --git a/src/osw/cli/main.py b/src/osw/cli/main.py index 4b8e0c8..2ef12f9 100644 --- a/src/osw/cli/main.py +++ b/src/osw/cli/main.py @@ -15,6 +15,11 @@ import typer +# Registers the CLI-only, path-taking operations (file download/upload, ledger +# path). Imported here -- and nowhere in osw.mcp -- so a path-taking operation +# can never reach the MCP registry. +import osw.cli.ops + # Registers every operation in osw.service.registry.REGISTRY as a side effect. import osw.service.ops # noqa: F401 from osw.service import config diff --git a/src/osw/cli/ops.py b/src/osw/cli/ops.py new file mode 100644 index 0000000..742a0c2 --- /dev/null +++ b/src/osw/cli/ops.py @@ -0,0 +1,145 @@ +"""CLI-only operations that name a filesystem path. + +This is the only module in the codebase allowed to do so: every operation +here declares ``surfaces=frozenset({"cli"})``, so none of it is ever visible +to ``iter_operations(surface="mcp")`` and the registry's path-name validator +never even runs against it (that validator only inspects the ``mcp`` +surface). A path argument is meaningful here because the CLI runs under the +invoking user's own shell permissions; it would be meaningless -- or a +filesystem escape hatch -- on an MCP client that may not share a host with +the server. + +Imported by ``osw.cli.main`` (and nowhere else) before the command-tree loop, +so these commands are registered without ``osw.mcp`` ever importing this +module. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Optional + +from osw.controller.file.wiki import WikiFileController +from osw.core import OverwriteOptions +from osw.service import errors +from osw.service.context import Context +from osw.service.ledger import LedgerRecord +from osw.service.registry import operation +from osw.utils.wiki import title_from_full_title +from osw.wtsite import WtSite + + +class _RenamedFile: + """Proxy over a file object that allows overriding its ``.name``. + + ``WikiFileController.put()`` derives the upload's suffix/label from + ``file.name``, but a real ``open()``-returned file object's ``.name`` (its + open-time path) is not a writable attribute. This proxy delegates + everything else to the wrapped file object. + """ + + def __init__(self, fh, name: str) -> None: + self._fh = fh + self.name = name + + def __getattr__(self, item): + return getattr(self._fh, item) + + +def _file_controller(ctx: Context, title: Optional[str] = None) -> WikiFileController: + """Build a ``WikiFileController``, optionally bound to a full title.""" + if title: + return WikiFileController( + osw=ctx.osw, title=title_from_full_title(title), namespace="File" + ) + return WikiFileController(osw=ctx.osw) + + +@operation( + group="file", + cli_name="download", + surfaces=frozenset({"cli"}), + read_only_hint=True, + idempotent_hint=True, +) +def download_file( + ctx: Context, + title: str, + target_dir: Optional[str] = None, + overwrite: bool = False, +) -> dict: + """Download a wiki file to the local filesystem. + + ``title`` is a full ``File:`` page title. Streams the file in chunks so a + large file never lands in memory at once. + """ + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + raise errors.NotFound(f"File '{title}' does not exist.") + + wf = _file_controller(ctx, title) + dest_dir = Path(target_dir) if target_dir else Path.cwd() + dest_dir.mkdir(parents=True, exist_ok=True) + dest_path = dest_dir / wf.title + if dest_path.exists() and not overwrite: + raise FileExistsError( + f"'{dest_path}' already exists. Pass --overwrite to replace it." + ) + stream = wf.get() + try: + with open(dest_path, "wb") as fh: + shutil.copyfileobj(stream, fh) + finally: + stream.close() + return {"title": title, "path": str(dest_path)} + + +@operation( + group="file", + cli_name="upload", + surfaces=frozenset({"cli"}), + writes=True, + destructive_hint=False, + idempotent_hint=True, + records=lambda r: [LedgerRecord(title=r["title"], op="create", slots=["jsondata"])], +) +def upload_file( + ctx: Context, + source_path: str, + target_title: Optional[str] = None, + name: Optional[str] = None, + overwrite: bool = True, +) -> dict: + """Upload a local file to the wiki as a WikiFile page. + + ``source_path`` is a path on the local disk. ``target_title`` is an + optional full ``File:`` page title (otherwise auto-generated). Records + the created page in the provenance ledger. + """ + src = Path(source_path) + if not src.is_file(): + raise errors.NotFound(f"Local file '{source_path}' does not exist.") + + wf = _file_controller(ctx, target_title) + overwrite_opt = OverwriteOptions.true if overwrite else OverwriteOptions.false + with open(src, "rb") as fh: + stream = _RenamedFile(fh, name or src.name) + wf.put(stream, overwrite=overwrite_opt) + + return { + "title": f"{wf.namespace}:{wf.title}", + "url": wf.url, + } + + +@operation( + group="ledger", + cli_name="path", + surfaces=frozenset({"cli"}), + read_only_hint=True, + idempotent_hint=True, +) +def ledger_path(ctx: Context) -> dict: + """Print the local path of the provenance ledger file for the active instance.""" + return {"path": str(ctx.ledger.path)} diff --git a/src/osw/mcp/tools/files.py b/src/osw/mcp/tools/files.py index 470b80d..d9cf71d 100644 --- a/src/osw/mcp/tools/files.py +++ b/src/osw/mcp/tools/files.py @@ -1,87 +1,22 @@ -"""File tools: download a file to local disk, upload a local file to the wiki.""" +"""File tools: path-free read/write of wiki file content. -from __future__ import annotations +No parameter on this surface may name a filesystem path (enforced by +``Operation``'s validator at import time); path-taking equivalents (download +to disk, upload from disk) are CLI-only, in ``osw.cli.ops``. +""" -from typing import Optional +from __future__ import annotations -from osw.core import OverwriteOptions +from osw.service.ops import files as _ops # noqa: F401 (registers the operations) +from osw.service.registry import bind, iter_operations -from ..connection import get_ledger, run_guarded +from .. import connection def register(mcp, *, include_writes: bool) -> None: - """Register file tools; the uploader only when ``include_writes``.""" - - @mcp.tool() - def download_file( - title_or_url: str, - target_dir: Optional[str] = None, - overwrite: bool = False, - ) -> dict: - """Download a WikiFile to the local disk. - - ``title_or_url`` is a ``File:`` full page title or a file URL. Writes only - to the local filesystem (no wiki mutation). Returns the local path. - """ - - def _run(osw): - result = osw.download_file( - title_or_url, target_dir=target_dir, overwrite=overwrite - ) - return { - "title": title_or_url, - "path": str(result.path) if result.path is not None else None, - } - - return run_guarded(_run) - - if not include_writes: - return - - @mcp.tool() - def upload_file( - source_path: str, - target_title: Optional[str] = None, - overwrite: bool = True, - name: Optional[str] = None, - ) -> dict: - """Upload a local file to the wiki as a WikiFile page. - - ``source_path`` is a path on the local disk. ``target_title`` is an - optional ``File:`` full page title (otherwise auto-generated). Records - the created page in the provenance ledger. - """ - ledger = get_ledger() - overwrite_opt = OverwriteOptions.true if overwrite else OverwriteOptions.false - - def _run(osw): - kwargs = {} - if name: - kwargs["name"] = name - result = osw.upload_file( - source=source_path, - url_or_title=target_title, - overwrite=overwrite_opt, - **kwargs, - ) - title = ( - getattr(result, "target_fpt", None) - or getattr(result, "url_or_title", None) - or getattr(result, "title", None) - ) - try: - url = result.get_url() - except Exception: - url = getattr(result, "url", None) - change_id = getattr(result, "change_id", None) - if title: - ledger.record( - title, - op="create", - tool="upload_file", - change_id=change_id, - slots=["jsondata"], - ) - return {"title": title, "url": url, "change_id": change_id} - - return run_guarded(_run) + """Register file tools; the writer only when ``include_writes``.""" + ctx = connection.legacy_context(include_writes=include_writes) + for op in iter_operations(surface="mcp", include_writes=include_writes): + if op.group != "file": + continue + mcp.tool()(bind(op, ctx)) diff --git a/src/osw/service/errors.py b/src/osw/service/errors.py index 790ed5b..3c8d5a1 100644 --- a/src/osw/service/errors.py +++ b/src/osw/service/errors.py @@ -13,7 +13,7 @@ * ``3`` -- invalid input: an argument is malformed, does not validate, or does not resolve (:class:`SchemaError`, :class:`ClassNotFound`, :class:`ValidationError`, :class:`UnknownInstance`, :class:`InvalidSlot`, - :class:`InvalidContent`, :class:`SlotMissing`). + :class:`InvalidContent`, :class:`SlotMissing`, :class:`BinaryContent`). * ``4`` -- refused/blocked: disallowed by a provenance or safety guard (:class:`ExternalDeleteBlocked`, :class:`ReadOnly`). * ``5`` -- not configured: required configuration is missing @@ -116,3 +116,14 @@ class SlotMissing(OpError): type = "SlotMissing" exit_code = 3 + + +class BinaryContent(OpError): + """A file's bytes do not decode under the requested text encoding. + + Raised by ``read_file_text`` when the requested file is not text; the mcp + surface cannot return raw bytes, so the caller must use the CLI instead. + """ + + type = "BinaryContent" + exit_code = 3 diff --git a/src/osw/service/ops/__init__.py b/src/osw/service/ops/__init__.py index a43776f..9c5c644 100644 --- a/src/osw/service/ops/__init__.py +++ b/src/osw/service/ops/__init__.py @@ -15,4 +15,4 @@ from __future__ import annotations -from . import entities, schema, search, slots, status +from . import entities, files, schema, search, slots, status diff --git a/src/osw/service/ops/files.py b/src/osw/service/ops/files.py new file mode 100644 index 0000000..50f31af --- /dev/null +++ b/src/osw/service/ops/files.py @@ -0,0 +1,153 @@ +"""Path-free wiki file content operations: info, read, write. + +``WikiFileController.get()`` returns a live stream and ``.put()`` accepts one +(see ``osw.controller.file.wiki``), so these operations never touch the local +filesystem: content moves between the wiki and the caller entirely in +memory, in bounded chunks. Path-taking counterparts (download to disk, upload +from disk) live in ``osw.cli.ops``, the only module allowed to name a path. +""" + +from __future__ import annotations + +import codecs +from io import BytesIO +from typing import Optional + +from osw.controller.file.wiki import WikiFileController +from osw.core import OverwriteOptions +from osw.service import errors +from osw.service.context import Context +from osw.service.ledger import LedgerRecord +from osw.service.registry import operation +from osw.utils.wiki import title_from_full_title +from osw.wtsite import WtSite + + +def _file_controller(ctx: Context, title: str) -> WikiFileController: + """Build a ``WikiFileController`` bound to ``title`` (a full ``File:`` title).""" + return WikiFileController( + osw=ctx.osw, title=title_from_full_title(title), namespace="File" + ) + + +@operation( + group="file", + cli_name="info", + read_only_hint=True, + idempotent_hint=True, +) +def get_file_info(ctx: Context, title: str) -> dict: + """Return a wiki file's metadata: url, existence, size and media type. + + ``title`` is a full ``File:`` page title. Reads only the headers of the + same download stream ``read_file_text`` uses; the file's content is + never pulled into memory. + """ + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + return { + "title": title, + "exists": False, + "url": None, + "size": None, + "media_type": None, + } + + wf = _file_controller(ctx, title) + stream = wf.get() + try: + size = stream.headers.get("Content-Length") + media_type = stream.headers.get("Content-Type") + finally: + stream.close() + return { + "title": title, + "exists": True, + "url": wf.url, + "size": int(size) if size is not None else None, + "media_type": media_type, + } + + +@operation( + group="file", + cli_name="cat", + read_only_hint=True, + idempotent_hint=True, +) +def read_file_text( + ctx: Context, title: str, encoding: str = "utf-8", limit: Optional[int] = None +) -> dict: + """Read a wiki file's content as text, returned inline in the result. + + Reads at most ``limit`` (or the server's configured max_chars) bytes plus + one, so an oversized file is never pulled fully into memory; truncation + is reported in the result rather than silently dropping content. If the + bytes do not decode under ``encoding``, use ``osw file download`` instead + to fetch the file to disk. + """ + page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0] + if not page.exists: + raise errors.NotFound(f"File '{title}' does not exist.") + + cap = limit if limit is not None else ctx.settings.max_chars + wf = _file_controller(ctx, title) + stream = wf.get() + try: + raw = stream.read(cap + 1) + finally: + stream.close() + truncated = len(raw) > cap + if truncated: + raw = raw[:cap] + try: + # Decoded incrementally, with final=False when the read was capped: + # `cap` counts bytes, so truncating can split a multi-byte character. + # A plain bytes.decode() would raise on that trailing fragment and a + # perfectly valid text file would be reported as binary. final=False + # buffers the fragment (and so discards it) while still raising on + # bytes that are genuinely undecodable. + content = codecs.getincrementaldecoder(encoding)().decode(raw, not truncated) + except UnicodeDecodeError as exc: + raise errors.BinaryContent( + f"File '{title}' is not valid {encoding} text; use " + "`osw file download` instead to fetch it to disk." + ) from exc + return { + "title": title, + "content": content, + "encoding": encoding, + "truncated": truncated, + } + + +@operation( + group="file", + cli_name="write", + writes=True, + destructive_hint=False, + idempotent_hint=True, + records=lambda r: [LedgerRecord(title=r["title"], op="create", slots=["jsondata"])], +) +def write_file_text( + ctx: Context, + title: str, + content: str, + name: Optional[str] = None, + overwrite: bool = True, +) -> dict: + """Write text content to a wiki file page, creating or overwriting it. + + ``title`` is a full ``File:`` page title. ``name`` sets the uploaded + file's base name (defaults to the bare filename portion of ``title``). + Records the page in the provenance ledger. + """ + wf = _file_controller(ctx, title) + stream = BytesIO(content.encode("utf-8")) + stream.name = name or title_from_full_title(title) + overwrite_opt = OverwriteOptions.true if overwrite else OverwriteOptions.false + wf.put(stream, overwrite=overwrite_opt) + return { + "title": f"{wf.namespace}:{wf.title}", + "url": wf.url, + } diff --git a/src/osw/service/ops/status.py b/src/osw/service/ops/status.py index 618f036..6c9536e 100644 --- a/src/osw/service/ops/status.py +++ b/src/osw/service/ops/status.py @@ -42,7 +42,6 @@ def status(ctx: Context) -> dict: ) return info ledger = ctx.ledger - info["ledger_path"] = str(ledger.path) info["ledger_entry_count"] = ledger.entry_count() info["osw_version"] = _osw_version() try: diff --git a/tests/test_cli.py b/tests/test_cli.py index b3e1a2a..06492b5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,7 @@ from __future__ import annotations +import io import json from unittest.mock import MagicMock @@ -17,6 +18,7 @@ import osw.cli.main as cli_main from osw.cli.main import app from osw.cli.render import render +from osw.core import OverwriteOptions from osw.service import config from osw.service.params import json_value from osw.service.registry import iter_operations @@ -249,3 +251,112 @@ def test_render_dict_shows_key_value_lines(): assert "title" in rendered assert "Item:OSW1" in rendered assert "exists" in rendered + + +# -- CLI-only path-taking file commands (osw.cli.ops) --------------------------- +# These are the only operations in the codebase allowed to name a path; they +# are exercised here rather than in tests/test_service_ops_files.py. +def test_download_file_writes_to_tmp_path( + runner, configured_env, monkeypatch, tmp_path +): + fake_osw, _page = _fake_osw_with_page(exists=True) + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + wf = MagicMock() + wf.title = "OSWabc123.txt" + wf.get.return_value = io.BytesIO(b"hello world") + monkeypatch.setattr("osw.cli.ops.WikiFileController", MagicMock(return_value=wf)) + + result = runner.invoke( + app, + ["file", "download", "File:OSWabc123.txt", "--target-dir", str(tmp_path)], + ) + + assert result.exit_code == 0, result.stderr + written = tmp_path / "OSWabc123.txt" + assert written.read_bytes() == b"hello world" + + +def test_download_file_missing_page_raises_not_found( + runner, configured_env, monkeypatch, tmp_path +): + fake_osw, _page = _fake_osw_with_page(exists=False) + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + result = runner.invoke( + app, + ["file", "download", "File:doesnotexist.txt", "--target-dir", str(tmp_path)], + ) + + assert result.exit_code == 2 # NotFound + assert "NotFound" in result.stderr + + +def test_upload_file_reads_from_tmp_path(runner, configured_env, monkeypatch, tmp_path): + fake_osw, _page = _fake_osw_with_page(exists=True) + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + src = tmp_path / "photo.png" + src.write_bytes(b"binarydata") + + wf = MagicMock() + wf.namespace = "File" + wf.title = "OSWxyz.png" + wf.url = "https://wiki.example.org/wiki/File:OSWxyz.png" + monkeypatch.setattr("osw.cli.ops.WikiFileController", MagicMock(return_value=wf)) + captured = {} + wf.put.side_effect = lambda stream, **kwargs: captured.update( + name=stream.name, content=stream.read(), kwargs=kwargs + ) + + result = runner.invoke(app, ["file", "upload", str(src)]) + + assert result.exit_code == 0, result.stderr + wf.put.assert_called_once() + assert captured["name"] == "photo.png" + assert captured["content"] == b"binarydata" + assert captured["kwargs"] == {"overwrite": OverwriteOptions.true} + + +def test_upload_file_honors_name_and_no_overwrite( + runner, configured_env, monkeypatch, tmp_path +): + fake_osw, _page = _fake_osw_with_page(exists=True) + monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw) + + src = tmp_path / "photo.png" + src.write_bytes(b"binarydata") + + wf = MagicMock() + wf.namespace = "File" + wf.title = "OSWxyz.png" + wf.url = "https://wiki.example.org/wiki/File:OSWxyz.png" + monkeypatch.setattr("osw.cli.ops.WikiFileController", MagicMock(return_value=wf)) + captured = {} + wf.put.side_effect = lambda stream, **kwargs: captured.update( + name=stream.name, kwargs=kwargs + ) + + result = runner.invoke( + app, + ["file", "upload", str(src), "--name", "renamed.png", "--no-overwrite"], + ) + + assert result.exit_code == 0, result.stderr + assert captured["name"] == "renamed.png" + assert captured["kwargs"] == {"overwrite": OverwriteOptions.false} + + +def test_upload_file_missing_source_raises_not_found(runner, configured_env, tmp_path): + result = runner.invoke(app, ["file", "upload", str(tmp_path / "nope.png")]) + + assert result.exit_code == 2 # NotFound + assert "NotFound" in result.stderr + + +# -- ledger path ------------------------------------------------------------------ +def test_ledger_path_prints_the_ledger_file_path(runner, configured_env): + result = runner.invoke(app, ["ledger", "path"]) + + assert result.exit_code == 0, result.stderr + assert "path" in result.stdout diff --git a/tests/test_no_paths_on_mcp_surface.py b/tests/test_no_paths_on_mcp_surface.py new file mode 100644 index 0000000..8d5eb07 --- /dev/null +++ b/tests/test_no_paths_on_mcp_surface.py @@ -0,0 +1,97 @@ +"""Guard tests: no filesystem path may ever reach the MCP surface. + +Runs in the plain dev env (no mcp extra needed): importing ``osw.cli.ops`` +(to register the CLI-only, path-taking operations, so the negative check +below cannot pass vacuously) and ``osw.service.ops`` touches neither the +``mcp`` SDK nor the network. Only ``test_mcp_server_never_imports_cli`` +needs the ``mcp`` extra (it imports ``osw.mcp.server`` itself), and +self-skips without it. +""" + +from __future__ import annotations + +import inspect +import subprocess +import sys +from unittest.mock import MagicMock + +import pytest + +# Registers every operation, including the CLI-only path-taking ones, so +# osw.service.registry.REGISTRY is fully populated for the checks below. +import osw.cli.ops +import osw.service.ops # noqa: F401 +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.registry import PATH_LIKE_NAMES, REGISTRY, bind, iter_operations + +_CLI_ONLY_PATH_OPS = {"download_file", "upload_file"} + + +def _params(fn): + """The op's parameters, minus ``ctx``.""" + return list(inspect.signature(fn).parameters.values())[1:] + + +def test_no_mcp_operation_names_a_path(): + mcp_ops = list(iter_operations(surface="mcp")) + assert mcp_ops, "expected at least one operation on the mcp surface" + + for op in mcp_ops: + offending = [p.name for p in _params(op.fn) if p.name in PATH_LIKE_NAMES] + assert not offending, f"{op.name}: path-like parameter(s) {offending}" + + # The assertion above must not pass vacuously: the CLI-only download/ + # upload operations DO name a path, and must NOT appear on the mcp + # surface. + mcp_names = {op.name for op in mcp_ops} + assert not (_CLI_ONLY_PATH_OPS & mcp_names) + + cli_ops_by_name = {op.name: op for op in iter_operations(surface="cli")} + for name in _CLI_ONLY_PATH_OPS: + assert name in cli_ops_by_name, f"expected {name!r} to be registered" + op = cli_ops_by_name[name] + param_names = {p.name for p in _params(op.fn)} + assert param_names & PATH_LIKE_NAMES, ( + f"{name}: expected at least one path-like parameter" + ) + assert "mcp" not in op.surfaces + + +def test_bound_operations_do_not_expose_ctx(): + ctx = Context( + Settings(domain="wiki.example.org", username="u", password="p"), + Policy(), + osw=MagicMock(), + ledger=MagicMock(), + ) + assert REGISTRY, "expected the registry to be populated" + for op in REGISTRY.values(): + bound = bind(op, ctx) + assert "ctx" not in inspect.signature(bound).parameters + + +def test_mcp_server_never_imports_cli(): + pytest.importorskip("mcp", reason="requires the osw[mcp] extra") + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys\n" + "import osw.mcp.server\n" + "leaked = [m for m in sys.modules if m == 'osw.cli' " + "or m.startswith('osw.cli.')]\n" + "print('LEAKED:' + ','.join(leaked) if leaked else 'CLEAN')\n", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + # Importing osw prints unrelated hints (e.g. about the wikitext extra) on + # stdout, so match the sentinel line rather than the whole stream. + sentinel = [ + line + for line in result.stdout.splitlines() + if line.startswith(("CLEAN", "LEAKED:")) + ] + assert sentinel == ["CLEAN"], result.stdout + result.stderr diff --git a/tests/test_service_ops_files.py b/tests/test_service_ops_files.py new file mode 100644 index 0000000..efe7d01 --- /dev/null +++ b/tests/test_service_ops_files.py @@ -0,0 +1,219 @@ +"""Unit tests for osw.service.ops.files (Operation.fn called directly). + +Runs in the plain dev env (no mcp extra, no network): ``WikiFileController`` +is replaced with a fake factory that records its constructor arguments, so +every test can inspect the title/namespace a real controller would have +derived without touching a wiki. +""" + +from unittest.mock import MagicMock + +import pytest + +from osw.core import OverwriteOptions +from osw.service import errors +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.ledger import LedgerRecord +from osw.service.ops import files +from osw.service.registry import REGISTRY + + +class _WfFactory: + """Stands in for ``WikiFileController``, recording every instance made. + + ``set_stream`` configures the ``.get()`` return value of instances made + *after* the call, mirroring how a real controller's stream only exists + once ``get()`` is invoked on it. + """ + + def __init__(self): + self.created: list = [] + self._stream = None + + def set_stream(self, stream) -> None: + self._stream = stream + + def __call__(self, **kwargs): + wf = MagicMock() + wf.namespace = kwargs.get("namespace") or "File" + wf.title = kwargs.get("title") + wf.url = f"https://wiki.example.org/wiki/{wf.namespace}:{wf.title}" + if self._stream is not None: + wf.get.return_value = self._stream + self.created.append(wf) + return wf + + +@pytest.fixture +def wf_factory(monkeypatch) -> _WfFactory: + """Replace ``files.WikiFileController`` with a fake, recording instances.""" + factory = _WfFactory() + monkeypatch.setattr(files, "WikiFileController", MagicMock(side_effect=factory)) + return factory + + +def _ctx() -> Context: + settings = Settings(domain="wiki.example.org", username="u", password="p") + return Context(settings, Policy(), osw=MagicMock(), ledger=MagicMock()) + + +def _set_page_exists(ctx: Context, exists: bool): + page = MagicMock() + page.exists = exists + ctx.osw.site.get_page.return_value.pages = [page] + return page + + +# -- get_file_info -------------------------------------------------------------- +def test_get_file_info_success(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, True) + stream = MagicMock() + stream.headers = {"Content-Length": "1234", "Content-Type": "image/png"} + wf_factory.set_stream(stream) + + result = files.get_file_info(ctx, "File:OSWabc123.png") + + wf = wf_factory.created[-1] + assert wf.title == "OSWabc123.png" + assert result == { + "title": "File:OSWabc123.png", + "exists": True, + "url": wf.url, + "size": 1234, + "media_type": "image/png", + } + stream.close.assert_called_once() + + +def test_get_file_info_missing(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, False) + + result = files.get_file_info(ctx, "File:doesnotexist.png") + + assert result == { + "title": "File:doesnotexist.png", + "exists": False, + "url": None, + "size": None, + "media_type": None, + } + assert wf_factory.created == [] # no controller built for a missing file + + +# -- read_file_text --------------------------------------------------------------- +def test_read_file_text_success(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, True) + stream = MagicMock() + stream.read.return_value = b"hello world" + wf_factory.set_stream(stream) + + result = files.read_file_text(ctx, "File:OSWabc.txt") + + assert result == { + "title": "File:OSWabc.txt", + "content": "hello world", + "encoding": "utf-8", + "truncated": False, + } + stream.read.assert_called_once_with(ctx.settings.max_chars + 1) + stream.close.assert_called_once() + + +def test_read_file_text_truncates(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, True) + stream = MagicMock() + stream.read.return_value = b"x" * 6 # cap + 1 bytes, cap == limit == 5 + wf_factory.set_stream(stream) + + result = files.read_file_text(ctx, "File:OSWabc.txt", limit=5) + + assert result["truncated"] is True + assert result["content"] == "x" * 5 + stream.read.assert_called_once_with(6) + + +def test_read_file_text_truncation_may_split_a_multibyte_character(wf_factory): + """A valid text file cut mid-character must not be reported as binary. + + ``limit`` counts bytes, so truncating can land inside a multi-byte + character. The incomplete trailing sequence is dropped; raising + BinaryContent here would tell the user to download a file that reads + perfectly well. + """ + ctx = _ctx() + _set_page_exists(ctx, True) + stream = MagicMock() + # 'ä' is two bytes starting at offset 9, so a cap of 10 splits it. + stream.read.return_value = ("a" * 9 + "ä" + "b" * 50).encode("utf-8")[:11] + wf_factory.set_stream(stream) + + result = files.read_file_text(ctx, "File:OSWabc.txt", limit=10) + + assert result["truncated"] is True + assert result["content"] == "a" * 9 + + +def test_read_file_text_missing_raises_not_found(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, False) + + with pytest.raises(errors.NotFound): + files.read_file_text(ctx, "File:doesnotexist.txt") + assert wf_factory.created == [] + + +def test_read_file_text_binary_raises_binary_content(wf_factory): + ctx = _ctx() + _set_page_exists(ctx, True) + stream = MagicMock() + stream.read.return_value = b"\xff\xfe\x00\x01" + wf_factory.set_stream(stream) + + with pytest.raises(errors.BinaryContent): + files.read_file_text(ctx, "File:OSWabc.bin") + + +# -- write_file_text ---------------------------------------------------------- +def test_write_file_text_success(wf_factory): + ctx = _ctx() + + result = files.write_file_text(ctx, "File:OSWabc.txt", "hello") + + wf = wf_factory.created[-1] + assert wf.title == "OSWabc.txt" + wf.put.assert_called_once() + (stream_arg,), put_kwargs = wf.put.call_args + assert stream_arg.read() == b"hello" + assert stream_arg.name == "OSWabc.txt" + assert put_kwargs == {"overwrite": OverwriteOptions.true} + + assert result == {"title": "File:OSWabc.txt", "url": wf.url} + + +def test_write_file_text_custom_name_and_no_overwrite(wf_factory): + ctx = _ctx() + + files.write_file_text( + ctx, "File:OSWabc.txt", "hello", name="renamed.txt", overwrite=False + ) + + wf = wf_factory.created[-1] + (stream_arg,), put_kwargs = wf.put.call_args + assert stream_arg.name == "renamed.txt" + assert put_kwargs == {"overwrite": OverwriteOptions.false} + + +def test_write_file_text_records_ledger_entry(): + op = REGISTRY["write_file_text"] + result = {"title": "File:OSWabc.txt", "url": "https://example.org/x"} + + records = op.records(result) + + assert records == [ + LedgerRecord(title="File:OSWabc.txt", op="create", slots=["jsondata"]) + ] From cb7d807a58c15152634858d0b781b39c46ad69bd Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 25 Aug 2026 17:21:57 +0200 Subject: [PATCH 10/28] feat(mcp): pin one instance per server, wire annotations and meta - delete list_instances/select_instance; each server process is pinned to one OSL instance and refuses to start if none resolves - map Operation's hints onto ToolAnnotations and _meta by explicit keyword, since the SDK silently absorbs a misspelled field name - set server instructions and version; make mcp.run(transport="stdio") explicit - add CLI --instance and osw instance list, returning iris only - retarget the "no instance selected" message at OSW_DOMAIN and --instance instead of the removed tool --- README.md | 25 +++--- src/osw/cli/main.py | 48 +++++++---- src/osw/cli/ops.py | 24 +++++- src/osw/mcp/connection.py | 11 +-- src/osw/mcp/registration.py | 73 +++++++++++++++++ src/osw/mcp/server.py | 34 +++++++- src/osw/mcp/tools/__init__.py | 5 +- src/osw/mcp/tools/entities.py | 3 +- src/osw/mcp/tools/files.py | 3 +- src/osw/mcp/tools/instances.py | 51 ------------ src/osw/mcp/tools/schema.py | 3 +- src/osw/mcp/tools/search.py | 3 +- src/osw/mcp/tools/slots.py | 3 +- src/osw/mcp/tools/status.py | 3 +- src/osw/service/context.py | 6 +- src/osw/service/ops/status.py | 5 +- tests/test_cli.py | 60 ++++++++++++++ tests/test_mcp_instances.py | 57 +------------ tests/test_mcp_registration.py | 142 +++++++++++++++++++++++++++++++++ tests/test_service_context.py | 3 +- 20 files changed, 401 insertions(+), 161 deletions(-) create mode 100644 src/osw/mcp/registration.py delete mode 100644 src/osw/mcp/tools/instances.py create mode 100644 tests/test_mcp_registration.py diff --git a/README.md b/README.md index 8015f2f..ecb849a 100644 --- a/README.md +++ b/README.md @@ -89,27 +89,24 @@ wiki-dev.open-semantic-lab.org: password: your-password ``` -**Multiple instances:** when the credential file holds more than one iri, the -server starts without an active instance and exposes two extra tools: +**One server per instance:** each server process is pinned to exactly one OSL +instance for its whole lifetime; there is no tool to switch at runtime. If no +instance resolves at startup (a configured `OSW_DOMAIN`, or a credential file +holding exactly one iri), the server refuses to start rather than register +tools that would all fail. -- `list_instances` returns the available iris, never any credential -- `select_instance(iri)` switches to one, rebuilding the connection and the - provenance ledger, which is kept separate per domain - -If `OSW_DOMAIN` is set, or the file holds exactly one iri, that instance is -selected automatically and neither tool needs to be called. Until an instance is -active the other tools return "No OSL instance selected". `status` reports which -one is active. - -Registering the server once per instance works too, and has the advantage that -the instance is visible in the tool name at every call site, with read-only -settable per instance: +To work with more than one instance, register a separate server per instance, +each with its own env file. This also has the advantage that the instance is +visible in the tool name at every call site, with read-only settable per +instance: ```bash claude mcp add osw-dev --env OSW_MCP_ENV_FILE=/abs/path/dev.env -- uvx --from "osw[mcp]" osw-mcp claude mcp add osw-prod --env OSW_MCP_ENV_FILE=/abs/path/prod.env --env OSW_MCP_READ_ONLY=true -- uvx --from "osw[mcp]" osw-mcp ``` +`status` reports the active instance and connection state (never the password). + Register it with Claude Code (reference the `.env` via `OSW_MCP_ENV_FILE`; do not put `OSW_PASSWORD` inline in a committed `.mcp.json`): diff --git a/src/osw/cli/main.py b/src/osw/cli/main.py index 2ef12f9..12ca1c4 100644 --- a/src/osw/cli/main.py +++ b/src/osw/cli/main.py @@ -11,7 +11,7 @@ from __future__ import annotations import inspect -from typing import Any, get_type_hints +from typing import Any, Optional, get_type_hints import typer @@ -22,7 +22,7 @@ # Registers every operation in osw.service.registry.REGISTRY as a side effect. import osw.service.ops # noqa: F401 -from osw.service import config +from osw.service import config, errors from osw.service.context import Context, Policy from osw.service.errors import OpError from osw.service.params import json_value @@ -37,6 +37,12 @@ @app.callback() def _callback( ctx: typer.Context, + instance: Optional[str] = typer.Option( + None, + "--instance", + help="Iri of the OSL instance to use for this command, when more " + "than one is configured (e.g. via a credential file).", + ), as_json: bool = typer.Option( False, "--json", "-j", help="Emit machine-readable JSON on stdout." ), @@ -50,10 +56,16 @@ def _callback( """osw: command-line access to an OpenSemanticLab (OSW) instance. Connection settings and credentials come from the environment or a - .env file (see ``osw.service.config``); no instance selection option is - exposed here yet. + .env file (see ``osw.service.config``). Pass --instance to pick which + configured instance this invocation talks to; unlike the MCP server, the + CLI is stateless, so the choice only applies to this one command. """ - ctx.obj = {"as_json": as_json, "read_only": read_only, "verbose": verbose} + ctx.obj = { + "instance": instance, + "as_json": as_json, + "read_only": read_only, + "verbose": verbose, + } def _op_params(op: Operation) -> list[inspect.Parameter]: @@ -100,17 +112,23 @@ def _run(op: Operation, typer_ctx: typer.Context, kwargs: dict[str, Any]) -> Non if content_model == "json" and isinstance(content, str): kwargs["content"] = json_value(content) - settings = config.load(strict=False) - policy = Policy( - capture_stdout=bool(opts.get("as_json")), - errors_as_dicts=False, - allow_writes=not opts.get("read_only"), - allow_interactive=True, - ) - context = Context(settings, policy) - bound = bind(op, context) - try: + instance = opts.get("instance") + if instance: + try: + config.set_active_instance(instance) + except ValueError as exc: + raise errors.UnknownInstance(str(exc)) from exc + + settings = config.load(strict=False) + policy = Policy( + capture_stdout=bool(opts.get("as_json")), + errors_as_dicts=False, + allow_writes=not opts.get("read_only"), + allow_interactive=True, + ) + context = Context(settings, policy) + bound = bind(op, context) result = bound(**kwargs) except OpError as exc: typer.echo(f"{exc.type}: {exc}", err=True) diff --git a/src/osw/cli/ops.py b/src/osw/cli/ops.py index 742a0c2..665cbf4 100644 --- a/src/osw/cli/ops.py +++ b/src/osw/cli/ops.py @@ -22,7 +22,7 @@ from osw.controller.file.wiki import WikiFileController from osw.core import OverwriteOptions -from osw.service import errors +from osw.service import config, errors from osw.service.context import Context from osw.service.ledger import LedgerRecord from osw.service.registry import operation @@ -143,3 +143,25 @@ def upload_file( def ledger_path(ctx: Context) -> dict: """Print the local path of the provenance ledger file for the active instance.""" return {"path": str(ctx.ledger.path)} + + +@operation( + group="instance", + cli_name="list", + surfaces=frozenset({"cli"}), + read_only_hint=True, + idempotent_hint=True, +) +def list_instances(ctx: Context) -> dict: + """List the OSL instances this process can connect to. + + Reports the iris available from the env-configured domain and/or a + configured credential file, and which one (if any) is currently active + for this invocation (see --instance). Never returns usernames, passwords, + or any other credential value. + """ + return { + "iris": config.available_iris(), + "active_iri": config.get_active_iri(), + "active_domain": config.get_active_domain(), + } diff --git a/src/osw/mcp/connection.py b/src/osw/mcp/connection.py index 7b80e8f..def612c 100644 --- a/src/osw/mcp/connection.py +++ b/src/osw/mcp/connection.py @@ -35,8 +35,9 @@ def _require_active_domain() -> str: if domain is None: available = ", ".join(config.available_iris()) or "(none)" raise RuntimeError( - "No OSL instance selected. Call select_instance first; " - f"available: {available}." + "No OSL instance selected. For a server process, set OSW_DOMAIN " + "(or OSW_ENV_FILE to point at a .env file that sets it); for the " + f"CLI, pass --instance . Available: {available}." ) return domain @@ -140,11 +141,7 @@ def legacy_context(*, include_writes: bool = True) -> _LegacyContext: def reset() -> None: - """Drop the shared connection and ledger so the next call rebuilds them. - - Called after switching the active instance (``select_instance``) so a - stale connection or a ledger keyed on the previous domain is never reused. - """ + """Drop the shared connection and ledger so the next call rebuilds them.""" global _osw, _ledger with _LOCK: if _osw is not None: diff --git a/src/osw/mcp/registration.py b/src/osw/mcp/registration.py new file mode 100644 index 0000000..46f10c6 --- /dev/null +++ b/src/osw/mcp/registration.py @@ -0,0 +1,73 @@ +"""Mapping from :class:`~osw.service.registry.Operation` metadata onto the +keyword arguments the mcp SDK's ``mcp.tool(...)`` decorator expects. + +Kept in its own module rather than in :mod:`osw.mcp.server`: ``server.py`` +imports ``tools/``, so ``tools/*.py`` importing back from ``server.py`` would +be circular. Once ``tools/`` is folded into ``server.py``, this module folds +in too. +""" + +from __future__ import annotations + +import inspect +from typing import Any, Optional + +from mcp.types import ToolAnnotations + +from osw.service.config import Settings +from osw.service.registry import Operation + + +def _annotations(op: Operation) -> Optional[ToolAnnotations]: + """Build ``ToolAnnotations`` from ``op``'s four hints. + + Returns ``None`` when every hint is unset, so a hint-less operation gets + no ``annotations`` at all rather than an all-``None`` object. + + Built by explicit keyword, never ``**dict``: passing an unrecognized + keyword to ``ToolAnnotations`` (verified empirically against the + installed mcp SDK) is silently dropped rather than raising, so a + misspelled field name would otherwise fail with no error and leave the + hint permanently ``None``. + """ + hints = ( + op.read_only_hint, + op.destructive_hint, + op.idempotent_hint, + op.open_world_hint, + ) + if all(hint is None for hint in hints): + return None + return ToolAnnotations( + read_only_hint=op.read_only_hint, + destructive_hint=op.destructive_hint, + idempotent_hint=op.idempotent_hint, + open_world_hint=op.open_world_hint, + ) + + +def _meta(op: Operation, settings: Settings) -> dict[str, Any]: + """Build the MCP ``_meta`` dict for ``op``. + + ``anthropic/maxResultSizeChars`` always has a value: ``op``'s own limit + if it declares one, else the server-wide default. ``requiresUserInteraction`` + is only present (and only ever ``True``) for operations that declare it. + ``op.extra_meta`` is merged last, so it can override either key. + """ + meta: dict[str, Any] = { + "anthropic/maxResultSizeChars": op.max_result_size_chars or settings.max_chars, + } + if op.requires_user_interaction: + meta["anthropic/requiresUserInteraction"] = True + meta.update(op.extra_meta) + return meta + + +def tool_kwargs(op: Operation, settings: Settings) -> dict[str, Any]: + """Keyword arguments for ``mcp.tool(...)`` for one operation.""" + return { + "name": op.name, + "description": inspect.getdoc(op.fn), + "annotations": _annotations(op), + "meta": _meta(op, settings), + } diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py index 0dceead..6ea7b5d 100644 --- a/src/osw/mcp/server.py +++ b/src/osw/mcp/server.py @@ -12,20 +12,50 @@ from mcp.server import MCPServer +import osw from osw.service import config from . import connection from .tools import register_all +INSTRUCTIONS = """\ +This server is pinned to exactly one OpenSemanticLab (OSL) instance for its +whole process lifetime; there is no tool to switch instances. Run one server +process per instance (a separate registration, its own env file) if you need +more than one. + +Entity and page titles are full MediaWiki page names, e.g. "Item:OSW1234...", +never a bare id or label. + +Before creating or updating an entity, fetch its category's JSON Schema +(get_category_schema) so the written jsondata validates against it. + +This server has no filesystem access: file content moves inline as text, not +as a path. For anything path-based (uploading/downloading a local file, the +provenance ledger's path), use the `osw` CLI instead. +""" + def create_server() -> MCPServer: """Build the MCPServer, registering tools per the read-only setting. Loads and validates settings first so a missing-credential misconfiguration fails fast (before any osw call that could trigger an interactive prompt). + Also fails fast if no instance resolves: this server is statically pinned + to one OSL instance for its whole lifetime, so registering tools that + would all fail at call time would be actively misleading. """ settings = config.get_settings() - mcp = MCPServer("osw") + domain = config.get_active_domain() + if domain is None: + available = ", ".join(config.available_iris()) or "(none)" + raise RuntimeError( + "No OSL instance resolved. Set OSW_DOMAIN (or OSW_ENV_FILE to " + "point at a .env file that sets it), or configure a credential " + "file (OSW_CRED_FILEPATH) holding exactly one iri. " + f"Available: {available}." + ) + mcp = MCPServer("osw", instructions=INSTRUCTIONS, version=osw.__version__) register_all(mcp, include_writes=not settings.read_only) return mcp @@ -40,7 +70,7 @@ def main() -> None: atexit.register(connection.shutdown) try: - mcp.run() # defaults to stdio transport + mcp.run(transport="stdio") finally: connection.shutdown() diff --git a/src/osw/mcp/tools/__init__.py b/src/osw/mcp/tools/__init__.py index f67d9aa..d7e782b 100644 --- a/src/osw/mcp/tools/__init__.py +++ b/src/osw/mcp/tools/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from . import entities, files, instances, schema, search, slots, status +from . import entities, files, schema, search, slots, status def register_all(mcp, *, include_writes: bool) -> None: @@ -10,8 +10,6 @@ def register_all(mcp, *, include_writes: bool) -> None: Mutating tools (create/update/delete/upload/set_slot) are only registered when ``include_writes`` is true, so a read-only server never exposes them. - Instance-selection tools are always registered: they change server-local - state (which OSL instance subsequent calls talk to), not wiki content. """ search.register(mcp) schema.register(mcp) @@ -19,4 +17,3 @@ def register_all(mcp, *, include_writes: bool) -> None: files.register(mcp, include_writes=include_writes) slots.register(mcp, include_writes=include_writes) status.register(mcp) - instances.register(mcp) diff --git a/src/osw/mcp/tools/entities.py b/src/osw/mcp/tools/entities.py index b09aa58..2d4c038 100644 --- a/src/osw/mcp/tools/entities.py +++ b/src/osw/mcp/tools/entities.py @@ -6,6 +6,7 @@ from osw.service.registry import bind, iter_operations from .. import connection +from ..registration import tool_kwargs def register(mcp, *, include_writes: bool) -> None: @@ -14,4 +15,4 @@ def register(mcp, *, include_writes: bool) -> None: for op in iter_operations(surface="mcp", include_writes=include_writes): if op.group != "entity": continue - mcp.tool()(bind(op, ctx)) + mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/mcp/tools/files.py b/src/osw/mcp/tools/files.py index d9cf71d..2657097 100644 --- a/src/osw/mcp/tools/files.py +++ b/src/osw/mcp/tools/files.py @@ -11,6 +11,7 @@ from osw.service.registry import bind, iter_operations from .. import connection +from ..registration import tool_kwargs def register(mcp, *, include_writes: bool) -> None: @@ -19,4 +20,4 @@ def register(mcp, *, include_writes: bool) -> None: for op in iter_operations(surface="mcp", include_writes=include_writes): if op.group != "file": continue - mcp.tool()(bind(op, ctx)) + mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/mcp/tools/instances.py b/src/osw/mcp/tools/instances.py deleted file mode 100644 index b271524..0000000 --- a/src/osw/mcp/tools/instances.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Instance selection tools: list and switch between configured OSL instances. - -A server can be configured with several candidate instances (an env-configured -domain and/or the iris in a credential file, see :mod:`osw.service.config`). These -tools let the model discover the available instances and pick which one -subsequent tool calls talk to. Registered unconditionally, not gated on -``include_writes``: they change server-local state, not wiki content. -""" - -from __future__ import annotations - -from osw.service import config - -from .. import connection - - -def register(mcp) -> None: - """Register the instance-selection tools on ``mcp``.""" - - @mcp.tool() - def list_instances() -> dict: - """List the OSL instances this server can connect to. - - Reports the iris available from the env-configured domain and/or a - configured credential file, and which one (if any) is currently - active. Never returns usernames, passwords, or any credential value. - """ - return { - "iris": config.available_iris(), - "active_iri": config.get_active_iri(), - "active_domain": config.get_active_domain(), - } - - @mcp.tool() - def select_instance(iri: str) -> dict: - """Select the OSL instance subsequent tool calls should talk to. - - ``iri`` must be one of the iris returned by ``list_instances``. - Rebuilds the shared connection and provenance ledger so a stale - instance is never reused, but does not connect eagerly; the next - tool call connects to the newly selected instance. - """ - try: - config.set_active_instance(iri) - except ValueError as exc: - return {"error": str(exc), "type": "UnknownInstance"} - connection.reset() - return { - "active_iri": config.get_active_iri(), - "active_domain": config.get_active_domain(), - } diff --git a/src/osw/mcp/tools/schema.py b/src/osw/mcp/tools/schema.py index e6d64b0..3d3456d 100644 --- a/src/osw/mcp/tools/schema.py +++ b/src/osw/mcp/tools/schema.py @@ -7,6 +7,7 @@ from osw.service.registry import bind, iter_operations from .. import connection +from ..registration import tool_kwargs def register(mcp) -> None: @@ -15,4 +16,4 @@ def register(mcp) -> None: for op in iter_operations(surface="mcp"): if op.group != "schema": continue - mcp.tool()(bind(op, ctx)) + mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/mcp/tools/search.py b/src/osw/mcp/tools/search.py index 6adfd2a..4ef91f3 100644 --- a/src/osw/mcp/tools/search.py +++ b/src/osw/mcp/tools/search.py @@ -6,6 +6,7 @@ from osw.service.registry import bind, iter_operations from .. import connection +from ..registration import tool_kwargs def register(mcp) -> None: @@ -14,4 +15,4 @@ def register(mcp) -> None: for op in iter_operations(surface="mcp"): if op.group != "search": continue - mcp.tool()(bind(op, ctx)) + mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/mcp/tools/slots.py b/src/osw/mcp/tools/slots.py index 6baa02e..71c8cd0 100644 --- a/src/osw/mcp/tools/slots.py +++ b/src/osw/mcp/tools/slots.py @@ -12,6 +12,7 @@ from osw.service.registry import bind, iter_operations from .. import connection +from ..registration import tool_kwargs def register(mcp, *, include_writes: bool) -> None: @@ -20,4 +21,4 @@ def register(mcp, *, include_writes: bool) -> None: for op in iter_operations(surface="mcp", include_writes=include_writes): if op.group != "slot": continue - mcp.tool()(bind(op, ctx)) + mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/mcp/tools/status.py b/src/osw/mcp/tools/status.py index e758b7b..e2c9d58 100644 --- a/src/osw/mcp/tools/status.py +++ b/src/osw/mcp/tools/status.py @@ -6,6 +6,7 @@ from osw.service.registry import bind, iter_operations from .. import connection +from ..registration import tool_kwargs _NAMES = ("status",) @@ -16,4 +17,4 @@ def register(mcp) -> None: for op in iter_operations(surface="mcp"): if op.name not in _NAMES: continue - mcp.tool()(bind(op, ctx)) + mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/service/context.py b/src/osw/service/context.py index d0d7162..9646637 100644 --- a/src/osw/service/context.py +++ b/src/osw/service/context.py @@ -66,8 +66,10 @@ def _require_active_domain(self) -> str: if domain is None: available = ", ".join(config.available_iris()) or "(none)" raise errors.NotConfigured( - "No OSL instance selected. Call select_instance first; " - f"available: {available}." + "No OSL instance selected. For a server process, set " + "OSW_DOMAIN (or OSW_ENV_FILE to point at a .env file that " + "sets it); for the CLI, pass --instance . " + f"Available: {available}." ) return domain diff --git a/src/osw/service/ops/status.py b/src/osw/service/ops/status.py index 6c9536e..90073bb 100644 --- a/src/osw/service/ops/status.py +++ b/src/osw/service/ops/status.py @@ -37,8 +37,9 @@ def status(ctx: Context) -> dict: available = ", ".join(config.available_iris()) or "(none)" info["connected"] = False info["message"] = ( - "No OSL instance selected. Call select_instance to choose " - f"one; available: {available}." + "No OSL instance selected. For a server process, set OSW_DOMAIN " + "(or OSW_ENV_FILE to point at a .env file that sets it); for the " + f"CLI, pass --instance . Available: {available}." ) return info ledger = ctx.ledger diff --git a/tests/test_cli.py b/tests/test_cli.py index 06492b5..ba4662e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,6 +13,7 @@ import pytest import typer +import yaml from typer.testing import CliRunner import osw.cli.main as cli_main @@ -360,3 +361,62 @@ def test_ledger_path_prints_the_ledger_file_path(runner, configured_env): assert result.exit_code == 0, result.stderr assert "path" in result.stdout + + +# -- instance list / --instance --------------------------------------------------- +def test_instance_list_never_leaks_credentials(runner, monkeypatch, tmp_path): + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({ + "wiki-a.example.org": {"username": "alice", "password": "supersecret"}, + }), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + config.reset() + + result = runner.invoke(app, ["instance", "list"]) + + assert result.exit_code == 0, result.stderr + assert "wiki-a.example.org" in result.stdout + assert "supersecret" not in result.stdout + assert "alice" not in result.stdout + + +def test_instance_flag_sets_active_instance(runner, monkeypatch, tmp_path): + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({ + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + config.reset() + + result = runner.invoke( + app, ["--instance", "wiki-b.example.org", "--json", "instance", "list"] + ) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["active_iri"] == "wiki-b.example.org" + assert payload["active_domain"] == "wiki-b.example.org" + + +def test_instance_flag_unknown_iri_exits_cleanly(runner, monkeypatch, tmp_path): + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({"wiki-a.example.org": {"username": "a", "password": "b"}}), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file)) + config.reset() + + result = runner.invoke(app, ["--instance", "nope.example.org", "instance", "list"]) + + assert result.exit_code == 3 # UnknownInstance + assert result.stderr.strip().startswith("UnknownInstance:") + assert "wiki-a.example.org" in result.stderr + assert "Traceback" not in result.stderr diff --git a/tests/test_mcp_instances.py b/tests/test_mcp_instances.py index 1f8c05b..e208eca 100644 --- a/tests/test_mcp_instances.py +++ b/tests/test_mcp_instances.py @@ -1,4 +1,4 @@ -"""Unit tests for multi-instance selection in osw.mcp (config + connection + tools). +"""Unit tests for multi-instance selection in osw.mcp (config + connection). These are fully offline: no network, no live wiki. """ @@ -10,7 +10,6 @@ pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") from osw.mcp import connection -from osw.mcp.tools import instances from osw.service import config _ALL_VARS = [ @@ -131,60 +130,6 @@ def test_set_active_instance_unknown_iri_raises(monkeypatch, tmp_path): assert "wiki-a.example.org" in str(exc.value) -def test_select_instance_tool_sets_active(monkeypatch, tmp_path): - cred_file = _write_cred_file( - tmp_path / "accounts.yaml", - { - "wiki-a.example.org": {"username": "a", "password": "b"}, - "wiki-b.example.org": {"username": "c", "password": "d"}, - }, - ) - monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) - fake = FakeMCP() - instances.register(fake) - - result = fake.tools["select_instance"](iri="wiki-b.example.org") - - assert result["active_iri"] == "wiki-b.example.org" - assert result["active_domain"] == "wiki-b.example.org" - assert config.get_active_iri() == "wiki-b.example.org" - - -def test_select_instance_tool_unknown_iri_returns_error(monkeypatch, tmp_path): - cred_file = _write_cred_file( - tmp_path / "accounts.yaml", - {"wiki-a.example.org": {"username": "a", "password": "b"}}, - ) - monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) - fake = FakeMCP() - instances.register(fake) - - result = fake.tools["select_instance"](iri="nope.example.org") - - assert result["type"] == "UnknownInstance" - assert "wiki-a.example.org" in result["error"] - - -# -- list_instances never leaks credentials --------------------------------- -def test_list_instances_never_leaks_credentials(monkeypatch, tmp_path): - cred_file = _write_cred_file( - tmp_path / "accounts.yaml", - { - "wiki-a.example.org": {"username": "alice", "password": "supersecret"}, - }, - ) - monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) - fake = FakeMCP() - instances.register(fake) - - result = fake.tools["list_instances"]() - - assert result["iris"] == ["wiki-a.example.org"] - assert result["active_iri"] == "wiki-a.example.org" - assert "supersecret" not in str(result) - assert "alice" not in str(result) - - # -- get_osw() / run_guarded without an active instance ---------------------- def test_get_osw_raises_when_no_instance_selected(monkeypatch, tmp_path): cred_file = _write_cred_file( diff --git a/tests/test_mcp_registration.py b/tests/test_mcp_registration.py new file mode 100644 index 0000000..91d8080 --- /dev/null +++ b/tests/test_mcp_registration.py @@ -0,0 +1,142 @@ +"""Unit tests for osw.mcp.registration (Operation -> mcp.tool() kwargs). + +These are fully offline: no network, no live wiki. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("mcp", reason="requires the osw[mcp] extra") + +from mcp.types import ToolAnnotations + +from osw.mcp.registration import _annotations, _meta, tool_kwargs +from osw.service.config import Settings +from osw.service.registry import Operation + + +def _op(**kwargs) -> Operation: + def fn(ctx) -> dict: + """A test operation.""" + return {} + + fields = {"name": "an_op", "fn": fn, **kwargs} + return Operation(**fields) + + +def _settings(**kwargs) -> Settings: + fields = {"domain": "wiki.example.org", **kwargs} + return Settings(**fields) + + +# -- _annotations ------------------------------------------------------------- +def test_annotations_maps_every_hint_onto_its_named_field(): + op = _op( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, + ) + + annotations = _annotations(op) + + assert isinstance(annotations, ToolAnnotations) + # Assert on the real attributes (not a dict), so a misspelled field name + # in _annotations -- silently absorbed by ToolAnnotations' extra-field + # tolerance -- leaves these ``None`` and the test fails. + assert annotations.read_only_hint is True + assert annotations.destructive_hint is False + assert annotations.idempotent_hint is True + assert annotations.open_world_hint is False + + +def test_annotations_none_when_no_hint_is_set(): + op = _op() + + assert _annotations(op) is None + + +# -- _meta ---------------------------------------------------------------------- +def test_meta_falls_back_to_settings_max_chars(): + op = _op() + settings = _settings(max_chars=12_345) + + meta = _meta(op, settings) + + assert meta["anthropic/maxResultSizeChars"] == 12_345 + assert "anthropic/requiresUserInteraction" not in meta + + +def test_meta_honours_op_max_result_size_chars(): + op = _op(max_result_size_chars=999) + settings = _settings(max_chars=12_345) + + meta = _meta(op, settings) + + assert meta["anthropic/maxResultSizeChars"] == 999 + + +def test_meta_sets_requires_user_interaction_only_when_declared(): + plain = _meta(_op(), _settings()) + interactive = _meta(_op(requires_user_interaction=True), _settings()) + + assert "anthropic/requiresUserInteraction" not in plain + assert interactive["anthropic/requiresUserInteraction"] is True + + +def test_meta_extra_meta_merges_last(): + op = _op(extra_meta={"anthropic/maxResultSizeChars": 1, "custom": "x"}) + settings = _settings(max_chars=100) + + meta = _meta(op, settings) + + assert meta["anthropic/maxResultSizeChars"] == 1 + assert meta["custom"] == "x" + + +# -- tool_kwargs ------------------------------------------------------------------ +def test_tool_kwargs_uses_name_and_docstring(): + op = _op() + settings = _settings() + + kwargs = tool_kwargs(op, settings) + + assert kwargs["name"] == "an_op" + assert kwargs["description"] == "A test operation." + assert kwargs["annotations"] is None + assert "anthropic/maxResultSizeChars" in kwargs["meta"] + + +# -- no instance-switching tools on the registered server ------------------------ +def test_registered_server_exposes_no_instance_switching_tools(monkeypatch): + from osw.mcp.tools import register_all + from osw.service import config + + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + config.reset() + + class FakeMCP: + def __init__(self): + self.names = [] + + def tool(self, *_args, **kwargs): + def deco(fn): + self.names.append(kwargs.get("name") or fn.__name__) + return fn + + return deco + + fake = FakeMCP() + try: + register_all(fake, include_writes=True) + finally: + config.reset() + + # Assert something WAS registered first: the two absence checks below + # would otherwise pass on an empty list. + assert "get_entity" in fake.names + assert "list_instances" not in fake.names + assert "select_instance" not in fake.names diff --git a/tests/test_service_context.py b/tests/test_service_context.py index c089b56..63f89a2 100644 --- a/tests/test_service_context.py +++ b/tests/test_service_context.py @@ -107,7 +107,8 @@ def test_osw_property_raises_not_configured_when_no_active_domain( with pytest.raises(errors.NotConfigured) as exc_info: _ = ctx.osw - assert "select_instance" in str(exc_info.value) + assert "OSW_DOMAIN" in str(exc_info.value) + assert "--instance" in str(exc_info.value) # -- limit ---------------------------------------------------------------- From b6fc0c0f1c6ec2fe4c2481105dd84d859669484b Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 26 Aug 2026 15:34:42 +0200 Subject: [PATCH 11/28] refactor(mcp): drop tool closures, fold registration into server - delete osw/mcp/tools/ and connection.py; bodies live in osw.service.ops - fold registration.py into server.py, its sole consumer - replace test_mcp_tools.py with test_service_ops.py + test_mcp_server.py - rebuild the integration fixture on bind()/iter_operations - test_mcp_instances.py -> test_service_instances.py, no longer SDK-gated --- src/osw/mcp/connection.py | 159 ----------- src/osw/mcp/registration.py | 73 ----- src/osw/mcp/server.py | 93 +++++- src/osw/mcp/tools/__init__.py | 19 -- src/osw/mcp/tools/entities.py | 18 -- src/osw/mcp/tools/files.py | 23 -- src/osw/mcp/tools/schema.py | 19 -- src/osw/mcp/tools/search.py | 18 -- src/osw/mcp/tools/slots.py | 24 -- src/osw/mcp/tools/status.py | 20 -- src/osw/service/context.py | 8 +- tests/integration/test_mcp_server.py | 50 ++-- tests/test_mcp_registration.py | 43 +-- tests/test_mcp_server.py | 147 ++++++++++ tests/test_mcp_tools.py | 265 ------------------ tests/test_service_errors.py | 2 +- ...instances.py => test_service_instances.py} | 60 ++-- tests/test_service_ops.py | 96 +++++++ 18 files changed, 384 insertions(+), 753 deletions(-) delete mode 100644 src/osw/mcp/connection.py delete mode 100644 src/osw/mcp/registration.py delete mode 100644 src/osw/mcp/tools/__init__.py delete mode 100644 src/osw/mcp/tools/entities.py delete mode 100644 src/osw/mcp/tools/files.py delete mode 100644 src/osw/mcp/tools/schema.py delete mode 100644 src/osw/mcp/tools/search.py delete mode 100644 src/osw/mcp/tools/slots.py delete mode 100644 src/osw/mcp/tools/status.py create mode 100644 tests/test_mcp_server.py delete mode 100644 tests/test_mcp_tools.py rename tests/{test_mcp_instances.py => test_service_instances.py} (86%) create mode 100644 tests/test_service_ops.py diff --git a/src/osw/mcp/connection.py b/src/osw/mcp/connection.py deleted file mode 100644 index def612c..0000000 --- a/src/osw/mcp/connection.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Shared, thread-safe connection to a live OSL instance. - -A single process-wide ``OswExpress`` is built lazily on first use. Because -mwclient's session is not thread-safe and MCPServer runs synchronous tools in a -worker-thread pool, every osw access is serialized through one lock. - -The osw library prints progress to ``stdout`` (e.g. "Connecting to ..."), but on -the stdio transport ``stdout`` is the JSON-RPC channel. The :func:`osw_guard` -context manager therefore redirects ``stdout`` to ``stderr`` for the duration of -each osw call (safe because the transport captured its own stream at startup and -the lock guarantees only one redirect at a time). -""" - -from __future__ import annotations - -import sys -import threading -from contextlib import contextmanager, redirect_stdout -from typing import Callable, Optional - -from osw.auth import CredentialManager -from osw.express import OswExpress -from osw.service import config -from osw.service.context import Context, Policy -from osw.service.ledger import Ledger - -_LOCK = threading.RLock() -_osw: Optional[OswExpress] = None -_ledger: Optional[Ledger] = None - - -def _require_active_domain() -> str: - """Return the active instance's domain, or raise a clear, actionable error.""" - domain = config.get_active_domain() - if domain is None: - available = ", ".join(config.available_iris()) or "(none)" - raise RuntimeError( - "No OSL instance selected. For a server process, set OSW_DOMAIN " - "(or OSW_ENV_FILE to point at a .env file that sets it); for the " - f"CLI, pass --instance . Available: {available}." - ) - return domain - - -def get_osw() -> OswExpress: - """Return the shared ``OswExpress``, connecting on first use. - - Credentials come from either of two sources, both already validated by - :func:`osw.service.config.load`: - - * ``OSW_USERNAME`` / ``OSW_PASSWORD`` (or their ``OSL_*`` aliases), read - by osw from the environment; or - * a credential file (``settings.cred_filepath``), configured via - ``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH``, wrapped in a - ``CredentialManager`` and passed to ``OswExpress`` explicitly. - - Connects to the active instance (see :mod:`osw.service.config`); raises if - none is selected. - """ - global _osw - if _osw is None: - settings = config.get_settings() - domain = _require_active_domain() - if settings.cred_filepath: - cred_mngr = CredentialManager(cred_filepath=settings.cred_filepath) - _osw = OswExpress(domain=domain, cred_mngr=cred_mngr) - else: - _osw = OswExpress(domain=domain) - return _osw - - -def get_ledger() -> Ledger: - """Return the shared provenance ledger, keyed on the active instance's domain.""" - global _ledger - if _ledger is None: - settings = config.get_settings() - domain = _require_active_domain() - _ledger = Ledger(domain=domain, state_dir=settings.state_dir) - return _ledger - - -@contextmanager -def osw_guard(): - """Serialize osw access and keep osw's stdout off the protocol channel.""" - with _LOCK, redirect_stdout(sys.stderr): - yield get_osw() - - -def run_guarded(fn: Callable[[OswExpress], dict]) -> dict: - """Run ``fn(osw)`` under the guard, converting exceptions into error dicts. - - Keeps tool signatures clean (no ``osw`` parameter leaks into the MCP schema) - and prevents stack traces from reaching the client; the model sees a - structured ``{"error", "type"}`` instead. - """ - try: - with osw_guard() as osw: - return fn(osw) - except Exception as exc: - print(f"[osw-mcp] tool error: {exc!r}", file=sys.stderr) - return {"error": str(exc), "type": type(exc).__name__} - - -class _LegacyContext(Context): - """Transitional :class:`Context` backed by this module's process globals. - - Operations have moved to :mod:`osw.service.ops`, but ``register()`` still - runs against the globals above and the existing tests monkeypatch - ``connection.get_osw`` / ``connection.get_ledger``. Resolving both through - the module functions on every access keeps that working. Deleted together - with the rest of this module once ``server.py`` builds a real Context. - """ - - def __init__(self, settings, policy=None) -> None: - super().__init__(settings, policy) - self._lock = _LOCK # share the lock with any remaining run_guarded call - - @property - def osw(self) -> OswExpress: - return get_osw() - - @property - def ledger(self) -> Ledger: - return get_ledger() - - def reset(self) -> None: - reset() - - -def legacy_context(*, include_writes: bool = True) -> _LegacyContext: - """Build the transitional context the tool groups bind their operations to.""" - return _LegacyContext( - config.get_settings(), - Policy( - capture_stdout=True, - errors_as_dicts=True, - allow_writes=include_writes, - allow_interactive=False, - ), - ) - - -def reset() -> None: - """Drop the shared connection and ledger so the next call rebuilds them.""" - global _osw, _ledger - with _LOCK: - if _osw is not None: - try: - with redirect_stdout(sys.stderr): - _osw.close_connection() - except Exception as exc: - print(f"[osw-mcp] error closing connection: {exc!r}", file=sys.stderr) - _osw = None - _ledger = None - - -def shutdown() -> None: - """Close the connection on server exit.""" - reset() diff --git a/src/osw/mcp/registration.py b/src/osw/mcp/registration.py deleted file mode 100644 index 46f10c6..0000000 --- a/src/osw/mcp/registration.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Mapping from :class:`~osw.service.registry.Operation` metadata onto the -keyword arguments the mcp SDK's ``mcp.tool(...)`` decorator expects. - -Kept in its own module rather than in :mod:`osw.mcp.server`: ``server.py`` -imports ``tools/``, so ``tools/*.py`` importing back from ``server.py`` would -be circular. Once ``tools/`` is folded into ``server.py``, this module folds -in too. -""" - -from __future__ import annotations - -import inspect -from typing import Any, Optional - -from mcp.types import ToolAnnotations - -from osw.service.config import Settings -from osw.service.registry import Operation - - -def _annotations(op: Operation) -> Optional[ToolAnnotations]: - """Build ``ToolAnnotations`` from ``op``'s four hints. - - Returns ``None`` when every hint is unset, so a hint-less operation gets - no ``annotations`` at all rather than an all-``None`` object. - - Built by explicit keyword, never ``**dict``: passing an unrecognized - keyword to ``ToolAnnotations`` (verified empirically against the - installed mcp SDK) is silently dropped rather than raising, so a - misspelled field name would otherwise fail with no error and leave the - hint permanently ``None``. - """ - hints = ( - op.read_only_hint, - op.destructive_hint, - op.idempotent_hint, - op.open_world_hint, - ) - if all(hint is None for hint in hints): - return None - return ToolAnnotations( - read_only_hint=op.read_only_hint, - destructive_hint=op.destructive_hint, - idempotent_hint=op.idempotent_hint, - open_world_hint=op.open_world_hint, - ) - - -def _meta(op: Operation, settings: Settings) -> dict[str, Any]: - """Build the MCP ``_meta`` dict for ``op``. - - ``anthropic/maxResultSizeChars`` always has a value: ``op``'s own limit - if it declares one, else the server-wide default. ``requiresUserInteraction`` - is only present (and only ever ``True``) for operations that declare it. - ``op.extra_meta`` is merged last, so it can override either key. - """ - meta: dict[str, Any] = { - "anthropic/maxResultSizeChars": op.max_result_size_chars or settings.max_chars, - } - if op.requires_user_interaction: - meta["anthropic/requiresUserInteraction"] = True - meta.update(op.extra_meta) - return meta - - -def tool_kwargs(op: Operation, settings: Settings) -> dict[str, Any]: - """Keyword arguments for ``mcp.tool(...)`` for one operation.""" - return { - "name": op.name, - "description": inspect.getdoc(op.fn), - "annotations": _annotations(op), - "meta": _meta(op, settings), - } diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py index 6ea7b5d..18042dc 100644 --- a/src/osw/mcp/server.py +++ b/src/osw/mcp/server.py @@ -8,15 +8,19 @@ from __future__ import annotations import atexit +import inspect import sys +from typing import Any, Optional from mcp.server import MCPServer +from mcp.types import ToolAnnotations import osw +import osw.service.ops from osw.service import config - -from . import connection -from .tools import register_all +from osw.service.config import Settings +from osw.service.context import Context, Policy +from osw.service.registry import Operation, bind, iter_operations INSTRUCTIONS = """\ This server is pinned to exactly one OpenSemanticLab (OSL) instance for its @@ -36,8 +40,63 @@ """ -def create_server() -> MCPServer: - """Build the MCPServer, registering tools per the read-only setting. +def _annotations(op: Operation) -> Optional[ToolAnnotations]: + """Build ``ToolAnnotations`` from ``op``'s four hints. + + Returns ``None`` when every hint is unset, so a hint-less operation gets + no ``annotations`` at all rather than an all-``None`` object. + + Built by explicit keyword, never ``**dict``: passing an unrecognized + keyword to ``ToolAnnotations`` (verified empirically against the + installed mcp SDK) is silently dropped rather than raising, so a + misspelled field name would otherwise fail with no error and leave the + hint permanently ``None``. + """ + hints = ( + op.read_only_hint, + op.destructive_hint, + op.idempotent_hint, + op.open_world_hint, + ) + if all(hint is None for hint in hints): + return None + return ToolAnnotations( + read_only_hint=op.read_only_hint, + destructive_hint=op.destructive_hint, + idempotent_hint=op.idempotent_hint, + open_world_hint=op.open_world_hint, + ) + + +def _meta(op: Operation, settings: Settings) -> dict[str, Any]: + """Build the MCP ``_meta`` dict for ``op``. + + ``anthropic/maxResultSizeChars`` always has a value: ``op``'s own limit + if it declares one, else the server-wide default. ``requiresUserInteraction`` + is only present (and only ever ``True``) for operations that declare it. + ``op.extra_meta`` is merged last, so it can override either key. + """ + meta: dict[str, Any] = { + "anthropic/maxResultSizeChars": op.max_result_size_chars or settings.max_chars, + } + if op.requires_user_interaction: + meta["anthropic/requiresUserInteraction"] = True + meta.update(op.extra_meta) + return meta + + +def tool_kwargs(op: Operation, settings: Settings) -> dict[str, Any]: + """Keyword arguments for ``mcp.tool(...)`` for one operation.""" + return { + "name": op.name, + "description": inspect.getdoc(op.fn), + "annotations": _annotations(op), + "meta": _meta(op, settings), + } + + +def _build_server() -> tuple[MCPServer, Context]: + """Build the MCPServer and the Context its tools are bound to. Loads and validates settings first so a missing-credential misconfiguration fails fast (before any osw call that could trigger an interactive prompt). @@ -55,24 +114,40 @@ def create_server() -> MCPServer: "file (OSW_CRED_FILEPATH) holding exactly one iri. " f"Available: {available}." ) + ctx = Context( + settings, + Policy( + capture_stdout=True, + errors_as_dicts=True, + allow_writes=not settings.read_only, + allow_interactive=False, + ), + ) mcp = MCPServer("osw", instructions=INSTRUCTIONS, version=osw.__version__) - register_all(mcp, include_writes=not settings.read_only) + for op in iter_operations(surface="mcp", include_writes=not settings.read_only): + mcp.tool(**tool_kwargs(op, settings))(bind(op, ctx)) + return mcp, ctx + + +def create_server() -> MCPServer: + """Build the MCPServer, registering tools per the read-only setting.""" + mcp, _ctx = _build_server() return mcp def main() -> None: """Console-script entry point: build the server and serve over stdio.""" try: - mcp = create_server() + mcp, ctx = _build_server() except Exception as exc: print(f"[osw-mcp] failed to start: {exc}", file=sys.stderr) raise SystemExit(1) from exc - atexit.register(connection.shutdown) + atexit.register(ctx.close) try: mcp.run(transport="stdio") finally: - connection.shutdown() + ctx.close() if __name__ == "__main__": diff --git a/src/osw/mcp/tools/__init__.py b/src/osw/mcp/tools/__init__.py deleted file mode 100644 index d7e782b..0000000 --- a/src/osw/mcp/tools/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""MCP tool groups for the osw-mcp server.""" - -from __future__ import annotations - -from . import entities, files, schema, search, slots, status - - -def register_all(mcp, *, include_writes: bool) -> None: - """Register every tool group on ``mcp``. - - Mutating tools (create/update/delete/upload/set_slot) are only registered - when ``include_writes`` is true, so a read-only server never exposes them. - """ - search.register(mcp) - schema.register(mcp) - entities.register(mcp, include_writes=include_writes) - files.register(mcp, include_writes=include_writes) - slots.register(mcp, include_writes=include_writes) - status.register(mcp) diff --git a/src/osw/mcp/tools/entities.py b/src/osw/mcp/tools/entities.py deleted file mode 100644 index 2d4c038..0000000 --- a/src/osw/mcp/tools/entities.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Entity tools: read entity JSON, export JSON-LD, create/update, delete.""" - -from __future__ import annotations - -from osw.service.ops import entities as _ops # noqa: F401 (registers the operations) -from osw.service.registry import bind, iter_operations - -from .. import connection -from ..registration import tool_kwargs - - -def register(mcp, *, include_writes: bool) -> None: - """Register entity tools; mutating ones only when ``include_writes``.""" - ctx = connection.legacy_context(include_writes=include_writes) - for op in iter_operations(surface="mcp", include_writes=include_writes): - if op.group != "entity": - continue - mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/mcp/tools/files.py b/src/osw/mcp/tools/files.py deleted file mode 100644 index 2657097..0000000 --- a/src/osw/mcp/tools/files.py +++ /dev/null @@ -1,23 +0,0 @@ -"""File tools: path-free read/write of wiki file content. - -No parameter on this surface may name a filesystem path (enforced by -``Operation``'s validator at import time); path-taking equivalents (download -to disk, upload from disk) are CLI-only, in ``osw.cli.ops``. -""" - -from __future__ import annotations - -from osw.service.ops import files as _ops # noqa: F401 (registers the operations) -from osw.service.registry import bind, iter_operations - -from .. import connection -from ..registration import tool_kwargs - - -def register(mcp, *, include_writes: bool) -> None: - """Register file tools; the writer only when ``include_writes``.""" - ctx = connection.legacy_context(include_writes=include_writes) - for op in iter_operations(surface="mcp", include_writes=include_writes): - if op.group != "file": - continue - mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/mcp/tools/schema.py b/src/osw/mcp/tools/schema.py deleted file mode 100644 index 3d3456d..0000000 --- a/src/osw/mcp/tools/schema.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Schema introspection: fetch a category's JSON Schema so the model can build -valid entities before writing them.""" - -from __future__ import annotations - -from osw.service.ops import schema as _ops # noqa: F401 (registers the operations) -from osw.service.registry import bind, iter_operations - -from .. import connection -from ..registration import tool_kwargs - - -def register(mcp) -> None: - """Register the read-only schema tool on ``mcp``.""" - ctx = connection.legacy_context() - for op in iter_operations(surface="mcp"): - if op.group != "schema": - continue - mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/mcp/tools/search.py b/src/osw/mcp/tools/search.py deleted file mode 100644 index 4ef91f3..0000000 --- a/src/osw/mcp/tools/search.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Search and query tools: semantic (SMW ask), full-text, instances, SPARQL.""" - -from __future__ import annotations - -from osw.service.ops import search as _ops # noqa: F401 (registers the operations) -from osw.service.registry import bind, iter_operations - -from .. import connection -from ..registration import tool_kwargs - - -def register(mcp) -> None: - """Register the search tools on ``mcp``.""" - ctx = connection.legacy_context() - for op in iter_operations(surface="mcp"): - if op.group != "search": - continue - mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/mcp/tools/slots.py b/src/osw/mcp/tools/slots.py deleted file mode 100644 index 71c8cd0..0000000 --- a/src/osw/mcp/tools/slots.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Full multi-slot page access: list slots, read a slot, write a slot. - -OSW pages are multi-slot MediaWiki pages. The valid slot keys and their content -models come from :data:`osw.wtsite.SLOTS` (main, jsondata, jsonschema, header, -footer, template, header_template, footer_template, data_template, -schema_template). -""" - -from __future__ import annotations - -from osw.service.ops import slots as _ops # noqa: F401 (registers the operations) -from osw.service.registry import bind, iter_operations - -from .. import connection -from ..registration import tool_kwargs - - -def register(mcp, *, include_writes: bool) -> None: - """Register slot tools; the writer only when ``include_writes``.""" - ctx = connection.legacy_context(include_writes=include_writes) - for op in iter_operations(surface="mcp", include_writes=include_writes): - if op.group != "slot": - continue - mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/mcp/tools/status.py b/src/osw/mcp/tools/status.py deleted file mode 100644 index e2c9d58..0000000 --- a/src/osw/mcp/tools/status.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Status / whoami tool: report connection and configuration (no secrets).""" - -from __future__ import annotations - -from osw.service.ops import status as _ops # noqa: F401 (registers the operations) -from osw.service.registry import bind, iter_operations - -from .. import connection -from ..registration import tool_kwargs - -_NAMES = ("status",) - - -def register(mcp) -> None: - """Register the read-only status tool on ``mcp``.""" - ctx = connection.legacy_context() - for op in iter_operations(surface="mcp"): - if op.name not in _NAMES: - continue - mcp.tool(**tool_kwargs(op, ctx.settings))(bind(op, ctx)) diff --git a/src/osw/service/context.py b/src/osw/service/context.py index 9646637..997bfbb 100644 --- a/src/osw/service/context.py +++ b/src/osw/service/context.py @@ -1,9 +1,9 @@ """Per-instance execution context shared by every osw.service adapter. -Replaces the module-level globals in :mod:`osw.mcp.connection` (``_osw``, -``_ledger``, ``_LOCK``) with an object, so a single process can hold more than -one connected instance and tests can inject a fake ``osw``/``ledger`` instead -of monkeypatching a module. +Holds the connection state (``osw``, ``ledger``, the lock) on an object rather +than in module-level globals, so a single process can hold more than one +connected instance and tests can inject a fake ``osw``/``ledger`` instead of +monkeypatching a module. The osw library prints progress to ``stdout`` (e.g. "Connecting to ..."). On the MCP stdio transport ``stdout`` is the JSON-RPC channel, so diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py index b74ecf1..a120631 100644 --- a/tests/integration/test_mcp_server.py +++ b/tests/integration/test_mcp_server.py @@ -14,23 +14,10 @@ pytest.importorskip("mcp", reason="requires the osw[mcp] extra") pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") -from osw.mcp import connection -from osw.mcp.tools import entities, schema, search, slots, status +import osw.service.ops # noqa: F401 (registers the operations) from osw.service import config - - -class _Collector: - """Captures @tool-decorated functions so they can be called directly.""" - - def __init__(self): - self.tools = {} - - def tool(self, *_a, **_k): - def deco(fn): - self.tools[fn.__name__] = fn - return fn - - return deco +from osw.service.context import Context, Policy +from osw.service.registry import bind, iter_operations @pytest.fixture @@ -43,21 +30,24 @@ def mcp_tools(wiki_domain, wiki_username, wiki_password, tmp_path, monkeypatch): monkeypatch.setenv("OSW_PASSWORD", wiki_password) monkeypatch.setenv("OSW_MCP_STATE_DIR", str(tmp_path / "state")) config.reset() - connection._osw = None - connection._ledger = None - - collector = _Collector() - status.register(collector) - search.register(collector) - schema.register(collector) - slots.register(collector, include_writes=True) - entities.register(collector, include_writes=True) - - yield collector.tools - connection.shutdown() - connection._osw = None - connection._ledger = None + ctx = Context( + config.get_settings(), + Policy( + capture_stdout=True, + errors_as_dicts=True, + allow_writes=True, + allow_interactive=False, + ), + ) + tools = { + op.name: bind(op, ctx) + for op in iter_operations(surface="mcp", include_writes=True) + } + + yield tools + + ctx.close() config.reset() diff --git a/tests/test_mcp_registration.py b/tests/test_mcp_registration.py index 91d8080..7b0fd4f 100644 --- a/tests/test_mcp_registration.py +++ b/tests/test_mcp_registration.py @@ -1,6 +1,9 @@ -"""Unit tests for osw.mcp.registration (Operation -> mcp.tool() kwargs). +"""Unit tests for osw.mcp.server's Operation -> mcp.tool() kwargs mapping +(``_annotations``, ``_meta``, ``tool_kwargs``). -These are fully offline: no network, no live wiki. +Pure unit tests, offline, no network, no live wiki. Server-level +registration-shape tests (which tools end up on a real ``MCPServer``) live in +``tests/test_mcp_server.py``. """ from __future__ import annotations @@ -11,7 +14,7 @@ from mcp.types import ToolAnnotations -from osw.mcp.registration import _annotations, _meta, tool_kwargs +from osw.mcp.server import _annotations, _meta, tool_kwargs from osw.service.config import Settings from osw.service.registry import Operation @@ -106,37 +109,3 @@ def test_tool_kwargs_uses_name_and_docstring(): assert kwargs["description"] == "A test operation." assert kwargs["annotations"] is None assert "anthropic/maxResultSizeChars" in kwargs["meta"] - - -# -- no instance-switching tools on the registered server ------------------------ -def test_registered_server_exposes_no_instance_switching_tools(monkeypatch): - from osw.mcp.tools import register_all - from osw.service import config - - monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") - monkeypatch.setenv("OSW_USERNAME", "u") - monkeypatch.setenv("OSW_PASSWORD", "p") - config.reset() - - class FakeMCP: - def __init__(self): - self.names = [] - - def tool(self, *_args, **kwargs): - def deco(fn): - self.names.append(kwargs.get("name") or fn.__name__) - return fn - - return deco - - fake = FakeMCP() - try: - register_all(fake, include_writes=True) - finally: - config.reset() - - # Assert something WAS registered first: the two absence checks below - # would otherwise pass on an empty list. - assert "get_entity" in fake.names - assert "list_instances" not in fake.names - assert "select_instance" not in fake.names diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..18b762e --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,147 @@ +"""Registration-shape tests for the osw-mcp server: which tools end up +registered on a real ``MCPServer``, not what any individual tool body does +(see ``tests/test_service_ops_*.py`` for that) and not the pure +``Operation`` -> ``mcp.tool()`` kwargs mapping (see +``tests/test_mcp_registration.py`` for that). + +These are fully offline: no network, no live wiki. +""" + +from __future__ import annotations + +import asyncio + +import pytest +import yaml + +pytest.importorskip("mcp", reason="requires the osw[mcp] extra") +pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") + +from osw.mcp import server +from osw.service import config +from osw.service.registry import iter_operations + +_ALL_VARS = [ + "OSW_DOMAIN", + "OSL_DOMAIN", + "OSW_USERNAME", + "OSL_USERNAME", + "OSW_PASSWORD", + "OSL_PASSWORD", + "OSW_CRED_FILEPATH", + "OSW_MCP_CRED_FILEPATH", + "OSL_CRED_FILEPATH", + "OSW_READ_ONLY", + "OSW_MCP_READ_ONLY", + "OSW_MCP_ENV_FILE", +] + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch, tmp_path): + for var in _ALL_VARS: + monkeypatch.delenv(var, raising=False) + # Point dotenv at an empty file so it never picks up a real .env on disk. + empty = tmp_path / "empty.env" + empty.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) + config.reset() + yield + config.reset() + + +def _configure(monkeypatch, *, read_only: bool = False) -> None: + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + monkeypatch.setenv("OSW_READ_ONLY", "true" if read_only else "false") + config.reset() + + +def _tool_names(mcp) -> set[str]: + tools = asyncio.run(mcp.list_tools()) + return {t.name for t in tools} + + +def test_every_mcp_surface_op_is_registered_and_no_others(monkeypatch): + _configure(monkeypatch) + + names = _tool_names(server.create_server()) + + expected = {op.name for op in iter_operations(surface="mcp", include_writes=True)} + assert expected # the comparison below must not pass vacuously + assert names == expected + + +def test_jsondata_schema_unchanged_by_cli_typer_marker(monkeypatch): + """A typer marker in a core signature must not alter the MCP JSON schema. + + ``create_or_update_entity``'s ``jsondata`` carries an + ``Annotated[dict, typer.Option(parser=json_value)]`` marker so the CLI + knows how to spell it. That only works because pydantic ignores + Annotated metadata it does not recognise; if that ever stops holding, + the schema shipped to a model silently changes. + """ + _configure(monkeypatch) + + tools = asyncio.run(server.create_server().list_tools()) + tool = next(t for t in tools if t.name == "create_or_update_entity") + + assert tool.input_schema["properties"]["jsondata"]["type"] == "object" + + +def test_read_only_server_omits_writes_full_server_includes_them(monkeypatch): + _configure(monkeypatch, read_only=True) + names_read_only = _tool_names(server.create_server()) + + _configure(monkeypatch, read_only=False) + names_full = _tool_names(server.create_server()) + + assert "get_entity" in names_read_only # a reader survives read-only mode + assert "create_or_update_entity" not in names_read_only + assert "delete_entity" not in names_read_only + assert "create_or_update_entity" in names_full + assert "delete_entity" in names_full + + +def test_annotations_and_meta_reach_the_sdk_for_a_representative_op(monkeypatch): + _configure(monkeypatch) + + tools = {t.name: t for t in asyncio.run(server.create_server().list_tools())} + + tool = tools["delete_entity"] + assert tool.annotations is not None + assert tool.annotations.destructive_hint is True + assert tool.meta["anthropic/requiresUserInteraction"] is True + assert "anthropic/maxResultSizeChars" in tool.meta + + +def test_no_instance_switching_tools_registered(monkeypatch): + _configure(monkeypatch) + + names = _tool_names(server.create_server()) + + # Assert something WAS registered first: the two absence checks below + # would otherwise pass on an empty list. + assert "get_entity" in names + assert "list_instances" not in names + assert "select_instance" not in names + + +def test_create_server_raises_when_no_instance_resolves(monkeypatch, tmp_path): + # A credential file with more than one iri makes settings valid (no + # OSW_DOMAIN/OSW_USERNAME/OSW_PASSWORD required) but leaves no instance + # auto-selected, so config.get_active_domain() is None. + cred_file = tmp_path / "accounts.yaml" + cred_file.write_text( + yaml.safe_dump({ + "wiki-a.example.org": {"username": "a", "password": "b"}, + "wiki-b.example.org": {"username": "c", "password": "d"}, + }), + encoding="utf-8", + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + config.reset() + + with pytest.raises(RuntimeError, match="No OSL instance resolved"): + server.create_server() diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py deleted file mode 100644 index 8ae2e60..0000000 --- a/tests/test_mcp_tools.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Unit tests for osw.mcp tool wiring and the delete provenance guard. - -These mock the shared connection so no network is required. -""" - -import asyncio -from unittest.mock import MagicMock - -import pytest -import yaml - -pytest.importorskip("mcp", reason="requires the osw[mcp] extra") -pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") - -from mcp.server import MCPServer - -from osw.mcp import connection -from osw.mcp.tools import entities, search, slots -from osw.service import config -from osw.service.config import Settings -from osw.service.context import Context, Policy -from osw.service.ops import entities as entity_ops -from osw.service.registry import REGISTRY, bind - - -class FakeMCP: - """Minimal stand-in that captures @tool-decorated functions by name.""" - - def __init__(self): - self.tools = {} - - def tool(self, *_a, **_k): - def deco(fn): - self.tools[fn.__name__] = fn - return fn - - return deco - - -@pytest.fixture -def env(monkeypatch, tmp_path): - empty = tmp_path / "empty.env" - empty.write_text("", encoding="utf-8") - monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) - monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") - monkeypatch.setenv("OSW_USERNAME", "u") - monkeypatch.setenv("OSW_PASSWORD", "p") - monkeypatch.setenv("OSW_MCP_STATE_DIR", str(tmp_path / "state")) - config.reset() - connection._osw = None - connection._ledger = None - yield - config.reset() - connection._osw = None - connection._ledger = None - - -def _osw_with_page(exists=True): - page = MagicMock() - page.exists = exists - osw = MagicMock() - osw.site.get_page.return_value.pages = [page] - return osw, page - - -# -- delete guard --------------------------------------------------------- -def test_delete_untracked_is_blocked(env, monkeypatch): - osw, page = _osw_with_page() - monkeypatch.setattr(connection, "get_osw", lambda: osw) - fake = FakeMCP() - entities.register(fake, include_writes=True) - - result = fake.tools["delete_entity"](title="Item:OSWx") - - assert result["type"] == "ExternalDeleteBlocked" - osw.site.get_page.assert_not_called() # never even fetched the page - page.delete.assert_not_called() - - -def test_delete_tracked_is_allowed(env, monkeypatch): - osw, page = _osw_with_page() - monkeypatch.setattr(connection, "get_osw", lambda: osw) - connection.get_ledger().record("Item:OSWx", op="create", tool="t") - fake = FakeMCP() - entities.register(fake, include_writes=True) - - result = fake.tools["delete_entity"](title="Item:OSWx") - - assert result == {"title": "Item:OSWx", "deleted": True} - page.delete.assert_called_once() - # deletion untracks the entry - assert connection.get_ledger().is_tracked("Item:OSWx") is False - - -def test_delete_external_with_confirm(env, monkeypatch): - osw, page = _osw_with_page() - monkeypatch.setattr(connection, "get_osw", lambda: osw) - fake = FakeMCP() - entities.register(fake, include_writes=True) - - result = fake.tools["delete_entity"]( - title="Item:OSWy", confirm_external_delete=True - ) - - assert result["deleted"] is True - page.delete.assert_called_once() - - -def test_delete_nonexistent_page(env, monkeypatch): - osw, page = _osw_with_page(exists=False) - monkeypatch.setattr(connection, "get_osw", lambda: osw) - connection.get_ledger().record("Item:OSWz", op="create", tool="t") - fake = FakeMCP() - entities.register(fake, include_writes=True) - - result = fake.tools["delete_entity"](title="Item:OSWz") - - assert result["deleted"] is False - assert result["type"] == "NotFound" - page.delete.assert_not_called() - - -# -- read wiring ---------------------------------------------------------- -def test_get_entity_reads_jsondata_slot(env, monkeypatch): - page = MagicMock() - page.exists = True - page.get_slot_content.return_value = {"label": [{"text": "X"}]} - page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1" - osw = MagicMock() - osw.site.get_page.return_value.pages = [page] - monkeypatch.setattr(connection, "get_osw", lambda: osw) - fake = FakeMCP() - entities.register(fake, include_writes=False) - - result = fake.tools["get_entity"](title="Item:OSW1") - - assert result["exists"] is True - assert result["jsondata"] == {"label": [{"text": "X"}]} - page.get_slot_content.assert_called_with("jsondata") - - -def test_search_entities_calls_semantic_search(env, monkeypatch): - osw = MagicMock() - osw.site.semantic_search.return_value = ["Item:OSW1", "Item:OSW2"] - monkeypatch.setattr(connection, "get_osw", lambda: osw) - fake = FakeMCP() - search.register(fake) - - result = fake.tools["search_entities"](ask_query="[[Category:Item]]") - - assert result["titles"] == ["Item:OSW1", "Item:OSW2"] - assert result["count"] == 2 - osw.site.semantic_search.assert_called_once() - - -def test_read_only_registration_omits_writes(env): - fake = FakeMCP() - entities.register(fake, include_writes=False) - assert "get_entity" in fake.tools - assert "create_or_update_entity" not in fake.tools - assert "delete_entity" not in fake.tools - - -# -- set_slot validation (no network) ------------------------------------- -def test_set_slot_rejects_unknown_slot(env, monkeypatch): - monkeypatch.setattr(connection, "get_osw", lambda: MagicMock()) - fake = FakeMCP() - slots.register(fake, include_writes=True) - - result = fake.tools["set_slot"](title="Item:OSW1", slot="bogus", content="x") - - assert result["type"] == "InvalidSlot" - - -def test_set_slot_rejects_wrong_content_type(env, monkeypatch): - monkeypatch.setattr(connection, "get_osw", lambda: MagicMock()) - fake = FakeMCP() - slots.register(fake, include_writes=True) - - result = fake.tools["set_slot"]( - title="Item:OSW1", slot="jsondata", content="not-json" - ) - - assert result["type"] == "InvalidContent" - - -def test_sparql_without_endpoint_reports_not_configured(env, monkeypatch): - monkeypatch.setattr(connection, "get_osw", lambda: MagicMock()) - fake = FakeMCP() - search.register(fake) - - result = fake.tools["sparql_query"](query="SELECT * WHERE {?s ?p ?o}") - - assert result["type"] == "NotConfigured" - - -def test_create_or_update_entity_uses_active_domain(env, monkeypatch, tmp_path): - """The response urls use the active domain, not a stale/static one.""" - cred_file = tmp_path / "accounts.yaml" - cred_file.write_text( - yaml.safe_dump({ - "wiki-a.example.org": {"username": "a", "password": "b"}, - "wiki-b.example.org": {"username": "c", "password": "d"}, - }), - encoding="utf-8", - ) - monkeypatch.delenv("OSW_DOMAIN", raising=False) - monkeypatch.delenv("OSW_USERNAME", raising=False) - monkeypatch.delenv("OSW_PASSWORD", raising=False) - monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) - config.reset() - connection._osw = None - connection._ledger = None - config.set_active_instance("wiki-b.example.org") - - osw = MagicMock() - osw.fetch_schema.return_value = MagicMock(error_messages=[]) - osw.store_entity.return_value = MagicMock( - pages={"Item:OSW1": MagicMock()}, change_id="c1" - ) - monkeypatch.setattr(connection, "get_osw", lambda: osw) - monkeypatch.setattr( - entity_ops, - "_resolve_category_class", - lambda category: entity_ops.model_entity.Entity, - ) - fake = FakeMCP() - entities.register(fake, include_writes=True) - - result = fake.tools["create_or_update_entity"]( - category="Category:Item", jsondata={"label": [{"text": "Test"}]} - ) - - assert result["urls"] == ["https://wiki-b.example.org/wiki/Item:OSW1"] - - -# -- jsondata's typer marker does not change the MCP schema ------------------ -def test_jsondata_schema_unchanged_by_cli_typer_marker(env): - """create_or_update_entity's jsondata carries a typer.Option marker (for - the CLI's JSON parser) since step 6 of the MCP/CLI migration; pydantic - ignores Annotated metadata it does not recognise, so the schema the MCP - SDK derives for it must still be a plain JSON-object schema.""" - op = REGISTRY["create_or_update_entity"] - ctx = Context(Settings(domain=None), Policy()) - mcp = MCPServer("test") - mcp.tool()(bind(op, ctx)) - - tools = asyncio.run(mcp.list_tools()) - tool = next(t for t in tools if t.name == "create_or_update_entity") - jsondata_schema = tool.input_schema["properties"]["jsondata"] - - assert jsondata_schema["type"] == "object" - - -def test_run_guarded_converts_exceptions(env, monkeypatch): - osw = MagicMock() - osw.site.get_page.side_effect = RuntimeError("boom") - monkeypatch.setattr(connection, "get_osw", lambda: osw) - fake = FakeMCP() - entities.register(fake, include_writes=False) - - result = fake.tools["get_entity"](title="Item:OSW1") - - assert result["type"] == "RuntimeError" - assert "boom" in result["error"] diff --git a/tests/test_service_errors.py b/tests/test_service_errors.py index 94ca07a..966fa96 100644 --- a/tests/test_service_errors.py +++ b/tests/test_service_errors.py @@ -1,7 +1,7 @@ """Unit tests for osw.service.errors. Each ``OpError`` subclass must reproduce, key-for-key and value-for-value, the -dict a tool body in ``osw.mcp.tools`` returns today. +error dict shape the MCP tools returned before the move to ``osw.service``. """ from osw.service import errors diff --git a/tests/test_mcp_instances.py b/tests/test_service_instances.py similarity index 86% rename from tests/test_mcp_instances.py rename to tests/test_service_instances.py index e208eca..043e775 100644 --- a/tests/test_mcp_instances.py +++ b/tests/test_service_instances.py @@ -1,16 +1,16 @@ -"""Unit tests for multi-instance selection in osw.mcp (config + connection). +"""Unit tests for multi-instance selection in osw.service (config + Context). -These are fully offline: no network, no live wiki. +These are fully offline: no network, no live wiki. They also need no MCP SDK: +osw.service is deliberately SDK-free, so unlike tests/test_mcp_*.py these run +in the default dev environment. """ import pytest import yaml -pytest.importorskip("mcp", reason="requires the osw[mcp] extra") -pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") - -from osw.mcp import connection -from osw.service import config +from osw.service import config, errors +from osw.service.context import Context, Policy +from osw.service.registry import Operation, bind _ALL_VARS = [ "OSW_DOMAIN", @@ -30,20 +30,6 @@ ] -class FakeMCP: - """Minimal stand-in that captures @tool-decorated functions by name.""" - - def __init__(self): - self.tools = {} - - def tool(self, *_a, **_k): - def deco(fn): - self.tools[fn.__name__] = fn - return fn - - return deco - - @pytest.fixture(autouse=True) def _clean_env(monkeypatch, tmp_path): for var in _ALL_VARS: @@ -53,12 +39,8 @@ def _clean_env(monkeypatch, tmp_path): empty.write_text("", encoding="utf-8") monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty)) config.reset() - connection._osw = None - connection._ledger = None yield config.reset() - connection._osw = None - connection._ledger = None def _write_cred_file(path, data): @@ -130,7 +112,7 @@ def test_set_active_instance_unknown_iri_raises(monkeypatch, tmp_path): assert "wiki-a.example.org" in str(exc.value) -# -- get_osw() / run_guarded without an active instance ---------------------- +# -- Context.osw / bind() without an active instance ------------------------- def test_get_osw_raises_when_no_instance_selected(monkeypatch, tmp_path): cred_file = _write_cred_file( tmp_path / "accounts.yaml", @@ -140,9 +122,10 @@ def test_get_osw_raises_when_no_instance_selected(monkeypatch, tmp_path): }, ) monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + ctx = Context(config.get_settings(), Policy()) - with pytest.raises(RuntimeError) as exc: - connection.get_osw() + with pytest.raises(errors.NotConfigured) as exc: + _ = ctx.osw assert "No OSL instance selected" in str(exc.value) assert "wiki-a.example.org" in str(exc.value) assert "wiki-b.example.org" in str(exc.value) @@ -160,9 +143,17 @@ def test_run_guarded_surfaces_no_instance_selected_as_structured_dict( ) monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) - result = connection.run_guarded(lambda osw: {"ok": True}) + def _touch_osw(ctx) -> dict: + """Test-only op: access ctx.osw to trigger active-domain resolution.""" + _ = ctx.osw + return {"ok": True} + + op = Operation(name="_touch_osw", fn=_touch_osw) + ctx = Context(config.get_settings(), Policy(errors_as_dicts=True)) + + result = bind(op, ctx)() - assert result["type"] == "RuntimeError" + assert result["type"] == "NotConfigured" assert "No OSL instance selected" in result["error"] @@ -181,7 +172,7 @@ def test_derive_domain_from_full_url(): ) -# -- connection.reset() drops the ledger ------------------------------------- +# -- Context.reset() drops the ledger ----------------------------------------- def test_reset_drops_ledger_for_new_domain_after_switching(monkeypatch, tmp_path): cred_file = _write_cred_file( tmp_path / "accounts.yaml", @@ -193,13 +184,14 @@ def test_reset_drops_ledger_for_new_domain_after_switching(monkeypatch, tmp_path monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) monkeypatch.setenv("OSW_MCP_STATE_DIR", str(tmp_path / "state")) config.set_active_instance("wiki-a.example.org") + ctx = Context(config.get_settings(), Policy()) - ledger_a = connection.get_ledger() + ledger_a = ctx.ledger assert "wiki-a.example.org" in str(ledger_a.path) config.set_active_instance("wiki-b.example.org") - connection.reset() - ledger_b = connection.get_ledger() + ctx.reset() + ledger_b = ctx.ledger assert "wiki-b.example.org" in str(ledger_b.path) assert ledger_a.path != ledger_b.path diff --git a/tests/test_service_ops.py b/tests/test_service_ops.py new file mode 100644 index 0000000..255f1a2 --- /dev/null +++ b/tests/test_service_ops.py @@ -0,0 +1,96 @@ +"""Unit tests preserving the MCP-wrapper-level assertions from the old +``osw.mcp.tools`` test suite (``test_mcp_tools.py``, now removed). + +Every operation body assertion from that file already lives in +``tests/test_service_ops_.py`` (called directly, the way this module's +sibling ``test_service_ops_files.py`` does), and the generic ``bind()`` / +error-payload mechanics live in ``tests/test_service_registry.py`` and +``tests/test_service_errors.py``. What is kept here is the handful of +assertions that only made sense through the ``bind()`` wrapper -- e.g. an +``OpError`` becoming a structured dict rather than raising -- exercised +against the real, registered operations (not a synthetic ``fn``), so nothing +here duplicates that coverage. +""" + +from unittest.mock import MagicMock + +import osw.service.ops # noqa: F401 (registers the operations) +from osw.service import registry +from osw.service.config import Settings +from osw.service.context import Context, Policy + + +def _settings() -> Settings: + return Settings(domain="wiki.example.org", username="u", password="p") + + +def _osw_with_page(exists=True): + page = MagicMock() + page.exists = exists + osw = MagicMock() + osw.site.get_page.return_value.pages = [page] + return osw, page + + +def _bound(name: str, ctx: Context): + return registry.bind(registry.REGISTRY[name], ctx) + + +# -- delete_entity: bind() turns its guard/hybrid errors into dicts --------- +def test_delete_untracked_is_blocked_as_dict(): + osw, page = _osw_with_page() + ledger = MagicMock() + ledger.is_tracked.return_value = False + ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=osw, ledger=ledger) + + result = _bound("delete_entity", ctx)(title="Item:OSWx") + + assert result["type"] == "ExternalDeleteBlocked" + osw.site.get_page.assert_not_called() # never even fetched the page + page.delete.assert_not_called() + + +def test_delete_nonexistent_page_returns_hybrid_dict(): + """delete_entity's NotFound carries {"title", "deleted": False} extras; + bind() must merge them with {"error", "type"} rather than dropping either + half of the shape.""" + osw, page = _osw_with_page(exists=False) + ledger = MagicMock() + ledger.is_tracked.return_value = True + ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=osw, ledger=ledger) + + result = _bound("delete_entity", ctx)(title="Item:OSWz") + + assert result == { + "title": "Item:OSWz", + "deleted": False, + "error": "Page 'Item:OSWz' does not exist.", + "type": "NotFound", + } + page.delete.assert_not_called() + + +def test_delete_tracked_is_allowed_as_dict(): + osw, page = _osw_with_page() + ledger = MagicMock() + ledger.is_tracked.return_value = True + ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=osw, ledger=ledger) + + result = _bound("delete_entity", ctx)(title="Item:OSWx") + + assert result == {"title": "Item:OSWx", "deleted": True} + page.delete.assert_called_once() + + +# -- real registry write flags, no mcp SDK required -------------------------- +def test_read_only_mcp_surface_omits_entity_writes(): + """A read-only server must not register create_or_update_entity/delete_entity, + but must still register the reader; checked against the real registry + (not a synthetic op) so a mis-flagged ``writes=`` on a real operation + would be caught here too.""" + names = { + op.name for op in registry.iter_operations(surface="mcp", include_writes=False) + } + assert "get_entity" in names + assert "create_or_update_entity" not in names + assert "delete_entity" not in names From c3ee9de8b814b3055941e221ef8cccfe7e5757bf Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 26 Aug 2026 15:36:26 +0200 Subject: [PATCH 12/28] build: type-check src/osw/mcp instead of excluding it - drop src/osw/mcp from [tool.ty.src] exclude - silence only the two unresolvable SDK imports inline (issue #139) --- pyproject.toml | 10 +++++----- src/osw/mcp/server.py | 7 +++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2254ab4..883f321 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -349,17 +349,17 @@ python-version = "3.10" # - src/osw/model/entity.py: generated (datamodel-code-generator) models # - examples, scripts: illustrative/maintenance code, not part of the package # - tests: not yet type-clean, tightened in a follow-up -# - src/osw/mcp: its dependencies (the mcp extra) cannot be installed -# alongside the workflow extra (see [tool.uv] conflicts); revert this -# once the anyio conflict is resolved -# (https://github.com/OpenSemanticLab/osw-python/issues/139) +# +# src/osw/mcp is no longer excluded. The mcp extra still cannot be installed +# alongside the dev group (issue #139), so the two SDK imports in server.py +# carry an inline `ty: ignore[unresolved-import]`; everything else there, and +# all of src/osw/service and src/osw/cli, is checked. exclude = [ "src/osw/model/entity.py", "examples", "scripts", "tests", "docs", - "src/osw/mcp", ] [tool.ty.rules] diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py index 18042dc..284feb3 100644 --- a/src/osw/mcp/server.py +++ b/src/osw/mcp/server.py @@ -12,8 +12,11 @@ import sys from typing import Any, Optional -from mcp.server import MCPServer -from mcp.types import ToolAnnotations +# ty cannot resolve these: the mcp extra is uninstallable alongside the dev +# group (anyio conflict, issue #139), so it is absent from the env ty runs in. +# The rest of this module is type-checked; drop the ignores once #139 is fixed. +from mcp.server import MCPServer # ty: ignore[unresolved-import] +from mcp.types import ToolAnnotations # ty: ignore[unresolved-import] import osw import osw.service.ops From cf21923f0a6c775c8616d4c75af43483fb5e2d18 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 26 Aug 2026 15:39:53 +0200 Subject: [PATCH 13/28] docs: document the osw CLI and unify the config reference - add a Command line section: command tree, global options, exit behaviour - move credentials into a shared Configuration section with an alias table - state that no MCP tool takes a path, and where the path-based commands live - add stdio type, multi-instance and per-instance permission examples - give each CLI command group a one-line help string --- README.md | 173 +++++++++++++++++++++++++++++++++++--------- src/osw/cli/main.py | 14 +++- 2 files changed, 152 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index ecb849a..8d40023 100644 --- a/README.md +++ b/README.md @@ -41,26 +41,53 @@ More runnable scripts live in [examples/](examples/), and the [Basics tutorial](docs/tutorials/basics.ipynb) walks through the OpenSemanticLab data model. -## MCP server +## Command line -`osw[mcp]` ships an [MCP](https://modelcontextprotocol.io) server that exposes a -live OpenSemanticLab instance to MCP clients such as Claude Code. It wraps -`OswExpress` and provides tools to search (semantic / SPARQL / full-text), -introspect category schemas, read entities and every page slot, create/update -and delete entities, and upload/download files. +Installing `osw` also installs an `osw` command that works against a live +instance: ```bash -pip install "osw[mcp]" +osw status +osw search ask '[[Category:Item]]' --limit 5 +osw entity get 'Item:OSW1234...' --json | jq . +osw file cat 'File:Example.csv' # inline text +osw file download 'File:Example.csv' --target-dir ./tmp # to disk ``` -This extra is deliberately not part of `osw[all]`. It needs `anyio>=4.9`, which -conflicts with the pin the `osw[workflow]` extra requires for prefect 2.x, so -the two cannot share an environment -([#139](https://github.com/OpenSemanticLab/osw-python/issues/139)). Installing -the server standalone, for example via `uvx`, avoids the question entirely. +Commands are grouped by subject: + +| Group | Commands | +| --- | --- | +| `entity` | `get`, `put`, `export`, `delete` | +| `file` | `info`, `cat`, `write`, `download`, `upload` | +| `search` | `ask`, `text`, `instances`, `sparql` | +| `slot` | `list`, `get`, `set` | +| `schema` | `get` | +| `instance` | `list` | +| `ledger` | `path` | +| top level | `status` | + +Global options apply to every command: + +- `--instance IRI` selects the instance for this invocation when more than one + is configured. The CLI is stateless, so the choice is never persisted. +- `--json` / `-j` writes machine-readable JSON to stdout and keeps osw's own + progress output on stderr, so it pipes cleanly into `jq`. +- `--read-only` refuses write operations. +- `--verbose` / `-v` shows full tracebacks instead of a one-line message. + +Failures exit non-zero with a short message on stderr and no traceback. + +The CLI and the MCP server below run the same operations from one shared, +SDK-free core (`osw.service`), so a command and its matching tool behave +identically. They differ in exactly one way: only the CLI accepts filesystem +paths. -Configure credentials in a gitignored `.env` file (the server reads them at -startup and never writes them to disk): +## Configuration + +Both the CLI and the MCP server read their settings from the environment or +from a `.env` file. Keep credentials in a gitignored file; they are read at +startup and never written back to disk. ```dotenv OSW_DOMAIN=wiki-dev.open-semantic-lab.org @@ -68,7 +95,7 @@ OSW_USERNAME=your-user OSW_PASSWORD=your-password # optional OSW_SPARQL_ENDPOINT=https://.../sparql -OSW_MCP_READ_ONLY=false # true hides all mutating tools +OSW_READ_ONLY=false # true hides all mutating tools ``` Alternatively, authenticate from an osw credential file, so the password is not @@ -76,12 +103,11 @@ duplicated into a second plaintext file: ```dotenv OSW_DOMAIN=wiki-dev.open-semantic-lab.org -OSW_MCP_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml +OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml ``` -`OSL_CRED_FILEPATH` is accepted as a fallback, so deployments that already -configure osw's `CredentialManager` need no extra setup. The file is the YAML -format `CredentialManager` already reads, keyed by iri: +The file is the YAML format osw's `CredentialManager` already reads, keyed by +iri, so deployments that configure it need no extra setup: ```yaml wiki-dev.open-semantic-lab.org: @@ -89,45 +115,124 @@ wiki-dev.open-semantic-lab.org: password: your-password ``` +A credential file may hold several iris. One is selected automatically only if +it is the only one; otherwise pick it with `osw --instance `, or pin the +server process with `OSW_DOMAIN`. + +The canonical variable names are `OSW_*`. Older `OSW_MCP_*` and `OSL_*` names +stay accepted so existing deployments keep working, and the first name that is +set wins: + +| Canonical | Also accepted | Meaning | +| --- | --- | --- | +| `OSW_DOMAIN` | `OSL_DOMAIN` | Instance to connect to | +| `OSW_USERNAME` | `OSL_USERNAME` | Login user | +| `OSW_PASSWORD` | `OSL_PASSWORD` | Login password | +| `OSW_CRED_FILEPATH` | `OSW_MCP_CRED_FILEPATH`, `OSL_CRED_FILEPATH` | YAML credential file, keyed by iri | +| `OSW_ENV_FILE` | `OSW_MCP_ENV_FILE` | `.env` file to load | +| `OSW_READ_ONLY` | `OSW_MCP_READ_ONLY` | `true` refuses every write | +| `OSW_SPARQL_ENDPOINT` | | Endpoint for `sparql` queries | +| `OSW_STATE_DIR` | `OSW_MCP_STATE_DIR` | Where the provenance ledger is kept | +| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) | +| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) | + +## MCP server + +`osw[mcp]` ships an [MCP](https://modelcontextprotocol.io) server that exposes a +live OpenSemanticLab instance to MCP clients such as Claude Code. It wraps +`OswExpress` and provides tools to search (semantic / SPARQL / full-text), +introspect category schemas, read entities and every page slot, create/update +and delete entities, and read and write file pages as text. + +```bash +pip install "osw[mcp]" +``` + +This extra is deliberately not part of `osw[all]`. It needs `anyio>=4.9`, which +conflicts with the pin the `osw[workflow]` extra requires for prefect 2.x, so +the two cannot share an environment +([#139](https://github.com/OpenSemanticLab/osw-python/issues/139)). Installing +the server standalone, for example via `uvx`, avoids the question entirely. + +**No filesystem access:** no MCP tool takes or returns a local path. File +content moves inline as text (`get_file_info`, `read_file_text`, +`write_file_text`), and everything path-based lives in the CLI instead +(`osw file download`, `osw file upload`, `osw ledger path`). MCP does not imply +a shared host: a server can be containerised or remote, so a path argument is +either meaningless or a way to reach a filesystem nobody granted access to. A +CLI runs where the command was typed, under that user's own permissions, and an +agent calling it goes through whatever command permissions already apply to it. + **One server per instance:** each server process is pinned to exactly one OSL instance for its whole lifetime; there is no tool to switch at runtime. If no instance resolves at startup (a configured `OSW_DOMAIN`, or a credential file holding exactly one iri), the server refuses to start rather than register tools that would all fail. -To work with more than one instance, register a separate server per instance, -each with its own env file. This also has the advantage that the instance is -visible in the tool name at every call site, with read-only settable per -instance: +Register it with Claude Code by referencing the `.env` via `OSW_ENV_FILE`. Do +not put `OSW_PASSWORD` inline in a committed `.mcp.json`. The transport is +stdio; SSE and HTTP are not supported. -```bash -claude mcp add osw-dev --env OSW_MCP_ENV_FILE=/abs/path/dev.env -- uvx --from "osw[mcp]" osw-mcp -claude mcp add osw-prod --env OSW_MCP_ENV_FILE=/abs/path/prod.env --env OSW_MCP_READ_ONLY=true -- uvx --from "osw[mcp]" osw-mcp +```json +{ + "mcpServers": { + "osw": { + "type": "stdio", + "command": "uvx", + "args": ["--from", "osw[mcp]", "osw-mcp"], + "env": { "OSW_ENV_FILE": "/abs/path/to/.env" } + } + } +} ``` -`status` reports the active instance and connection state (never the password). +Or via the CLI: + +```bash +claude mcp add osw --transport stdio --env OSW_ENV_FILE=/abs/path/to/.env -- uvx --from "osw[mcp]" osw-mcp +``` -Register it with Claude Code (reference the `.env` via `OSW_MCP_ENV_FILE`; do -not put `OSW_PASSWORD` inline in a committed `.mcp.json`): +To work with more than one instance, register one server per instance, each +with its own env file pinning a single `OSW_DOMAIN`: ```json { "mcpServers": { - "osw": { + "osw-dev": { + "type": "stdio", + "command": "uvx", + "args": ["--from", "osw[mcp]", "osw-mcp"], + "env": { "OSW_ENV_FILE": "/abs/path/dev.env" } + }, + "osw-prod": { + "type": "stdio", "command": "uvx", "args": ["--from", "osw[mcp]", "osw-mcp"], - "env": { "OSW_MCP_ENV_FILE": "/abs/path/to/.env" } + "env": { + "OSW_ENV_FILE": "/abs/path/prod.env", + "OSW_READ_ONLY": "true" + } } } } ``` -Or via the CLI: +That puts the instance in the tool name at every call site +(`mcp__osw-prod__get_entity`), so the destination is visible in the permission +prompt, read-only is settable per instance, and permissions can differ per +instance: -```bash -claude mcp add osw --env OSW_MCP_ENV_FILE=/abs/path/to/.env -- uvx --from "osw[mcp]" osw-mcp +```json +{ + "permissions": { + "allow": ["mcp__osw-dev"], + "ask": ["mcp__osw-prod"] + } +} ``` +`status` reports the active instance and connection state (never the password). + **Safe deletes:** the server records every entity it creates or modifies in a local provenance ledger. It deletes those without extra prompting, but refuses to delete anything it did not create unless the caller passes diff --git a/src/osw/cli/main.py b/src/osw/cli/main.py index 12ca1c4..acc8981 100644 --- a/src/osw/cli/main.py +++ b/src/osw/cli/main.py @@ -166,6 +166,18 @@ def command(**kwargs: Any) -> None: _groups: dict[str, typer.Typer] = {} +# One line per command group. Without these ``osw --help`` lists eight bare +# group names with nothing next to them; a group missing an entry still works. +_GROUP_HELP = { + "entity": "Read, write, export and delete entities.", + "file": "Wiki file pages: metadata, inline text, and local transfer.", + "instance": "Inspect the OSL instances this process can connect to.", + "ledger": "The local provenance ledger of pages written from here.", + "schema": "Category JSON Schemas.", + "search": "Query the instance: semantic, full-text or SPARQL.", + "slot": "Read and write individual page slots.", +} + for _op in iter_operations(surface="cli"): _command = _make_command(_op) if _op.group is None: @@ -175,7 +187,7 @@ def command(**kwargs: Any) -> None: if _sub is None: _sub = typer.Typer() _groups[_op.group] = _sub - app.add_typer(_sub, name=_op.group) + app.add_typer(_sub, name=_op.group, help=_GROUP_HELP.get(_op.group)) _sub.command(name=_op.command)(_command) From 085db4aa9d50b5dff31f5490ffec45afb01cecad Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 26 Aug 2026 17:05:51 +0200 Subject: [PATCH 14/28] fix(config): find .env from the CWD, report config sources - CLI now searches upward from the working directory, not from the installed package's directory (dotenv's default walks the call stack) - MCP server searches nowhere: its CWD is chosen by the client - both print the resolved .env and credential file to stderr at startup - a missing credential file whose path holds a control character now explains .env double-quote escape decoding --- README.md | 34 ++++++++- src/osw/cli/main.py | 7 ++ src/osw/mcp/server.py | 4 ++ src/osw/service/config.py | 118 ++++++++++++++++++++++++++++--- tests/test_cli.py | 13 +++- tests/test_service_config.py | 133 +++++++++++++++++++++++++++++++++++ 6 files changed, 294 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8d40023..5c3c5ea 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,9 @@ paths. ## Configuration Both the CLI and the MCP server read their settings from the environment or -from a `.env` file. Keep credentials in a gitignored file; they are read at -startup and never written back to disk. +from a `.env` file. Keep credentials in a gitignored file; they are read once +per process, into that process only, and never written back to disk. A real +environment variable always wins over the same name in a `.env` file. ```dotenv OSW_DOMAIN=wiki-dev.open-semantic-lab.org @@ -136,6 +137,35 @@ set wins: | `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) | | `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) | +### Where the `.env` file comes from + +Set `OSW_ENV_FILE` to a path and that file is loaded, always. With it unset the +two adapters differ on purpose: + +- The **CLI** searches upward from the working directory, so a `.env` in a + project root applies to every `osw` command run anywhere inside it. +- The **MCP server** searches nowhere. Its working directory is picked by the + MCP client, so an implicit search would make the credentials it loads depend + on how the client happened to be launched. Point it at a file explicitly with + `OSW_ENV_FILE` in the server's `env` block (see below). + +Both print the sources they resolved to stderr before connecting: + +```text +[osw] env file : /home/me/project/.env (found from the working directory upward) +[osw] credential file: /abs/path/to/accounts.pwd.yaml +``` + +Quote Windows paths with single quotes, or leave them unquoted. A double-quoted +value in a `.env` file is escape-decoded, so `\a` in a path silently becomes a +BEL byte that renders as nothing: + +```dotenv +OSW_CRED_FILEPATH='C:\Users\me\accounts.pwd.yaml' # ok +OSW_CRED_FILEPATH=C:\Users\me\accounts.pwd.yaml # ok +OSW_CRED_FILEPATH="C:\Users\me\accounts.pwd.yaml" # broken: \a is eaten +``` + ## MCP server `osw[mcp]` ships an [MCP](https://modelcontextprotocol.io) server that exposes a diff --git a/src/osw/cli/main.py b/src/osw/cli/main.py index acc8981..932bfb2 100644 --- a/src/osw/cli/main.py +++ b/src/osw/cli/main.py @@ -60,6 +60,10 @@ def _callback( configured instance this invocation talks to; unlike the MCP server, the CLI is stateless, so the choice only applies to this one command. """ + # The CLI's working directory is the one the user typed the command in, so + # searching it upward for a .env is what they mean. The MCP server leaves + # this off: its working directory is chosen by the MCP client. + config.set_env_file_discovery(True) ctx.obj = { "instance": instance, "as_json": as_json, @@ -120,6 +124,9 @@ def _run(op: Operation, typer_ctx: typer.Context, kwargs: dict[str, Any]) -> Non except ValueError as exc: raise errors.UnknownInstance(str(exc)) from exc + # Before load(), so a misconfiguration that makes loading raise still + # reports which files were read. + config.log_config_sources() settings = config.load(strict=False) policy = Policy( capture_stdout=bool(opts.get("as_json")), diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py index 284feb3..7d5342e 100644 --- a/src/osw/mcp/server.py +++ b/src/osw/mcp/server.py @@ -107,6 +107,10 @@ def _build_server() -> tuple[MCPServer, Context]: to one OSL instance for its whole lifetime, so registering tools that would all fail at call time would be actively misleading. """ + # Before get_settings(), so a misconfiguration that makes loading raise + # still reports which files were read. stderr, so it lands in the MCP + # client's server log without touching the JSON-RPC stream on stdout. + config.log_config_sources() settings = config.get_settings() domain = config.get_active_domain() if domain is None: diff --git a/src/osw/service/config.py b/src/osw/service/config.py index f093542..14dabac 100644 --- a/src/osw/service/config.py +++ b/src/osw/service/config.py @@ -11,6 +11,7 @@ from __future__ import annotations import os +import sys from dataclasses import dataclass, field from pathlib import Path from typing import Optional @@ -56,7 +57,7 @@ class Settings: # domain is optional: with a usable credential file, no domain need be # configured via the environment; the active instance is then chosen from - # the credential file (auto-selected or via the select_instance tool). + # the credential file (auto-selected, or picked with the CLI's --instance). domain: Optional[str] # username/password are optional: a configured credential file is an # alternative source of credentials (see ENV_CRED_FILEPATH). @@ -96,6 +97,24 @@ def _int_env(names: tuple[str, ...], default: int) -> int: ) +def _escape_hint(value: str) -> str: + """Extra error text when ``value`` holds a control character, else "". + + A double-quoted value in a ``.env`` file goes through escape decoding, so + a Windows path like ``"C:\\dir\\accounts.yaml"`` silently loses its ``\\a`` + to a BEL byte. The result renders as nothing in a terminal, which makes the + resulting "does not exist" message look like it is naming the right path. + """ + if not any(ord(char) < 32 for char in value): + return "" + return ( + f" The configured path contains a control character ({value!r}). A " + "double-quoted value in a .env file is escape-decoded, so a Windows " + r"path loses sequences like \a, \b, \f, \n, \r, \t and \v. Use single " + "quotes, no quotes, forward slashes, or doubled backslashes." + ) + + def _cred_file_iris(cred_filepath: str) -> list[str]: """Return the top-level iri keys in a credential YAML file, best effort.""" try: @@ -150,14 +169,54 @@ def _verify_cred_file_has_domain(cred_filepath: str, domain: str) -> None: ) +# Whether to look for a .env file when none is configured explicitly. Off by +# default, so a process only reads a file it was pointed at: the MCP server's +# working directory is chosen by the MCP client, so searching it would make +# which credentials get loaded depend on how the client was launched. The CLI +# turns it on (see osw.cli.main), where the working directory is the one the +# user typed the command in. +_discover_env_file: bool = False + +# Where the .env file actually came from, for the startup banner. One of +# "explicit", "discovered", "none" (searched, nothing found) or "not searched". +_env_file_path: Optional[str] = None +_env_file_origin: str = "not searched" + + +def set_env_file_discovery(enabled: bool) -> None: + """Enable or disable the implicit ``.env`` search (default: disabled). + + Must be called before settings are first loaded, since the file is read + exactly once per process; a call that would *change* the setting after + that raises rather than silently having no effect. Re-asserting the + current value is always allowed, so an adapter can call this on every + command without tracking whether it already did. + """ + global _discover_env_file + if enabled != _discover_env_file and _settings is not None: + raise RuntimeError( + "set_env_file_discovery() must be called before settings are " + "loaded; they are already cached for this process." + ) + _discover_env_file = enabled + + def _load_env_file() -> None: - """Load a .env file if one is configured or discoverable. + """Load a .env file if one is configured or (when enabled) discoverable. dotenv is optional (it ships with the ``mcp`` extra). An *explicitly* configured env file with dotenv missing is an error, because the operator asked for something that cannot happen. An implicit search is skipped silently. + + The implicit search starts at the current working directory and walks + upward. ``dotenv.load_dotenv()`` with no arguments would instead walk up + from the *calling module's* directory, which is this file: under an + editable install that is the osw checkout and under a normal install it is + site-packages. Neither is what a user standing in a project directory + means by "the .env file", hence the explicit ``usecwd=True``. """ + global _env_file_path, _env_file_origin path = _first_env(ENV_FILE) try: import dotenv @@ -172,16 +231,50 @@ def _load_env_file() -> None: ) if path: dotenv.load_dotenv(path) - else: - dotenv.load_dotenv() + _env_file_path, _env_file_origin = path, "explicit" + return + if not _discover_env_file: + return + found = dotenv.find_dotenv(usecwd=True) + if not found: + _env_file_origin = "none" + return + dotenv.load_dotenv(found) + _env_file_path, _env_file_origin = found, "discovered" + + +def log_config_sources(stream=None) -> None: + """Print where configuration was read from, one line per source. + + Loads the ``.env`` file first if that has not happened yet, and reads the + environment directly rather than a ``Settings``. Both so this can run + *before* settings are loaded: a misconfiguration makes loading raise, and + that is exactly when knowing which files were read matters most. + + Always writes to ``stderr``: under MCP ``stdout`` carries the JSON-RPC + stream, and under ``osw --json`` it carries the result payload. + """ + _load_env_file() + out = sys.stderr if stream is None else stream + described = { + "explicit": f"{_env_file_path} (from {ENV_FILE[0]})", + "discovered": f"{_env_file_path} (found from the working directory upward)", + "none": "none found (searched from the working directory upward)", + "not searched": f"not configured (set {ENV_FILE[0]} to use one)", + }[_env_file_origin] + print(f"[osw] env file : {described}", file=out) + cred_filepath = _first_env(ENV_CRED_FILEPATH) + if cred_filepath: + print(f"[osw] credential file: {cred_filepath}", file=out) def load(strict: bool = True) -> Settings: """Load and validate settings from the environment. Loads a ``.env`` file first: the path in ``OSW_ENV_FILE`` (or its - ``OSW_MCP_ENV_FILE`` alias) if set, otherwise dotenv's default search from - the current working directory upward. + ``OSW_MCP_ENV_FILE`` alias) if set, otherwise a search from the current + working directory upward, but only when ``set_env_file_discovery(True)`` + has enabled it (the CLI does; the MCP server does not). Credentials can come from either ``OSW_USERNAME``/``OSW_PASSWORD`` (or their ``OSL_*`` aliases) or from a credential file configured via @@ -228,11 +321,12 @@ def load(strict: bool = True) -> Settings: "Set OSW_CRED_FILEPATH (or its OSW_MCP_CRED_FILEPATH / " "OSL_CRED_FILEPATH aliases) to a valid path, or remove it and " "configure OSW_USERNAME/OSW_PASSWORD instead." + + _escape_hint(cred_filepath) ) cred_file_usable = True # A usable credential file makes the domain optional: which instance to - # use is then chosen later (auto-selected or via select_instance). + # use is then chosen later (auto-selected, or via the CLI's --instance). checks = [] if not cred_file_usable: checks.append((ENV_DOMAIN, domain)) @@ -281,9 +375,13 @@ def get_settings() -> Settings: def reset() -> None: """Drop cached settings and the active-instance selection (used by tests).""" global _settings, _active_iri, _active_resolved + global _discover_env_file, _env_file_path, _env_file_origin _settings = None _active_iri = None _active_resolved = False + _discover_env_file = False + _env_file_path = None + _env_file_origin = "not searched" # -- active-instance state --------------------------------------------------- @@ -292,8 +390,8 @@ def reset() -> None: # env-configured domain and/or the iris in a credential file). Exactly one of # them is "active" at a time; tools connect to whichever one is active. The # active instance is auto-selected on first access (see ``_auto_select_iri``) -# and can be changed at runtime via ``set_active_instance`` (the -# ``select_instance`` tool). +# and can be changed via ``set_active_instance`` (the CLI's ``--instance`` +# flag; the MCP server is pinned to one instance and never switches). _active_iri: Optional[str] = None _active_resolved: bool = False @@ -306,7 +404,7 @@ def _auto_select_iri() -> Optional[str]: 2. Otherwise, if a credential file is configured and contains exactly one iri, that iri is the active instance. 3. Otherwise there is no active instance until ``set_active_instance`` is - called (e.g. via the ``select_instance`` tool). + called (e.g. via the CLI's ``--instance`` flag). """ settings = get_settings() if settings.domain: diff --git a/tests/test_cli.py b/tests/test_cli.py index ba4662e..d932458 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -76,6 +76,13 @@ def runner(): return CliRunner(mix_stderr=False) +def _error_lines(stderr: str) -> list[str]: + """``stderr`` minus the ``[osw]`` config banner every command prints.""" + return [ + line for line in stderr.strip().splitlines() if not line.startswith("[osw] ") + ] + + def _fake_osw_with_page(exists=True): page = MagicMock() page.exists = exists @@ -162,10 +169,10 @@ def test_op_error_exits_with_its_exit_code_and_no_traceback(runner, configured_e result = runner.invoke(app, ["search", "sparql", "SELECT * WHERE {?s ?p ?o}"]) assert result.exit_code == 5 - assert result.stderr.strip() == ( + assert _error_lines(result.stderr) == [ "NotConfigured: SPARQL endpoint not configured. Set " "OSW_SPARQL_ENDPOINT or pass the 'endpoint' argument." - ) + ] assert "Traceback" not in result.stderr assert "Traceback" not in result.stdout @@ -185,7 +192,7 @@ def test_read_only_blocks_a_write_command(runner, configured_env): ) assert result.exit_code == 4 - assert result.stderr.strip().startswith("ReadOnly:") + assert _error_lines(result.stderr)[0].startswith("ReadOnly:") assert "Traceback" not in result.stderr diff --git a/tests/test_service_config.py b/tests/test_service_config.py index 4df8a16..44b7b2c 100644 --- a/tests/test_service_config.py +++ b/tests/test_service_config.py @@ -398,3 +398,136 @@ def test_load_env_file_silent_when_not_configured_and_dotenv_missing( monkeypatch.setitem(sys.modules, "dotenv", None) # must not raise config._load_env_file() + + +# -- implicit .env discovery ---------------------------------------------------- +def test_no_implicit_env_search_by_default(monkeypatch, tmp_path): + """Discovery is off unless an adapter opts in, so a stray .env in the + working directory cannot decide which instance a server connects to.""" + monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False) + monkeypatch.delenv("OSW_ENV_FILE", raising=False) + (tmp_path / ".env").write_text( + "OSW_DOMAIN=from-cwd.example.org\n", encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + config._load_env_file() + + assert config._first_env(config.ENV_DOMAIN) is None + assert config._env_file_origin == "not searched" + + +def test_implicit_env_search_starts_at_the_working_directory(monkeypatch, tmp_path): + """The search must start at the CWD, not at this module's directory. + + ``dotenv.load_dotenv()`` with no arguments walks up from the *calling + module's* file, which is osw/service/config.py: under an editable install + that is the osw checkout, so it would silently load the checkout's own + .env no matter where the user is standing. + """ + monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False) + monkeypatch.delenv("OSW_ENV_FILE", raising=False) + nested = tmp_path / "project" / "sub" + nested.mkdir(parents=True) + (tmp_path / "project" / ".env").write_text( + "OSW_DOMAIN=from-cwd.example.org\n", encoding="utf-8" + ) + monkeypatch.chdir(nested) + config.set_env_file_discovery(True) + + config._load_env_file() + + assert config._first_env(config.ENV_DOMAIN) == "from-cwd.example.org" + assert config._env_file_origin == "discovered" + + +def test_explicit_env_file_wins_over_discovery(monkeypatch, tmp_path): + explicit = tmp_path / "explicit.env" + explicit.write_text("OSW_DOMAIN=explicit.example.org\n", encoding="utf-8") + (tmp_path / ".env").write_text( + "OSW_DOMAIN=from-cwd.example.org\n", encoding="utf-8" + ) + monkeypatch.setenv("OSW_ENV_FILE", str(explicit)) + monkeypatch.chdir(tmp_path) + config.set_env_file_discovery(True) + + config._load_env_file() + + assert config._first_env(config.ENV_DOMAIN) == "explicit.example.org" + assert config._env_file_origin == "explicit" + + +def test_set_env_file_discovery_raises_only_on_a_late_change(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "u") + monkeypatch.setenv("OSW_PASSWORD", "p") + config.get_settings() # populates the cache + + config.set_env_file_discovery(False) # re-asserting the current value is fine + + with pytest.raises(RuntimeError, match="before settings are loaded"): + config.set_env_file_discovery(True) + + +# -- .env escape footgun -------------------------------------------------------- +def test_missing_cred_file_flags_an_escape_mangled_path(monkeypatch): + r"""A double-quoted Windows path in .env loses \a to a BEL byte. + + The mangled path then renders as if it were the path the user typed, so + the plain "does not exist" message looks wrong rather than informative. + """ + # What dotenv produces for OSW_CRED_FILEPATH="C:\dir\accounts.yaml": + # the \a is decoded to BEL, which prints as nothing. + mangled = "C:" + chr(92) + "dir" + chr(7) + "ccounts.yaml" + monkeypatch.setenv("OSW_CRED_FILEPATH", mangled) + + with pytest.raises(RuntimeError) as exc: + config.load() + + assert "control character" in str(exc.value) + assert "single quotes" in str(exc.value) + + +def test_missing_cred_file_without_control_chars_has_no_escape_hint( + monkeypatch, tmp_path +): + monkeypatch.setenv("OSW_CRED_FILEPATH", str(tmp_path / "nope.yaml")) + + with pytest.raises(RuntimeError) as exc: + config.load() + + assert "does not exist" in str(exc.value) + assert "control character" not in str(exc.value) + + +# -- startup banner ------------------------------------------------------------- +def test_log_config_sources_reports_env_and_cred_file(monkeypatch, tmp_path, capsys): + cred = tmp_path / "accounts.yaml" + cred.write_text( + yaml.safe_dump({"wiki.example.org": {"username": "u", "password": "p"}}), + encoding="utf-8", + ) + env = tmp_path / "creds.env" + env.write_text("", encoding="utf-8") + monkeypatch.setenv("OSW_ENV_FILE", str(env)) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred)) + config._load_env_file() + + config.log_config_sources() + + captured = capsys.readouterr() + # stdout is the JSON-RPC stream under MCP and the result payload under + # `osw --json`, so the banner must never appear there. + assert captured.out == "" + assert str(env) in captured.err + assert str(cred) in captured.err + + +def test_log_config_sources_omits_cred_file_when_unconfigured(monkeypatch, capsys): + config._load_env_file() + + config.log_config_sources() + + captured = capsys.readouterr() + assert "env file" in captured.err + assert "credential file" not in captured.err From 10706462683d625ba390f887126bb5660eb4cc1d Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Thu, 27 Aug 2026 14:44:02 +0200 Subject: [PATCH 15/28] docs: move CLI and MCP sections out of the README - new docs page "CLI and MCP tools", added to the zensical nav - README keeps a short pointer section, otherwise back to its old shape - get-started extras table gains the osw[mcp] row --- README.md | 239 ++------------------------------------------ docs/cli-and-mcp.md | 237 +++++++++++++++++++++++++++++++++++++++++++ docs/get-started.md | 1 + zensical.toml | 1 + 4 files changed, 246 insertions(+), 232 deletions(-) create mode 100644 docs/cli-and-mcp.md diff --git a/README.md b/README.md index 5c3c5ea..3c19612 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,8 @@ pip install osw ``` Optional extras (`osw[wikitext]`, `osw[DB]`, `osw[S3]`, `osw[dataimport]`, -`osw[UI]`, `osw[mcp]`, `osw[all]`) are described in the +`osw[UI]`, `osw[all]`) are described in the [Get Started guide](https://opensemanticlab.github.io/osw-python/get-started/). -Note that `osw[mcp]` is not part of `osw[all]` and has to be installed -explicitly, see [MCP server](#mcp-server). ## Quickstart @@ -41,241 +39,18 @@ More runnable scripts live in [examples/](examples/), and the [Basics tutorial](docs/tutorials/basics.ipynb) walks through the OpenSemanticLab data model. -## Command line +## CLI and MCP tools -Installing `osw` also installs an `osw` command that works against a live -instance: +Installing `osw` also installs an `osw` command line client, and the separate +`osw[mcp]` extra adds an MCP server that exposes a live instance to agent +clients such as Claude Code: ```bash -osw status osw search ask '[[Category:Item]]' --limit 5 -osw entity get 'Item:OSW1234...' --json | jq . -osw file cat 'File:Example.csv' # inline text -osw file download 'File:Example.csv' --target-dir ./tmp # to disk ``` -Commands are grouped by subject: - -| Group | Commands | -| --- | --- | -| `entity` | `get`, `put`, `export`, `delete` | -| `file` | `info`, `cat`, `write`, `download`, `upload` | -| `search` | `ask`, `text`, `instances`, `sparql` | -| `slot` | `list`, `get`, `set` | -| `schema` | `get` | -| `instance` | `list` | -| `ledger` | `path` | -| top level | `status` | - -Global options apply to every command: - -- `--instance IRI` selects the instance for this invocation when more than one - is configured. The CLI is stateless, so the choice is never persisted. -- `--json` / `-j` writes machine-readable JSON to stdout and keeps osw's own - progress output on stderr, so it pipes cleanly into `jq`. -- `--read-only` refuses write operations. -- `--verbose` / `-v` shows full tracebacks instead of a one-line message. - -Failures exit non-zero with a short message on stderr and no traceback. - -The CLI and the MCP server below run the same operations from one shared, -SDK-free core (`osw.service`), so a command and its matching tool behave -identically. They differ in exactly one way: only the CLI accepts filesystem -paths. - -## Configuration - -Both the CLI and the MCP server read their settings from the environment or -from a `.env` file. Keep credentials in a gitignored file; they are read once -per process, into that process only, and never written back to disk. A real -environment variable always wins over the same name in a `.env` file. - -```dotenv -OSW_DOMAIN=wiki-dev.open-semantic-lab.org -OSW_USERNAME=your-user -OSW_PASSWORD=your-password -# optional -OSW_SPARQL_ENDPOINT=https://.../sparql -OSW_READ_ONLY=false # true hides all mutating tools -``` - -Alternatively, authenticate from an osw credential file, so the password is not -duplicated into a second plaintext file: - -```dotenv -OSW_DOMAIN=wiki-dev.open-semantic-lab.org -OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml -``` - -The file is the YAML format osw's `CredentialManager` already reads, keyed by -iri, so deployments that configure it need no extra setup: - -```yaml -wiki-dev.open-semantic-lab.org: - username: your-user - password: your-password -``` - -A credential file may hold several iris. One is selected automatically only if -it is the only one; otherwise pick it with `osw --instance `, or pin the -server process with `OSW_DOMAIN`. - -The canonical variable names are `OSW_*`. Older `OSW_MCP_*` and `OSL_*` names -stay accepted so existing deployments keep working, and the first name that is -set wins: - -| Canonical | Also accepted | Meaning | -| --- | --- | --- | -| `OSW_DOMAIN` | `OSL_DOMAIN` | Instance to connect to | -| `OSW_USERNAME` | `OSL_USERNAME` | Login user | -| `OSW_PASSWORD` | `OSL_PASSWORD` | Login password | -| `OSW_CRED_FILEPATH` | `OSW_MCP_CRED_FILEPATH`, `OSL_CRED_FILEPATH` | YAML credential file, keyed by iri | -| `OSW_ENV_FILE` | `OSW_MCP_ENV_FILE` | `.env` file to load | -| `OSW_READ_ONLY` | `OSW_MCP_READ_ONLY` | `true` refuses every write | -| `OSW_SPARQL_ENDPOINT` | | Endpoint for `sparql` queries | -| `OSW_STATE_DIR` | `OSW_MCP_STATE_DIR` | Where the provenance ledger is kept | -| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) | -| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) | - -### Where the `.env` file comes from - -Set `OSW_ENV_FILE` to a path and that file is loaded, always. With it unset the -two adapters differ on purpose: - -- The **CLI** searches upward from the working directory, so a `.env` in a - project root applies to every `osw` command run anywhere inside it. -- The **MCP server** searches nowhere. Its working directory is picked by the - MCP client, so an implicit search would make the credentials it loads depend - on how the client happened to be launched. Point it at a file explicitly with - `OSW_ENV_FILE` in the server's `env` block (see below). - -Both print the sources they resolved to stderr before connecting: - -```text -[osw] env file : /home/me/project/.env (found from the working directory upward) -[osw] credential file: /abs/path/to/accounts.pwd.yaml -``` - -Quote Windows paths with single quotes, or leave them unquoted. A double-quoted -value in a `.env` file is escape-decoded, so `\a` in a path silently becomes a -BEL byte that renders as nothing: - -```dotenv -OSW_CRED_FILEPATH='C:\Users\me\accounts.pwd.yaml' # ok -OSW_CRED_FILEPATH=C:\Users\me\accounts.pwd.yaml # ok -OSW_CRED_FILEPATH="C:\Users\me\accounts.pwd.yaml" # broken: \a is eaten -``` - -## MCP server - -`osw[mcp]` ships an [MCP](https://modelcontextprotocol.io) server that exposes a -live OpenSemanticLab instance to MCP clients such as Claude Code. It wraps -`OswExpress` and provides tools to search (semantic / SPARQL / full-text), -introspect category schemas, read entities and every page slot, create/update -and delete entities, and read and write file pages as text. - -```bash -pip install "osw[mcp]" -``` - -This extra is deliberately not part of `osw[all]`. It needs `anyio>=4.9`, which -conflicts with the pin the `osw[workflow]` extra requires for prefect 2.x, so -the two cannot share an environment -([#139](https://github.com/OpenSemanticLab/osw-python/issues/139)). Installing -the server standalone, for example via `uvx`, avoids the question entirely. - -**No filesystem access:** no MCP tool takes or returns a local path. File -content moves inline as text (`get_file_info`, `read_file_text`, -`write_file_text`), and everything path-based lives in the CLI instead -(`osw file download`, `osw file upload`, `osw ledger path`). MCP does not imply -a shared host: a server can be containerised or remote, so a path argument is -either meaningless or a way to reach a filesystem nobody granted access to. A -CLI runs where the command was typed, under that user's own permissions, and an -agent calling it goes through whatever command permissions already apply to it. - -**One server per instance:** each server process is pinned to exactly one OSL -instance for its whole lifetime; there is no tool to switch at runtime. If no -instance resolves at startup (a configured `OSW_DOMAIN`, or a credential file -holding exactly one iri), the server refuses to start rather than register -tools that would all fail. - -Register it with Claude Code by referencing the `.env` via `OSW_ENV_FILE`. Do -not put `OSW_PASSWORD` inline in a committed `.mcp.json`. The transport is -stdio; SSE and HTTP are not supported. - -```json -{ - "mcpServers": { - "osw": { - "type": "stdio", - "command": "uvx", - "args": ["--from", "osw[mcp]", "osw-mcp"], - "env": { "OSW_ENV_FILE": "/abs/path/to/.env" } - } - } -} -``` - -Or via the CLI: - -```bash -claude mcp add osw --transport stdio --env OSW_ENV_FILE=/abs/path/to/.env -- uvx --from "osw[mcp]" osw-mcp -``` - -To work with more than one instance, register one server per instance, each -with its own env file pinning a single `OSW_DOMAIN`: - -```json -{ - "mcpServers": { - "osw-dev": { - "type": "stdio", - "command": "uvx", - "args": ["--from", "osw[mcp]", "osw-mcp"], - "env": { "OSW_ENV_FILE": "/abs/path/dev.env" } - }, - "osw-prod": { - "type": "stdio", - "command": "uvx", - "args": ["--from", "osw[mcp]", "osw-mcp"], - "env": { - "OSW_ENV_FILE": "/abs/path/prod.env", - "OSW_READ_ONLY": "true" - } - } - } -} -``` - -That puts the instance in the tool name at every call site -(`mcp__osw-prod__get_entity`), so the destination is visible in the permission -prompt, read-only is settable per instance, and permissions can differ per -instance: - -```json -{ - "permissions": { - "allow": ["mcp__osw-dev"], - "ask": ["mcp__osw-prod"] - } -} -``` - -`status` reports the active instance and connection state (never the password). - -**Safe deletes:** the server records every entity it creates or modifies in a -local provenance ledger. It deletes those without extra prompting, but refuses -to delete anything it did not create unless the caller passes -`confirm_external_delete=true`. - -**Editable-checkout caveat:** `create_or_update_entity` and -`export_entity_jsonld` call `fetch_schema`, which regenerates -`src/osw/model/entity.py` inside the installed package. With a normal -`pip install "osw[mcp]"` this writes into site-packages and is harmless. If you -run the server from an editable source checkout, those two tools will modify the -generated model file in your working tree. The read tools (`get_entity`, -`get_slot`, `get_category_schema`, ...) read raw page slots and never trigger -this. +Commands, tools and their configuration are described in the +[CLI and MCP guide](https://opensemanticlab.github.io/osw-python/cli-and-mcp/). ## Contributing diff --git a/docs/cli-and-mcp.md b/docs/cli-and-mcp.md new file mode 100644 index 0000000..9e182e8 --- /dev/null +++ b/docs/cli-and-mcp.md @@ -0,0 +1,237 @@ +# CLI and MCP tools + +Besides the Python API, osw ships two adapters that talk to a live instance: +the `osw` command line client, and an MCP server for agent clients such as +Claude Code. Both run the same operations from one shared, SDK-free core +(`osw.service`), so a command and its matching tool behave identically. They +differ in exactly one way: only the CLI accepts filesystem paths. + +## Command line + +Installing `osw` also installs an `osw` command: + +```bash +osw status +osw search ask '[[Category:Item]]' --limit 5 +osw entity get 'Item:OSW1234...' --json | jq . +osw file cat 'File:Example.csv' # inline text +osw file download 'File:Example.csv' --target-dir ./tmp # to disk +``` + +Commands are grouped by subject: + +| Group | Commands | +| --- | --- | +| `entity` | `get`, `put`, `export`, `delete` | +| `file` | `info`, `cat`, `write`, `download`, `upload` | +| `search` | `ask`, `text`, `instances`, `sparql` | +| `slot` | `list`, `get`, `set` | +| `schema` | `get` | +| `instance` | `list` | +| `ledger` | `path` | +| top level | `status` | + +Global options apply to every command: + +- `--instance IRI` selects the instance for this invocation when more than one + is configured. The CLI is stateless, so the choice is never persisted. +- `--json` / `-j` writes machine-readable JSON to stdout and keeps osw's own + progress output on stderr, so it pipes cleanly into `jq`. +- `--read-only` refuses write operations. +- `--verbose` / `-v` shows full tracebacks instead of a one-line message. + +Failures exit non-zero with a short message on stderr and no traceback. + +## Configuration + +Both the CLI and the MCP server read their settings from the environment or +from a `.env` file. Keep credentials in a gitignored file; they are read once +per process, into that process only, and never written back to disk. A real +environment variable always wins over the same name in a `.env` file. + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_USERNAME=your-user +OSW_PASSWORD=your-password +# optional +OSW_SPARQL_ENDPOINT=https://.../sparql +OSW_READ_ONLY=false # true hides all mutating tools +``` + +Alternatively, authenticate from an osw credential file, so the password is not +duplicated into a second plaintext file: + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml +``` + +The file is the YAML format osw's `CredentialManager` already reads, keyed by +iri, so deployments that configure it need no extra setup: + +```yaml +wiki-dev.open-semantic-lab.org: + username: your-user + password: your-password +``` + +A credential file may hold several iris. One is selected automatically only if +it is the only one; otherwise pick it with `osw --instance `, or pin the +server process with `OSW_DOMAIN`. + +The canonical variable names are `OSW_*`. Older `OSW_MCP_*` and `OSL_*` names +stay accepted so existing deployments keep working, and the first name that is +set wins: + +| Canonical | Also accepted | Meaning | +| --- | --- | --- | +| `OSW_DOMAIN` | `OSL_DOMAIN` | Instance to connect to | +| `OSW_USERNAME` | `OSL_USERNAME` | Login user | +| `OSW_PASSWORD` | `OSL_PASSWORD` | Login password | +| `OSW_CRED_FILEPATH` | `OSW_MCP_CRED_FILEPATH`, `OSL_CRED_FILEPATH` | YAML credential file, keyed by iri | +| `OSW_ENV_FILE` | `OSW_MCP_ENV_FILE` | `.env` file to load | +| `OSW_READ_ONLY` | `OSW_MCP_READ_ONLY` | `true` refuses every write | +| `OSW_SPARQL_ENDPOINT` | | Endpoint for `sparql` queries | +| `OSW_STATE_DIR` | `OSW_MCP_STATE_DIR` | Where the provenance ledger is kept | +| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) | +| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) | + +### Where the `.env` file comes from + +Set `OSW_ENV_FILE` to a path and that file is loaded, always. With it unset the +two adapters differ on purpose: + +- The **CLI** searches upward from the working directory, so a `.env` in a + project root applies to every `osw` command run anywhere inside it. +- The **MCP server** searches nowhere. Its working directory is picked by the + MCP client, so an implicit search would make the credentials it loads depend + on how the client happened to be launched. Point it at a file explicitly with + `OSW_ENV_FILE` in the server's `env` block (see below). + +Both print the sources they resolved to stderr before connecting: + +```text +[osw] env file : /home/me/project/.env (found from the working directory upward) +[osw] credential file: /abs/path/to/accounts.pwd.yaml +``` + +Quote Windows paths with single quotes, or leave them unquoted. A double-quoted +value in a `.env` file is escape-decoded, so `\a` in a path silently becomes a +BEL byte that renders as nothing: + +```dotenv +OSW_CRED_FILEPATH='C:\Users\me\accounts.pwd.yaml' # ok +OSW_CRED_FILEPATH=C:\Users\me\accounts.pwd.yaml # ok +OSW_CRED_FILEPATH="C:\Users\me\accounts.pwd.yaml" # broken: \a is eaten +``` + +## MCP server + +`osw[mcp]` ships an [MCP](https://modelcontextprotocol.io) server that exposes a +live OpenSemanticLab instance to MCP clients such as Claude Code. It wraps +`OswExpress` and provides tools to search (semantic / SPARQL / full-text), +introspect category schemas, read entities and every page slot, create/update +and delete entities, and read and write file pages as text. + +```bash +pip install "osw[mcp]" +``` + +This extra is deliberately not part of `osw[all]`. It needs `anyio>=4.9`, which +conflicts with the pin the `osw[workflow]` extra requires for prefect 2.x, so +the two cannot share an environment +([#139](https://github.com/OpenSemanticLab/osw-python/issues/139)). Installing +the server standalone, for example via `uvx`, avoids the question entirely. + +**No filesystem access:** no MCP tool takes or returns a local path. File +content moves inline as text (`get_file_info`, `read_file_text`, +`write_file_text`), and everything path-based lives in the CLI instead +(`osw file download`, `osw file upload`, `osw ledger path`). MCP does not imply +a shared host: a server can be containerised or remote, so a path argument is +either meaningless or a way to reach a filesystem nobody granted access to. A +CLI runs where the command was typed, under that user's own permissions, and an +agent calling it goes through whatever command permissions already apply to it. + +**One server per instance:** each server process is pinned to exactly one OSL +instance for its whole lifetime; there is no tool to switch at runtime. If no +instance resolves at startup (a configured `OSW_DOMAIN`, or a credential file +holding exactly one iri), the server refuses to start rather than register +tools that would all fail. + +Register it with Claude Code by referencing the `.env` via `OSW_ENV_FILE`. Do +not put `OSW_PASSWORD` inline in a committed `.mcp.json`. The transport is +stdio; SSE and HTTP are not supported. + +```json +{ + "mcpServers": { + "osw": { + "type": "stdio", + "command": "uvx", + "args": ["--from", "osw[mcp]", "osw-mcp"], + "env": { "OSW_ENV_FILE": "/abs/path/to/.env" } + } + } +} +``` + +Or via the CLI: + +```bash +claude mcp add osw --transport stdio --env OSW_ENV_FILE=/abs/path/to/.env -- uvx --from "osw[mcp]" osw-mcp +``` + +To work with more than one instance, register one server per instance, each +with its own env file pinning a single `OSW_DOMAIN`: + +```json +{ + "mcpServers": { + "osw-dev": { + "type": "stdio", + "command": "uvx", + "args": ["--from", "osw[mcp]", "osw-mcp"], + "env": { "OSW_ENV_FILE": "/abs/path/dev.env" } + }, + "osw-prod": { + "type": "stdio", + "command": "uvx", + "args": ["--from", "osw[mcp]", "osw-mcp"], + "env": { + "OSW_ENV_FILE": "/abs/path/prod.env", + "OSW_READ_ONLY": "true" + } + } + } +} +``` + +That puts the instance in the tool name at every call site +(`mcp__osw-prod__get_entity`), so the destination is visible in the permission +prompt, read-only is settable per instance, and permissions can differ per +instance: + +```json +{ + "permissions": { + "allow": ["mcp__osw-dev"], + "ask": ["mcp__osw-prod"] + } +} +``` + +`status` reports the active instance and connection state (never the password). + +**Safe deletes:** the server records every entity it creates or modifies in a +local provenance ledger. It deletes those without extra prompting, but refuses +to delete anything it did not create unless the caller passes +`confirm_external_delete=true`. + +**Editable-checkout caveat:** `create_or_update_entity` and +`export_entity_jsonld` call `fetch_schema`, which regenerates +`src/osw/model/entity.py` inside the installed package. With a normal +`pip install "osw[mcp]"` this writes into site-packages and is harmless. If you +run the server from an editable source checkout, those two tools will modify the +generated model file in your working tree. The read tools (`get_entity`, +`get_slot`, `get_category_schema`, ...) read raw page slots and never trigger +this. diff --git a/docs/get-started.md b/docs/get-started.md index 0d1292d..e01f2a2 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -31,6 +31,7 @@ | `osw[dataimport]` | Additional tools to import data | | `osw[UI]` | To use a helper UI to work with entity slots | | `osw[all]` | All of the above | +| `osw[mcp]` | [MCP server](cli-and-mcp.md#mcp-server) for agent clients, not part of `osw[all]` | Install multiple extras with `pip install osw[opt1,opt2]`. diff --git a/zensical.toml b/zensical.toml index 50218f4..671f583 100644 --- a/zensical.toml +++ b/zensical.toml @@ -14,6 +14,7 @@ nav = [ { "Home" = "index.md" }, { "About" = "about.md" }, { "Get Started" = "get-started.md" }, + { "CLI and MCP tools" = "cli-and-mcp.md" }, { "API Reference" = [ { "Overview" = "api/index.md" }, { "OSW" = "api/core.md" }, From a1ef49af899de5fc1c12d89e4d1fe0201a44b7f7 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Thu, 27 Aug 2026 15:41:12 +0200 Subject: [PATCH 16/28] docs: default MCP examples to a credential file in env - server entries now set OSW_CRED_FILEPATH plus OSW_DOMAIN, no .env needed - multi-instance example shares one credential file, one domain per server - register via claude mcp add-json, which takes the entry verbatim --- docs/cli-and-mcp.md | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/cli-and-mcp.md b/docs/cli-and-mcp.md index 9e182e8..e62df16 100644 --- a/docs/cli-and-mcp.md +++ b/docs/cli-and-mcp.md @@ -158,9 +158,11 @@ instance resolves at startup (a configured `OSW_DOMAIN`, or a credential file holding exactly one iri), the server refuses to start rather than register tools that would all fail. -Register it with Claude Code by referencing the `.env` via `OSW_ENV_FILE`. Do -not put `OSW_PASSWORD` inline in a committed `.mcp.json`. The transport is -stdio; SSE and HTTP are not supported. +Every setting from the table above can be set in the server entry's `env` +block, so no `.env` file is needed. Pointing at a credential file is the +recommended form: the client config then holds a path and an instance name, and +no secret at all. Never put `OSW_PASSWORD` inline in a committed `.mcp.json`. +The transport is stdio; SSE and HTTP are not supported. ```json { @@ -169,20 +171,34 @@ stdio; SSE and HTTP are not supported. "type": "stdio", "command": "uvx", "args": ["--from", "osw[mcp]", "osw-mcp"], - "env": { "OSW_ENV_FILE": "/abs/path/to/.env" } + "env": { + "OSW_CRED_FILEPATH": "/abs/path/to/accounts.pwd.yaml", + "OSW_DOMAIN": "wiki-dev.open-semantic-lab.org" + } } } } ``` -Or via the CLI: +`OSW_DOMAIN` may be omitted when the credential file holds exactly one iri. +Setting it anyway is worth the line: the server then checks at startup that the +file has a matching entry and, if not, names the iris the file does contain +(never their secrets) instead of failing later on the first call. + +To point at a `.env` file instead, replace the `env` block with +`{ "OSW_ENV_FILE": "/abs/path/to/.env" }`. + +Registering the same thing from a shell is easiest with `add-json`, which takes +the entry verbatim. Note that a Windows path needs forward slashes or doubled +backslashes to be valid JSON: ```bash -claude mcp add osw --transport stdio --env OSW_ENV_FILE=/abs/path/to/.env -- uvx --from "osw[mcp]" osw-mcp +claude mcp add-json osw '{"type":"stdio","command":"uvx","args":["--from","osw[mcp]","osw-mcp"],"env":{"OSW_CRED_FILEPATH":"/abs/path/to/accounts.pwd.yaml","OSW_DOMAIN":"wiki-dev.open-semantic-lab.org"}}' ``` To work with more than one instance, register one server per instance, each -with its own env file pinning a single `OSW_DOMAIN`: +pinned to a single `OSW_DOMAIN`. One credential file can serve them all, since +it is keyed by iri: ```json { @@ -191,14 +207,18 @@ with its own env file pinning a single `OSW_DOMAIN`: "type": "stdio", "command": "uvx", "args": ["--from", "osw[mcp]", "osw-mcp"], - "env": { "OSW_ENV_FILE": "/abs/path/dev.env" } + "env": { + "OSW_CRED_FILEPATH": "/abs/path/to/accounts.pwd.yaml", + "OSW_DOMAIN": "wiki-dev.open-semantic-lab.org" + } }, "osw-prod": { "type": "stdio", "command": "uvx", "args": ["--from", "osw[mcp]", "osw-mcp"], "env": { - "OSW_ENV_FILE": "/abs/path/prod.env", + "OSW_CRED_FILEPATH": "/abs/path/to/accounts.pwd.yaml", + "OSW_DOMAIN": "wiki.open-semantic-lab.org", "OSW_READ_ONLY": "true" } } From cd172470f5cff08f616875dea166eb1c6309133d Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Thu, 27 Aug 2026 15:54:12 +0200 Subject: [PATCH 17/28] feat(mcp): require an explicitly configured OSW_DOMAIN - server no longer auto-selects a single-iri credential file; the CLI still does - docs present the env block and the .env file as two supported styles - multi-instance example shows one server of each style - drop the "OSW_DOMAIN may be omitted" note --- docs/cli-and-mcp.md | 69 ++++++++++++++++++++++++++-------------- src/osw/mcp/server.py | 20 +++++++----- tests/test_mcp_server.py | 32 +++++++++++++------ 3 files changed, 80 insertions(+), 41 deletions(-) diff --git a/docs/cli-and-mcp.md b/docs/cli-and-mcp.md index e62df16..065c4e2 100644 --- a/docs/cli-and-mcp.md +++ b/docs/cli-and-mcp.md @@ -75,9 +75,10 @@ wiki-dev.open-semantic-lab.org: password: your-password ``` -A credential file may hold several iris. One is selected automatically only if -it is the only one; otherwise pick it with `osw --instance `, or pin the -server process with `OSW_DOMAIN`. +A credential file may hold several iris. The CLI selects one automatically if +it is the only one, and otherwise wants `osw --instance `. The MCP server +never selects one: it requires `OSW_DOMAIN`, see +[One server per instance](#mcp-server). The canonical variable names are `OSW_*`. Older `OSW_MCP_*` and `OSL_*` names stay accepted so existing deployments keep working, and the first name that is @@ -153,15 +154,29 @@ CLI runs where the command was typed, under that user's own permissions, and an agent calling it goes through whatever command permissions already apply to it. **One server per instance:** each server process is pinned to exactly one OSL -instance for its whole lifetime; there is no tool to switch at runtime. If no -instance resolves at startup (a configured `OSW_DOMAIN`, or a credential file -holding exactly one iri), the server refuses to start rather than register -tools that would all fail. - -Every setting from the table above can be set in the server entry's `env` -block, so no `.env` file is needed. Pointing at a credential file is the -recommended form: the client config then holds a path and an instance name, and -no secret at all. Never put `OSW_PASSWORD` inline in a committed `.mcp.json`. +instance for its whole lifetime; there is no tool to switch at runtime. +`OSW_DOMAIN` must be set, either in the server entry's `env` block or in the +`.env` file that entry names. The server never picks an instance for you, not +even when the credential file holds exactly one iri: which instance a tool call +reaches has to be readable from the configuration. Without it the server +refuses to start rather than register tools that would all fail. + +There are two ways to configure a server entry, and both are supported: + +- **Directly in the entry's `env` block.** Every setting from the table above + can be set there, so no `.env` file is needed at all. +- **In a `.env` file**, named by `OSW_ENV_FILE` in the `env` block. Useful when + several tools share one settings file, or when the client config is committed + and the settings file is not. + +The `env` block naming `OSW_CRED_FILEPATH` and `OSW_DOMAIN` is the preferred +form. It is more verbose, and that is the point: the destination instance is +spelled out in the entry itself, so it is visible at a glance and in a diff, +rather than being one indirection away in a file the entry merely points at. +The secret stays out of the client config either way, since a credential file +contributes a path and an instance name and nothing else. Never put +`OSW_PASSWORD` inline in a committed `.mcp.json`. + The transport is stdio; SSE and HTTP are not supported. ```json @@ -180,13 +195,13 @@ The transport is stdio; SSE and HTTP are not supported. } ``` -`OSW_DOMAIN` may be omitted when the credential file holds exactly one iri. -Setting it anyway is worth the line: the server then checks at startup that the -file has a matching entry and, if not, names the iris the file does contain -(never their secrets) instead of failing later on the first call. +At startup the server checks that the credential file has an entry matching +`OSW_DOMAIN` and, if not, names the iris the file does contain (never their +secrets), so a typo surfaces immediately rather than on the first tool call. -To point at a `.env` file instead, replace the `env` block with -`{ "OSW_ENV_FILE": "/abs/path/to/.env" }`. +The `.env` variant of the same entry replaces the whole `env` block with +`{ "OSW_ENV_FILE": "/abs/path/to/dev.env" }`, where `dev.env` sets `OSW_DOMAIN` +and the credentials. Registering the same thing from a shell is easiest with `add-json`, which takes the entry verbatim. Note that a Windows path needs forward slashes or doubled @@ -197,8 +212,10 @@ claude mcp add-json osw '{"type":"stdio","command":"uvx","args":["--from","osw[m ``` To work with more than one instance, register one server per instance, each -pinned to a single `OSW_DOMAIN`. One credential file can serve them all, since -it is keyed by iri: +pinned to a single `OSW_DOMAIN`. The two entries below show the two styles side +by side: `osw-dev` puts everything in a `.env` file, `osw-prod` names the +credential file and the domain directly. One credential file can serve any +number of servers, since it is keyed by iri. ```json { @@ -207,10 +224,7 @@ it is keyed by iri: "type": "stdio", "command": "uvx", "args": ["--from", "osw[mcp]", "osw-mcp"], - "env": { - "OSW_CRED_FILEPATH": "/abs/path/to/accounts.pwd.yaml", - "OSW_DOMAIN": "wiki-dev.open-semantic-lab.org" - } + "env": { "OSW_ENV_FILE": "/abs/path/to/dev.env" } }, "osw-prod": { "type": "stdio", @@ -226,6 +240,13 @@ it is keyed by iri: } ``` +`dev.env` has to pin the instance itself, since the server will not infer one: + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml +``` + That puts the instance in the tool name at every call site (`mcp__osw-prod__get_entity`), so the destination is visible in the permission prompt, read-only is settable per instance, and permissions can differ per diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py index 7d5342e..5fef915 100644 --- a/src/osw/mcp/server.py +++ b/src/osw/mcp/server.py @@ -103,23 +103,27 @@ def _build_server() -> tuple[MCPServer, Context]: Loads and validates settings first so a missing-credential misconfiguration fails fast (before any osw call that could trigger an interactive prompt). - Also fails fast if no instance resolves: this server is statically pinned - to one OSL instance for its whole lifetime, so registering tools that - would all fail at call time would be actively misleading. + Also fails fast unless a domain was configured *explicitly*: this server is + statically pinned to one OSL instance for its whole lifetime, and which one + that is has to be readable from the configuration rather than inferred. + Deliberately stricter than :func:`config.get_active_domain`, which the CLI + uses: there the instance is visible on the command line at every + invocation, and ``--instance`` can override it per command. """ # Before get_settings(), so a misconfiguration that makes loading raise # still reports which files were read. stderr, so it lands in the MCP # client's server log without touching the JSON-RPC stream on stdout. config.log_config_sources() settings = config.get_settings() - domain = config.get_active_domain() + domain = settings.domain if domain is None: available = ", ".join(config.available_iris()) or "(none)" raise RuntimeError( - "No OSL instance resolved. Set OSW_DOMAIN (or OSW_ENV_FILE to " - "point at a .env file that sets it), or configure a credential " - "file (OSW_CRED_FILEPATH) holding exactly one iri. " - f"Available: {available}." + "No OSL instance configured. Set OSW_DOMAIN in this server's env " + "block, or in the .env file named by OSW_ENV_FILE. The server " + "never picks an instance for you, not even when a credential file " + "holds exactly one iri, because which instance a tool call reaches " + f"must be readable from the configuration. Available: {available}." ) ctx = Context( settings, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 18b762e..d84c772 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -128,20 +128,34 @@ def test_no_instance_switching_tools_registered(monkeypatch): assert "select_instance" not in names -def test_create_server_raises_when_no_instance_resolves(monkeypatch, tmp_path): - # A credential file with more than one iri makes settings valid (no - # OSW_DOMAIN/OSW_USERNAME/OSW_PASSWORD required) but leaves no instance - # auto-selected, so config.get_active_domain() is None. +def _write_cred_file(tmp_path, iris): cred_file = tmp_path / "accounts.yaml" cred_file.write_text( - yaml.safe_dump({ - "wiki-a.example.org": {"username": "a", "password": "b"}, - "wiki-b.example.org": {"username": "c", "password": "d"}, - }), + yaml.safe_dump({iri: {"username": "a", "password": "b"} for iri in iris}), encoding="utf-8", ) + return cred_file + + +def test_create_server_raises_when_no_domain_is_configured(monkeypatch, tmp_path): + # A credential file with more than one iri makes settings valid (no + # OSW_DOMAIN/OSW_USERNAME/OSW_PASSWORD required) but names no instance. + cred_file = _write_cred_file(tmp_path, ["wiki-a.example.org", "wiki-b.example.org"]) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + config.reset() + + with pytest.raises(RuntimeError, match="No OSL instance configured"): + server.create_server() + + +def test_create_server_does_not_auto_select_a_single_iri(monkeypatch, tmp_path): + # config.get_active_domain() *would* resolve this one (the CLI relies on + # that), but the server must not: which instance its tools reach has to be + # readable from the configuration, not inferred from the credential file. + cred_file = _write_cred_file(tmp_path, ["wiki-only.example.org"]) monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) config.reset() + assert config.get_active_domain() == "wiki-only.example.org" - with pytest.raises(RuntimeError, match="No OSL instance resolved"): + with pytest.raises(RuntimeError, match="No OSL instance configured"): server.create_server() From 3d91d92d8c80c59500e90fa2ac30b107b67597d6 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 28 Aug 2026 10:43:12 +0200 Subject: [PATCH 18/28] docs: restructure the CLI and MCP guide - add a Setup section with the uv/pip installs up front - move Configuration below the MCP section, both adapters share it - state where the .env is looked for first, drop the always-loaded claim - note that --instance is optional and when it is required - collect the rationale in a Design notes section at the end --- docs/cli-and-mcp.md | 347 +++++++++++++++++++++++++------------------- 1 file changed, 200 insertions(+), 147 deletions(-) diff --git a/docs/cli-and-mcp.md b/docs/cli-and-mcp.md index 065c4e2..49b8139 100644 --- a/docs/cli-and-mcp.md +++ b/docs/cli-and-mcp.md @@ -6,9 +6,51 @@ Claude Code. Both run the same operations from one shared, SDK-free core (`osw.service`), so a command and its matching tool behave identically. They differ in exactly one way: only the CLI accepts filesystem paths. -## Command line +## Setup + +Installing osw provides the `osw` command; the `osw[mcp]` extra adds the +`osw-mcp` server: + +=== "uv (recommended)" + + ```bash + uv add osw + uv add "osw[mcp]" + ``` + +=== "pip" + + ```bash + pip install osw + pip install "osw[mcp]" + ``` -Installing `osw` also installs an `osw` command: +`osw[mcp]` is not part of `osw[all]`, see [Design notes](#design-notes). The +other extras are listed in the +[Get Started guide](get-started.md#optional-extras). The server also runs +without being installed at all, which is what the registration examples below +do: + +```bash +uvx --from "osw[mcp]" osw-mcp +``` + +Both adapters need an instance and credentials. The quickest start is a +gitignored `.env` file in your project root: + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_USERNAME=your-user +OSW_PASSWORD=your-password +``` + +The CLI finds that file by searching upward from the working directory, so +`osw status` now reports the instance and connection state. The MCP server does +not search: its settings come from the `env` block of its registration, see +[Registering a server](#registering-a-server). Everything that can be set is +listed under [Configuration](#configuration). + +## Command line ```bash osw status @@ -33,8 +75,9 @@ Commands are grouped by subject: Global options apply to every command: -- `--instance IRI` selects the instance for this invocation when more than one - is configured. The CLI is stateless, so the choice is never persisted. +- `--instance IRI` picks the instance. Optional: it is only required when the + `.env` sets no `OSW_DOMAIN` and the credential file it names holds more than + one iri. - `--json` / `-j` writes machine-readable JSON to stdout and keeps osw's own progress output on stderr, so it pipes cleanly into `jq`. - `--read-only` refuses write operations. @@ -42,142 +85,43 @@ Global options apply to every command: Failures exit non-zero with a short message on stderr and no traceback. -## Configuration - -Both the CLI and the MCP server read their settings from the environment or -from a `.env` file. Keep credentials in a gitignored file; they are read once -per process, into that process only, and never written back to disk. A real -environment variable always wins over the same name in a `.env` file. - -```dotenv -OSW_DOMAIN=wiki-dev.open-semantic-lab.org -OSW_USERNAME=your-user -OSW_PASSWORD=your-password -# optional -OSW_SPARQL_ENDPOINT=https://.../sparql -OSW_READ_ONLY=false # true hides all mutating tools -``` - -Alternatively, authenticate from an osw credential file, so the password is not -duplicated into a second plaintext file: - -```dotenv -OSW_DOMAIN=wiki-dev.open-semantic-lab.org -OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml -``` - -The file is the YAML format osw's `CredentialManager` already reads, keyed by -iri, so deployments that configure it need no extra setup: - -```yaml -wiki-dev.open-semantic-lab.org: - username: your-user - password: your-password -``` - -A credential file may hold several iris. The CLI selects one automatically if -it is the only one, and otherwise wants `osw --instance `. The MCP server -never selects one: it requires `OSW_DOMAIN`, see -[One server per instance](#mcp-server). - -The canonical variable names are `OSW_*`. Older `OSW_MCP_*` and `OSL_*` names -stay accepted so existing deployments keep working, and the first name that is -set wins: - -| Canonical | Also accepted | Meaning | -| --- | --- | --- | -| `OSW_DOMAIN` | `OSL_DOMAIN` | Instance to connect to | -| `OSW_USERNAME` | `OSL_USERNAME` | Login user | -| `OSW_PASSWORD` | `OSL_PASSWORD` | Login password | -| `OSW_CRED_FILEPATH` | `OSW_MCP_CRED_FILEPATH`, `OSL_CRED_FILEPATH` | YAML credential file, keyed by iri | -| `OSW_ENV_FILE` | `OSW_MCP_ENV_FILE` | `.env` file to load | -| `OSW_READ_ONLY` | `OSW_MCP_READ_ONLY` | `true` refuses every write | -| `OSW_SPARQL_ENDPOINT` | | Endpoint for `sparql` queries | -| `OSW_STATE_DIR` | `OSW_MCP_STATE_DIR` | Where the provenance ledger is kept | -| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) | -| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) | - -### Where the `.env` file comes from - -Set `OSW_ENV_FILE` to a path and that file is loaded, always. With it unset the -two adapters differ on purpose: - -- The **CLI** searches upward from the working directory, so a `.env` in a - project root applies to every `osw` command run anywhere inside it. -- The **MCP server** searches nowhere. Its working directory is picked by the - MCP client, so an implicit search would make the credentials it loads depend - on how the client happened to be launched. Point it at a file explicitly with - `OSW_ENV_FILE` in the server's `env` block (see below). - -Both print the sources they resolved to stderr before connecting: - -```text -[osw] env file : /home/me/project/.env (found from the working directory upward) -[osw] credential file: /abs/path/to/accounts.pwd.yaml -``` - -Quote Windows paths with single quotes, or leave them unquoted. A double-quoted -value in a `.env` file is escape-decoded, so `\a` in a path silently becomes a -BEL byte that renders as nothing: - -```dotenv -OSW_CRED_FILEPATH='C:\Users\me\accounts.pwd.yaml' # ok -OSW_CRED_FILEPATH=C:\Users\me\accounts.pwd.yaml # ok -OSW_CRED_FILEPATH="C:\Users\me\accounts.pwd.yaml" # broken: \a is eaten -``` - ## MCP server `osw[mcp]` ships an [MCP](https://modelcontextprotocol.io) server that exposes a live OpenSemanticLab instance to MCP clients such as Claude Code. It wraps `OswExpress` and provides tools to search (semantic / SPARQL / full-text), introspect category schemas, read entities and every page slot, create/update -and delete entities, and read and write file pages as text. - -```bash -pip install "osw[mcp]" -``` - -This extra is deliberately not part of `osw[all]`. It needs `anyio>=4.9`, which -conflicts with the pin the `osw[workflow]` extra requires for prefect 2.x, so -the two cannot share an environment -([#139](https://github.com/OpenSemanticLab/osw-python/issues/139)). Installing -the server standalone, for example via `uvx`, avoids the question entirely. +and delete entities, and read and write file pages as text. The transport is +stdio; SSE and HTTP are not supported. **No filesystem access:** no MCP tool takes or returns a local path. File content moves inline as text (`get_file_info`, `read_file_text`, `write_file_text`), and everything path-based lives in the CLI instead -(`osw file download`, `osw file upload`, `osw ledger path`). MCP does not imply -a shared host: a server can be containerised or remote, so a path argument is -either meaningless or a way to reach a filesystem nobody granted access to. A -CLI runs where the command was typed, under that user's own permissions, and an -agent calling it goes through whatever command permissions already apply to it. +(`osw file download`, `osw file upload`, `osw ledger path`). **One server per instance:** each server process is pinned to exactly one OSL instance for its whole lifetime; there is no tool to switch at runtime. `OSW_DOMAIN` must be set, either in the server entry's `env` block or in the -`.env` file that entry names. The server never picks an instance for you, not -even when the credential file holds exactly one iri: which instance a tool call -reaches has to be readable from the configuration. Without it the server -refuses to start rather than register tools that would all fail. +`.env` file that entry names. Without it the server refuses to start rather than +register tools that would all fail. + +### Registering a server -There are two ways to configure a server entry, and both are supported: +A server entry can carry its settings in two ways: -- **Directly in the entry's `env` block.** Every setting from the table above - can be set there, so no `.env` file is needed at all. +- **Directly in the entry's `env` block.** Every variable from the + [reference table](#variable-reference) can be set there, so no `.env` file is + needed at all. - **In a `.env` file**, named by `OSW_ENV_FILE` in the `env` block. Useful when several tools share one settings file, or when the client config is committed and the settings file is not. -The `env` block naming `OSW_CRED_FILEPATH` and `OSW_DOMAIN` is the preferred -form. It is more verbose, and that is the point: the destination instance is -spelled out in the entry itself, so it is visible at a glance and in a diff, -rather than being one indirection away in a file the entry merely points at. -The secret stays out of the client config either way, since a credential file -contributes a path and an instance name and nothing else. Never put -`OSW_PASSWORD` inline in a committed `.mcp.json`. - -The transport is stdio; SSE and HTTP are not supported. +Prefer the `env` block naming `OSW_CRED_FILEPATH` and `OSW_DOMAIN`: the +destination instance is spelled out in the entry itself, so it is visible at a +glance and in a diff rather than one indirection away. The secret stays out of +the client config either way, since a credential file contributes a path and an +instance name and nothing else. Never put `OSW_PASSWORD` inline in a committed +`.mcp.json`. ```json { @@ -199,23 +143,20 @@ At startup the server checks that the credential file has an entry matching `OSW_DOMAIN` and, if not, names the iris the file does contain (never their secrets), so a typo surfaces immediately rather than on the first tool call. -The `.env` variant of the same entry replaces the whole `env` block with -`{ "OSW_ENV_FILE": "/abs/path/to/dev.env" }`, where `dev.env` sets `OSW_DOMAIN` -and the credentials. - -Registering the same thing from a shell is easiest with `add-json`, which takes -the entry verbatim. Note that a Windows path needs forward slashes or doubled +Registering the same entry from a shell is easiest with `add-json`, which takes +it verbatim. Note that a Windows path needs forward slashes or doubled backslashes to be valid JSON: ```bash claude mcp add-json osw '{"type":"stdio","command":"uvx","args":["--from","osw[mcp]","osw-mcp"],"env":{"OSW_CRED_FILEPATH":"/abs/path/to/accounts.pwd.yaml","OSW_DOMAIN":"wiki-dev.open-semantic-lab.org"}}' ``` -To work with more than one instance, register one server per instance, each -pinned to a single `OSW_DOMAIN`. The two entries below show the two styles side -by side: `osw-dev` puts everything in a `.env` file, `osw-prod` names the -credential file and the domain directly. One credential file can serve any -number of servers, since it is keyed by iri. +### More than one instance + +Register one server per instance, each pinned to a single `OSW_DOMAIN`. The two +entries below show both styles side by side: `osw-dev` puts everything in a +`.env` file, `osw-prod` names the credential file and the domain directly. One +credential file can serve any number of servers, since it is keyed by iri. ```json { @@ -247,7 +188,7 @@ OSW_DOMAIN=wiki-dev.open-semantic-lab.org OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml ``` -That puts the instance in the tool name at every call site +The instance is then part of the tool name at every call site (`mcp__osw-prod__get_entity`), so the destination is visible in the permission prompt, read-only is settable per instance, and permissions can differ per instance: @@ -261,18 +202,130 @@ instance: } ``` -`status` reports the active instance and connection state (never the password). - -**Safe deletes:** the server records every entity it creates or modifies in a -local provenance ledger. It deletes those without extra prompting, but refuses -to delete anything it did not create unless the caller passes -`confirm_external_delete=true`. - -**Editable-checkout caveat:** `create_or_update_entity` and -`export_entity_jsonld` call `fetch_schema`, which regenerates -`src/osw/model/entity.py` inside the installed package. With a normal -`pip install "osw[mcp]"` this writes into site-packages and is harmless. If you -run the server from an editable source checkout, those two tools will modify the -generated model file in your working tree. The read tools (`get_entity`, -`get_slot`, `get_category_schema`, ...) read raw page slots and never trigger -this. +### Notes and caveats + +- `status` reports the active instance and connection state, never the password. +- **Safe deletes:** the server records every entity it creates or modifies in a + local provenance ledger. It deletes those without extra prompting, but refuses + to delete anything it did not create unless the caller passes + `confirm_external_delete=true`. +- **Editable checkouts:** `create_or_update_entity` and `export_entity_jsonld` + call `fetch_schema`, which regenerates `src/osw/model/entity.py` inside the + installed package. With a normal, non-editable install this writes into + site-packages and is harmless, but a server run from an editable source + checkout modifies that file in your working tree. The read tools + (`get_entity`, `get_slot`, `get_category_schema`, ...) read raw page slots and + never trigger it. + +## Configuration + +Both adapters share the settings below. + +### Where settings come from + +Settings are read from the process environment. A `.env` file is one optional +way to fill it, and a real environment variable always wins over the same name +in a file. + +- `OSW_ENV_FILE` set: exactly that file is loaded, and nothing is searched for. +- Unset, **CLI**: searches upward from the working directory, so a `.env` in a + project root applies to every `osw` command run anywhere inside it. +- Unset, **MCP server**: searches nowhere. Its working directory is picked by + the MCP client, so an implicit search would tie the credentials it loads to + how the client happened to be launched. + +Both print the sources they resolved to stderr before connecting: + +```text +[osw] env file : /home/me/project/.env (found from the working directory upward) +[osw] credential file: /abs/path/to/accounts.pwd.yaml +``` + +### Credentials + +Keep credentials in a gitignored file. They are read once per process, into that +process only, and never written back to disk. + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_USERNAME=your-user +OSW_PASSWORD=your-password +# optional +OSW_SPARQL_ENDPOINT=https://.../sparql +OSW_READ_ONLY=false # true hides all mutating tools +``` + +Alternatively, authenticate from an osw credential file, so the password is not +duplicated into a second plaintext file: + +```dotenv +OSW_DOMAIN=wiki-dev.open-semantic-lab.org +OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml +``` + +The file is the YAML format osw's `CredentialManager` already reads, keyed by +iri, so deployments that configure it need no extra setup: + +```yaml +wiki-dev.open-semantic-lab.org: + username: your-user + password: your-password +``` + +A credential file may hold several iris. The CLI selects one automatically if it +is the only one, and otherwise wants `osw --instance `. The MCP server +never selects one, see +[One server per instance](#mcp-server). + +### Variable reference + +The canonical variable names are `OSW_*`. Older `OSW_MCP_*` and `OSL_*` names +stay accepted so existing deployments keep working, and the first name that is +set wins: + +| Canonical | Also accepted | Meaning | +| --- | --- | --- | +| `OSW_DOMAIN` | `OSL_DOMAIN` | Instance to connect to | +| `OSW_USERNAME` | `OSL_USERNAME` | Login user | +| `OSW_PASSWORD` | `OSL_PASSWORD` | Login password | +| `OSW_CRED_FILEPATH` | `OSW_MCP_CRED_FILEPATH`, `OSL_CRED_FILEPATH` | YAML credential file, keyed by iri | +| `OSW_ENV_FILE` | `OSW_MCP_ENV_FILE` | `.env` file to load | +| `OSW_READ_ONLY` | `OSW_MCP_READ_ONLY` | `true` refuses every write | +| `OSW_SPARQL_ENDPOINT` | | Endpoint for `sparql` queries | +| `OSW_STATE_DIR` | `OSW_MCP_STATE_DIR` | Where the provenance ledger is kept | +| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) | +| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) | + +### Windows paths in a `.env` file + +Quote them with single quotes, or leave them unquoted. A double-quoted value is +escape-decoded, so `\a` in a path silently becomes a BEL byte that renders as +nothing: + +```dotenv +OSW_CRED_FILEPATH='C:\Users\me\accounts.pwd.yaml' # ok +OSW_CRED_FILEPATH=C:\Users\me\accounts.pwd.yaml # ok +OSW_CRED_FILEPATH="C:\Users\me\accounts.pwd.yaml" # broken: \a is eaten +``` + +## Design notes + +Why the two adapters are shaped the way they are: + +- **No filesystem access on the MCP surface.** MCP does not imply a shared host: + a server can be containerised or remote, so a path argument is either + meaningless or a way to reach a filesystem nobody granted access to. A CLI + runs where the command was typed, under that user's own permissions, and an + agent calling it goes through whatever command permissions already apply. +- **One instance per server process.** Which instance a tool call reaches has to + be readable from the configuration rather than inferred, so the server never + picks one for you, not even when the credential file holds exactly one iri. +- **stdio only.** SSE is deprecated upstream, and HTTP would need a + per-connection auth model this server does not have: it holds one set of wiki + credentials, which every client would share. +- **`osw[mcp]` outside `osw[all]`.** It needs `anyio>=4.9`, which conflicts with + the pin the `osw[workflow]` extra requires for prefect 2.x, so the two cannot + share an environment + ([#139](https://github.com/OpenSemanticLab/osw-python/issues/139)). + Installing the server standalone, for example via `uvx`, avoids the question + entirely. From 3a41c4836e5f0042734b66d06adb7245aaf386cf Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 28 Aug 2026 11:17:39 +0200 Subject: [PATCH 19/28] docs: simplify the CLI and MCP setup section - lead with uv tool install, the mcp extra includes the base package - fold the pip, uv add and uvx variants into a details element - move the editable-install caveat to a Notes for developers section - document running the server from a local checkout via uvx --from --- docs/cli-and-mcp.md | 64 +++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/docs/cli-and-mcp.md b/docs/cli-and-mcp.md index 49b8139..1b6c0ff 100644 --- a/docs/cli-and-mcp.md +++ b/docs/cli-and-mcp.md @@ -8,32 +8,30 @@ differ in exactly one way: only the CLI accepts filesystem paths. ## Setup -Installing osw provides the `osw` command; the `osw[mcp]` extra adds the -`osw-mcp` server: +Install one of the two; the second includes the first: -=== "uv (recommended)" +```bash +uv tool install osw # the `osw` command +uv tool install "osw[mcp]" # the same, plus the `osw-mcp` server +``` - ```bash - uv add osw - uv add "osw[mcp]" - ``` +
+Other ways to install -=== "pip" +```bash +pip install "osw[mcp]" # into the active environment +uv add "osw[mcp]" # as a dependency of the current uv project +uvx --from "osw[mcp]" osw-mcp # run the server without installing it +``` - ```bash - pip install osw - pip install "osw[mcp]" - ``` +`uvx` is what the registration examples further down use, so the server needs +no install of its own. + +
`osw[mcp]` is not part of `osw[all]`, see [Design notes](#design-notes). The other extras are listed in the -[Get Started guide](get-started.md#optional-extras). The server also runs -without being installed at all, which is what the registration examples below -do: - -```bash -uvx --from "osw[mcp]" osw-mcp -``` +[Get Started guide](get-started.md#optional-extras). Both adapters need an instance and credentials. The quickest start is a gitignored `.env` file in your project root: @@ -209,13 +207,6 @@ instance: local provenance ledger. It deletes those without extra prompting, but refuses to delete anything it did not create unless the caller passes `confirm_external_delete=true`. -- **Editable checkouts:** `create_or_update_entity` and `export_entity_jsonld` - call `fetch_schema`, which regenerates `src/osw/model/entity.py` inside the - installed package. With a normal, non-editable install this writes into - site-packages and is harmless, but a server run from an editable source - checkout modifies that file in your working tree. The read tools - (`get_entity`, `get_slot`, `get_category_schema`, ...) read raw page slots and - never trigger it. ## Configuration @@ -329,3 +320,24 @@ Why the two adapters are shaped the way they are: ([#139](https://github.com/OpenSemanticLab/osw-python/issues/139)). Installing the server standalone, for example via `uvx`, avoids the question entirely. + +## Notes for developers + +To try an unreleased branch against a real client, point `uvx` at the checkout +instead of at PyPI. Everything else about the registration stays the same: + +```bash +uvx --reinstall --from "/abs/path/to/osw-python[mcp]" osw-mcp +``` + +`--reinstall` is what picks up your latest edits, since `uvx` caches the wheel +it builds. In a JSON `args` array, a Windows path needs forward slashes or +doubled backslashes. + +Prefer that over an editable install for the server. `create_or_update_entity` +and `export_entity_jsonld` call `fetch_schema`, which regenerates +`src/osw/model/entity.py` inside the installed package: `uvx` builds a +non-editable wheel, so the write lands in the uv cache, while under +`pip install -e` or `uv sync` it lands in your working tree. The read tools +(`get_entity`, `get_slot`, `get_category_schema`, ...) read raw page slots and +never trigger it. From 84b69e967a1245793e9994b65b0af2cd969b6b94 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 28 Aug 2026 11:29:03 +0200 Subject: [PATCH 20/28] added local folders to .gitignore --- .gitignore | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index dc1ba37..a362bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -69,5 +69,8 @@ playground /osw_files/ */accounts.pwd.yaml /accounts.pwd.yaml -.ign -.claude + +# Local folders +.ign/ +.claude/ +graphify-out/ From 951a66e6389d2b753397538a851996bd5343088f Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 28 Aug 2026 11:48:49 +0200 Subject: [PATCH 21/28] docs: correct why the CLI may infer an instance - --instance is optional, so it is not what makes inference acceptable - the CLI resolves per invocation and reports the instance it resolved - state the --instance condition as OSW_DOMAIN unset, not .env-specific --- docs/cli-and-mcp.md | 4 ++-- src/osw/mcp/server.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cli-and-mcp.md b/docs/cli-and-mcp.md index 1b6c0ff..94f170d 100644 --- a/docs/cli-and-mcp.md +++ b/docs/cli-and-mcp.md @@ -73,8 +73,8 @@ Commands are grouped by subject: Global options apply to every command: -- `--instance IRI` picks the instance. Optional: it is only required when the - `.env` sets no `OSW_DOMAIN` and the credential file it names holds more than +- `--instance IRI` picks the instance. Optional: it is only required when + `OSW_DOMAIN` is not set and the configured credential file holds more than one iri. - `--json` / `-j` writes machine-readable JSON to stdout and keeps osw's own progress output on stderr, so it pipes cleanly into `jq`. diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py index 5fef915..331d888 100644 --- a/src/osw/mcp/server.py +++ b/src/osw/mcp/server.py @@ -107,8 +107,8 @@ def _build_server() -> tuple[MCPServer, Context]: statically pinned to one OSL instance for its whole lifetime, and which one that is has to be readable from the configuration rather than inferred. Deliberately stricter than :func:`config.get_active_domain`, which the CLI - uses: there the instance is visible on the command line at every - invocation, and ``--instance`` can override it per command. + uses: there the instance is resolved per invocation and reported at + startup, and ``--instance`` can override it per command. """ # Before get_settings(), so a misconfiguration that makes loading raise # still reports which files were read. stderr, so it lands in the MCP From 8f81fec145fda977544f9d95576b7edf225c26a1 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 28 Aug 2026 15:58:51 +0200 Subject: [PATCH 22/28] refactor: de-isolate the mcp extra from the dev environment - drop the ty: ignore on the mcp SDK imports in server.py - run the MCP tests unconditionally instead of importorskip-ing them - update the config.py env-file hint (no more `test` group) - docs: mcp is part of osw[all]; replace the anyio design note --- README.md | 2 +- docs/cli-and-mcp.md | 12 ++++-------- docs/get-started.md | 2 +- src/osw/mcp/server.py | 7 ++----- src/osw/service/config.py | 3 +-- tests/integration/test_mcp_server.py | 3 --- tests/test_mcp_registration.py | 4 ---- tests/test_mcp_server.py | 3 --- tests/test_no_paths_on_mcp_surface.py | 12 +++--------- 9 files changed, 12 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 3c19612..bd932dc 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ OpenSemanticLab data model. ## CLI and MCP tools -Installing `osw` also installs an `osw` command line client, and the separate +Installing `osw` also installs an `osw` command line client, and the `osw[mcp]` extra adds an MCP server that exposes a live instance to agent clients such as Claude Code: diff --git a/docs/cli-and-mcp.md b/docs/cli-and-mcp.md index 94f170d..4bb68a6 100644 --- a/docs/cli-and-mcp.md +++ b/docs/cli-and-mcp.md @@ -29,8 +29,7 @@ no install of its own. -`osw[mcp]` is not part of `osw[all]`, see [Design notes](#design-notes). The -other extras are listed in the +`osw[mcp]` is also part of `osw[all]`. The other extras are listed in the [Get Started guide](get-started.md#optional-extras). Both adapters need an instance and credentials. The quickest start is a @@ -314,12 +313,9 @@ Why the two adapters are shaped the way they are: - **stdio only.** SSE is deprecated upstream, and HTTP would need a per-connection auth model this server does not have: it holds one set of wiki credentials, which every client would share. -- **`osw[mcp]` outside `osw[all]`.** It needs `anyio>=4.9`, which conflicts with - the pin the `osw[workflow]` extra requires for prefect 2.x, so the two cannot - share an environment - ([#139](https://github.com/OpenSemanticLab/osw-python/issues/139)). - Installing the server standalone, for example via `uvx`, avoids the question - entirely. +- **`mcp` is an extra, not a base dependency.** The SDK pulls in a server stack + (starlette, uvicorn, sse-starlette) that nothing in the Python API or the CLI + needs, so only users who actually run the server pay for it. ## Notes for developers diff --git a/docs/get-started.md b/docs/get-started.md index e01f2a2..efc4f2a 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -30,8 +30,8 @@ | `osw[S3]` | Interact with S3 stores per S3FileController | | `osw[dataimport]` | Additional tools to import data | | `osw[UI]` | To use a helper UI to work with entity slots | +| `osw[mcp]` | [MCP server](cli-and-mcp.md#mcp-server) for agent clients | | `osw[all]` | All of the above | -| `osw[mcp]` | [MCP server](cli-and-mcp.md#mcp-server) for agent clients, not part of `osw[all]` | Install multiple extras with `pip install osw[opt1,opt2]`. diff --git a/src/osw/mcp/server.py b/src/osw/mcp/server.py index 331d888..b15f5cc 100644 --- a/src/osw/mcp/server.py +++ b/src/osw/mcp/server.py @@ -12,11 +12,8 @@ import sys from typing import Any, Optional -# ty cannot resolve these: the mcp extra is uninstallable alongside the dev -# group (anyio conflict, issue #139), so it is absent from the env ty runs in. -# The rest of this module is type-checked; drop the ignores once #139 is fixed. -from mcp.server import MCPServer # ty: ignore[unresolved-import] -from mcp.types import ToolAnnotations # ty: ignore[unresolved-import] +from mcp.server import MCPServer +from mcp.types import ToolAnnotations import osw import osw.service.ops diff --git a/src/osw/service/config.py b/src/osw/service/config.py index 14dabac..084d881 100644 --- a/src/osw/service/config.py +++ b/src/osw/service/config.py @@ -226,8 +226,7 @@ def _load_env_file() -> None: name = next((n for n in ENV_FILE if os.getenv(n) == path), ENV_FILE[0]) raise RuntimeError( f"{name} is set (to '{path}') but python-dotenv is not installed. " - "Install the osw[mcp] extra, or the `test` dependency group, to " - "use an env file." + "Install the osw[mcp] extra to use an env file." ) if path: dotenv.load_dotenv(path) diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py index a120631..d6598fc 100644 --- a/tests/integration/test_mcp_server.py +++ b/tests/integration/test_mcp_server.py @@ -11,9 +11,6 @@ import pytest -pytest.importorskip("mcp", reason="requires the osw[mcp] extra") -pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") - import osw.service.ops # noqa: F401 (registers the operations) from osw.service import config from osw.service.context import Context, Policy diff --git a/tests/test_mcp_registration.py b/tests/test_mcp_registration.py index 7b0fd4f..d708251 100644 --- a/tests/test_mcp_registration.py +++ b/tests/test_mcp_registration.py @@ -8,10 +8,6 @@ from __future__ import annotations -import pytest - -pytest.importorskip("mcp", reason="requires the osw[mcp] extra") - from mcp.types import ToolAnnotations from osw.mcp.server import _annotations, _meta, tool_kwargs diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index d84c772..6dfa5ac 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -14,9 +14,6 @@ import pytest import yaml -pytest.importorskip("mcp", reason="requires the osw[mcp] extra") -pytest.importorskip("dotenv", reason="requires the osw[mcp] extra") - from osw.mcp import server from osw.service import config from osw.service.registry import iter_operations diff --git a/tests/test_no_paths_on_mcp_surface.py b/tests/test_no_paths_on_mcp_surface.py index 8d5eb07..49ac041 100644 --- a/tests/test_no_paths_on_mcp_surface.py +++ b/tests/test_no_paths_on_mcp_surface.py @@ -1,11 +1,8 @@ """Guard tests: no filesystem path may ever reach the MCP surface. -Runs in the plain dev env (no mcp extra needed): importing ``osw.cli.ops`` -(to register the CLI-only, path-taking operations, so the negative check -below cannot pass vacuously) and ``osw.service.ops`` touches neither the -``mcp`` SDK nor the network. Only ``test_mcp_server_never_imports_cli`` -needs the ``mcp`` extra (it imports ``osw.mcp.server`` itself), and -self-skips without it. +Offline: importing ``osw.cli.ops`` (to register the CLI-only, path-taking +operations, so the negative check below cannot pass vacuously) and +``osw.service.ops`` touches the network nowhere. """ from __future__ import annotations @@ -15,8 +12,6 @@ import sys from unittest.mock import MagicMock -import pytest - # Registers every operation, including the CLI-only path-taking ones, so # osw.service.registry.REGISTRY is fully populated for the checks below. import osw.cli.ops @@ -72,7 +67,6 @@ def test_bound_operations_do_not_expose_ctx(): def test_mcp_server_never_imports_cli(): - pytest.importorskip("mcp", reason="requires the osw[mcp] extra") result = subprocess.run( [ sys.executable, From be75e47ced3ad4692f5b14a30d509d09c0a39c2e Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 28 Aug 2026 16:06:29 +0200 Subject: [PATCH 23/28] docs: drop remaining references to the separate MCP environment - test docstrings no longer contrast against a plain dev env - deptry comment no longer claims extras are absent from dev - README lists osw[mcp] among the extras --- README.md | 2 +- pyproject.toml | 4 ++-- tests/test_cli.py | 4 ++-- tests/test_service_instances.py | 5 ++--- tests/test_service_ops_files.py | 2 +- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index bd932dc..3866a67 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ pip install osw ``` Optional extras (`osw[wikitext]`, `osw[DB]`, `osw[S3]`, `osw[dataimport]`, -`osw[UI]`, `osw[all]`) are described in the +`osw[UI]`, `osw[mcp]`, `osw[all]`) are described in the [Get Started guide](https://opensemanticlab.github.io/osw-python/get-started/). ## Quickstart diff --git a/pyproject.toml b/pyproject.toml index 36c874b..19201ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -362,8 +362,8 @@ opensemantic-core = "opensemantic" opensemantic-base = "opensemantic" pybars3-wheel = "pybars" "backports.strenum" = "backports" -# extras packages not installed in the dev env, mapped explicitly so -# deptry does not have to guess ("Assuming ..." warnings) +# extras packages whose import name deptry would otherwise have to guess +# ("Assuming ..." warnings) psycopg2 = "psycopg2" openpyxl = "openpyxl" pysimplegui = "PySimpleGUI" diff --git a/tests/test_cli.py b/tests/test_cli.py index d932458..1871373 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,7 @@ """Unit tests for the osw CLI (src/osw/cli). -Runs in the plain dev env (no mcp extra needed): the CLI never imports the -mcp SDK. No network is touched -- ``osw.service.context.OswExpress`` is +The CLI never imports the mcp SDK, and no network is touched -- +``osw.service.context.OswExpress`` is patched wherever a test actually reaches a command's body. """ diff --git a/tests/test_service_instances.py b/tests/test_service_instances.py index 043e775..afbfab7 100644 --- a/tests/test_service_instances.py +++ b/tests/test_service_instances.py @@ -1,8 +1,7 @@ """Unit tests for multi-instance selection in osw.service (config + Context). -These are fully offline: no network, no live wiki. They also need no MCP SDK: -osw.service is deliberately SDK-free, so unlike tests/test_mcp_*.py these run -in the default dev environment. +These are fully offline: no network, no live wiki, and no MCP SDK, since +osw.service is deliberately SDK-free. """ import pytest diff --git a/tests/test_service_ops_files.py b/tests/test_service_ops_files.py index efe7d01..83a11ca 100644 --- a/tests/test_service_ops_files.py +++ b/tests/test_service_ops_files.py @@ -1,6 +1,6 @@ """Unit tests for osw.service.ops.files (Operation.fn called directly). -Runs in the plain dev env (no mcp extra, no network): ``WikiFileController`` +Fully offline: ``WikiFileController`` is replaced with a fake factory that records its constructor arguments, so every test can inspect the title/namespace a real controller would have derived without touching a wiki. From 64d99d66ea45bc56bdaa6d12f2502df0bbbaf88c Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 28 Aug 2026 16:10:45 +0200 Subject: [PATCH 24/28] fix: allow uploading a file from an in-memory stream Closes #140. - express.py: replace the unreachable isinstance(source, IO) check with a duck-typed one; typing.IO is not runtime-checkable - InMemoryController: drop the __init__ that assigned stream before the model was initialised and overwrote a caller-supplied stream - default the stream to BytesIO, matching the byte-oriented get/put - declare IO in the upload_file / osw_upload_file signatures --- src/osw/controller/file/memory.py | 15 +++--- src/osw/express.py | 12 +++-- tests/test_in_memory_upload.py | 76 +++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 tests/test_in_memory_upload.py diff --git a/src/osw/controller/file/memory.py b/src/osw/controller/file/memory.py index 26e5de7..5bd9658 100644 --- a/src/osw/controller/file/memory.py +++ b/src/osw/controller/file/memory.py @@ -1,27 +1,26 @@ import shutil -from io import StringIO +from io import BytesIO from typing import IO, Any, Dict, List, Optional +from pydantic.v1 import Field + from osw.controller.file.base import FileController from osw.core import model class InMemoryController(FileController, model.LocalFile): - """File controller for local files""" + """File controller for in-memory streams""" label: Optional[List[model.Label]] = [model.Label(text="Unnamed stream")] """the label of the stream, e.g., the name of the file the stream originates from. Defaults to 'Unnamed stream'.""" - stream: IO - """the stream to the file""" + stream: Any = Field(default_factory=BytesIO) + """the stream to the file, any file-like object. Defaults to an empty + binary buffer. Byte-oriented, to match the get/put counterparts.""" class Config: arbitrary_types_allowed = True - def __init__(self, **kwargs): - self.stream = StringIO() - super().__init__(**kwargs) - def get(self) -> IO: return self.stream diff --git a/src/osw/express.py b/src/osw/express.py index a865a82..a54aa72 100644 --- a/src/osw/express.py +++ b/src/osw/express.py @@ -231,7 +231,7 @@ def download_file( def upload_file( self, - source: Union["LocalFileController", "WikiFileController", str, Path], + source: Union["LocalFileController", "WikiFileController", str, Path, IO], url_or_title: Optional[str] = None, overwrite: OVERWRITE_CLASS_OPTIONS = OverwriteOptions.true, delete_after_use: bool = False, @@ -247,7 +247,7 @@ def upload_file( ---------- source The source file to upload. Can be a LocalFileController, WikiFileController, - str or Path. + str, Path or an open file-like object. url_or_title The URL or full page title of the WikiFile page to upload the file to. Used to overwrite autogenerated full page title on the target domain. If it is @@ -635,7 +635,9 @@ def __init__( data["path"] = Path(source) data["source"] = Path(source) data["source_file_controller"] = LocalFileController(path=data.get("path")) - elif isinstance(source, IO): + # duck-typed: typing.IO is not runtime-checkable, isinstance(BytesIO(), IO) + # is False, so an explicit isinstance check would never match a stream + elif hasattr(source, "read"): data["source_file_controller"] = InMemoryController(stream=source) else: raise ValueError( @@ -736,7 +738,7 @@ def __init__( def osw_upload_file( - source: Union[LocalFileController, WikiFileController, str, Path], + source: Union[LocalFileController, WikiFileController, str, Path, IO], url_or_title: Optional[str] = None, overwrite: OVERWRITE_CLASS_OPTIONS = OverwriteOptions.true, delete_after_use: bool = False, @@ -756,7 +758,7 @@ def osw_upload_file( ---------- source The source file to upload. Can be a LocalFileController, WikiFileController, - str or Path. + str, Path or an open file-like object. url_or_title The URL or full page title of the WikiFile page to upload the file to. Used to overwrite autogenerated full page title on the target domain. If it is diff --git a/tests/test_in_memory_upload.py b/tests/test_in_memory_upload.py new file mode 100644 index 0000000..9713c06 --- /dev/null +++ b/tests/test_in_memory_upload.py @@ -0,0 +1,76 @@ +"""Unit tests for uploading a file from an in-memory stream (issue #140). + +Fully offline: no network, no live wiki. The upload path is cut short by +replacing ``WikiFileController.from_other`` with a stub that records the +source controller it was handed, so the dispatch in ``UploadFileResult`` can +be checked without touching a wiki. +""" + +from __future__ import annotations + +from io import BytesIO +from unittest.mock import MagicMock + +import pytest + +import osw.express +from osw.controller.file.memory import InMemoryController + + +def test_controller_accepts_a_caller_supplied_stream(): + stream = BytesIO(b"payload") + controller = InMemoryController(stream=stream) + assert controller.get() is stream + assert controller.get().read() == b"payload" + + +def test_controller_defaults_to_an_empty_binary_buffer(): + controller = InMemoryController() + assert isinstance(controller.stream, BytesIO) + assert controller.stream.getvalue() == b"" + + +def test_controller_put_copies_into_the_stream(): + controller = InMemoryController() + controller.put(BytesIO(b"payload")) + assert controller.stream.getvalue() == b"payload" + + +def test_upload_wraps_a_stream_in_an_in_memory_controller(monkeypatch): + """A BytesIO must reach WikiFileController as an InMemoryController. + + Guards the duck-typed source check in UploadFileResult.__init__: an + ``isinstance(source, IO)`` test never matches, because typing.IO is not + runtime-checkable. + """ + stream = BytesIO(b"payload") + seen = {} + + class _StopBeforeUpload(Exception): + pass + + def _fake_from_other(other, osw, **data): + seen["source_file_controller"] = other + raise _StopBeforeUpload + + monkeypatch.setattr( + osw.express.WikiFileController, + "from_other", + staticmethod(_fake_from_other), + ) + + with pytest.raises(_StopBeforeUpload): + osw.express.UploadFileResult( + source=stream, + osw_express=MagicMock(), + target_fpt="File:Test.bin", + ) + + controller = seen["source_file_controller"] + assert isinstance(controller, InMemoryController) + assert controller.get() is stream + + +def test_upload_rejects_a_source_that_is_not_file_like(): + with pytest.raises(ValueError, match="must be a LocalFileController"): + osw.express.UploadFileResult(source=object(), osw_express=MagicMock()) From 2051c26174e149455bf7111c0e8671d13259eddb Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 28 Aug 2026 16:20:43 +0200 Subject: [PATCH 25/28] refactor(service): validate Settings with pydantic - convert Settings from a frozen dataclass to a frozen pydantic model - add validators for domain, sparql_endpoint, state_dir, cred_filepath - constrain max_results/max_chars to positive integers via Field(gt=0) - drop _int_env in favour of one ValidationError -> RuntimeError site that still names the exact alias that was set Closes #143 --- src/osw/service/config.py | 131 ++++++++++++++++++++++++++++------- tests/test_service_config.py | 72 +++++++++++++++++++ 2 files changed, 178 insertions(+), 25 deletions(-) diff --git a/src/osw/service/config.py b/src/osw/service/config.py index 084d881..030c106 100644 --- a/src/osw/service/config.py +++ b/src/osw/service/config.py @@ -12,12 +12,12 @@ import os import sys -from dataclasses import dataclass, field from pathlib import Path from typing import Optional from urllib.parse import urlparse import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator from osw.auth import CredentialManager @@ -51,10 +51,41 @@ def _first_env(names: tuple[str, ...]) -> Optional[str]: return None -@dataclass(frozen=True) -class Settings: +# Which env variable tuple feeds each Settings field, used to name the offending +# variable when pydantic rejects a value. +_ENV_BY_FIELD: dict[str, tuple[str, ...]] = { + "domain": ENV_DOMAIN, + "username": ENV_USERNAME, + "password": ENV_PASSWORD, + "cred_filepath": ENV_CRED_FILEPATH, + "sparql_endpoint": ENV_SPARQL_ENDPOINT, + "read_only": ENV_READ_ONLY, + "state_dir": ENV_STATE_DIR, + "max_results": ENV_MAX_RESULTS, + "max_chars": ENV_MAX_CHARS, +} + + +def _env_name_for(field_name: str) -> str: + """Name the env variable that actually supplied ``field_name``. + + Falls back to the canonical name so the operator always gets something + actionable to fix. + """ + names = _ENV_BY_FIELD.get(field_name, ()) + if not names: + return field_name + for name in names: + if os.getenv(name): + return name + return names[0] + + +class Settings(BaseModel): """Resolved, validated server settings.""" + model_config = ConfigDict(frozen=True) + # domain is optional: with a usable credential file, no domain need be # configured via the environment; the active instance is then chosen from # the credential file (auto-selected, or picked with the CLI's --instance). @@ -63,13 +94,59 @@ class Settings: # alternative source of credentials (see ENV_CRED_FILEPATH). username: Optional[str] = None # kept only to build the SPARQL client; never returned by any tool - password: Optional[str] = field(default=None, repr=False) + password: Optional[str] = Field(default=None, repr=False) cred_filepath: Optional[str] = None sparql_endpoint: Optional[str] = None read_only: bool = False state_dir: Optional[str] = None - max_results: int = 100 - max_chars: int = 100_000 + max_results: int = Field(default=100, gt=0) + max_chars: int = Field(default=100_000, gt=0) + + @field_validator("domain") + @classmethod + def _validate_domain(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + if not value.strip(): + raise ValueError("must not be empty or whitespace-only") + if any(char.isspace() for char in value): + raise ValueError("must not contain whitespace") + if any(ord(char) < 32 for char in value): + raise ValueError("must not contain control characters") + return value + + @field_validator("sparql_endpoint") + @classmethod + def _validate_sparql_endpoint(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + parsed = urlparse(value) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ValueError( + "must be a valid http(s) URL (e.g. 'https://wiki.example.org/sparql')" + ) + return value + + @field_validator("state_dir") + @classmethod + def _validate_state_dir(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + if not value.strip(): + raise ValueError("must not be empty or whitespace-only") + return value + + @field_validator("cred_filepath") + @classmethod + def _validate_cred_filepath(cls, value: Optional[str]) -> Optional[str]: + # Control characters are deliberately not rejected here: load() already + # produces a much better, hint-carrying error for that case via + # _escape_hint(), and that check runs before Settings is constructed. + if value is None: + return value + if not value.strip(): + raise ValueError("must not be empty or whitespace-only") + return value def redacted(self) -> dict: """A dict view safe for logging / the status tool (no password).""" @@ -82,21 +159,6 @@ def redacted(self) -> dict: } -def _int_env(names: tuple[str, ...], default: int) -> int: - raw = _first_env(names) - if raw is None or raw.strip() == "": - return default - try: - return int(raw) - except ValueError: - # Name the variable that was actually set (not necessarily the - # canonical one), so the operator can find what to fix. - name = next((n for n in names if os.getenv(n) == raw), names[0]) - raise RuntimeError( - f"Environment variable {name}={raw!r} is not a valid integer." - ) - - def _escape_hint(value: str) -> str: """Extra error text when ``value`` holds a control character, else "". @@ -292,7 +354,9 @@ def load(strict: bool = True) -> Settings: report "not configured" rather than crash. Every other error still raises regardless of ``strict``: a configured credential file that does not exist, a configured credential file with no entry matching a - configured domain, an unparseable integer environment variable, and a + configured domain, an environment variable holding a value the + settings model rejects (an unparseable or non-positive integer, a + malformed SPARQL endpoint URL, a domain containing whitespace), and a missing ``python-dotenv`` for an explicitly configured env file. Raises @@ -347,7 +411,7 @@ def load(strict: bool = True) -> Settings: if cred_file_usable and domain: _verify_cred_file_has_domain(cred_filepath, domain) - return Settings( + kwargs: dict = dict( domain=domain, username=username, password=password, @@ -355,9 +419,26 @@ def load(strict: bool = True) -> Settings: sparql_endpoint=_first_env(ENV_SPARQL_ENDPOINT), read_only=(_first_env(ENV_READ_ONLY) or "").lower() in _TRUTHY, state_dir=_first_env(ENV_STATE_DIR), - max_results=_int_env(ENV_MAX_RESULTS, 100), - max_chars=_int_env(ENV_MAX_CHARS, 100_000), ) + # An unset or blank/whitespace-only integer variable falls back to the + # model default; pass the raw string only when there is one to validate. + max_results_raw = _first_env(ENV_MAX_RESULTS) + if max_results_raw is not None and max_results_raw.strip(): + kwargs["max_results"] = max_results_raw + max_chars_raw = _first_env(ENV_MAX_CHARS) + if max_chars_raw is not None and max_chars_raw.strip(): + kwargs["max_chars"] = max_chars_raw + + try: + return Settings(**kwargs) + except ValidationError as exc: + details = [] + for err in exc.errors(): + field_name = str(err["loc"][0]) if err["loc"] else "" + details.append( + f"{_env_name_for(field_name)}={err.get('input')!r}: {err['msg']}" + ) + raise RuntimeError("Invalid OSW configuration: " + "; ".join(details)) from exc _settings: Optional[Settings] = None diff --git a/tests/test_service_config.py b/tests/test_service_config.py index 44b7b2c..c4bbbdb 100644 --- a/tests/test_service_config.py +++ b/tests/test_service_config.py @@ -4,8 +4,10 @@ import pytest import yaml +from pydantic import ValidationError from osw.service import config +from osw.service.config import Settings _ALL_VARS = [ "OSW_DOMAIN", @@ -531,3 +533,73 @@ def test_log_config_sources_omits_cred_file_when_unconfigured(monkeypatch, capsy captured = capsys.readouterr() assert "env file" in captured.err assert "credential file" not in captured.err + + +# -- Settings validation (pydantic) ------------------------------------------ + + +def test_blank_max_results_falls_back_to_default(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MAX_RESULTS", " ") + settings = config.load() + assert settings.max_results == 100 + + +def test_zero_max_results_raises(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MAX_RESULTS", "0") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_MAX_RESULTS" in str(exc.value) + + +def test_malformed_sparql_endpoint_raises(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_SPARQL_ENDPOINT", "not a url") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_SPARQL_ENDPOINT" in str(exc.value) + + +def test_valid_sparql_endpoint_loads(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_SPARQL_ENDPOINT", "https://wiki.example.org/sparql") + settings = config.load() + assert settings.sparql_endpoint == "https://wiki.example.org/sparql" + + +def test_error_names_the_alias_that_was_set(monkeypatch): + # Only the alias is set (not the canonical name), so the error must name + # the alias, not the canonical variable, for the operator to find it. + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MCP_MAX_RESULTS", "notanumber") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_MCP_MAX_RESULTS" in str(exc.value) + + +def test_domain_with_whitespace_rejected(): + with pytest.raises(ValidationError): + Settings(domain="wiki.example.org has a space") + + +def test_domain_as_full_url_accepted(): + # get_active_domain() relies on a full URL being a legal domain value. + settings = Settings(domain="https://wiki.example.org/w/") + assert settings.domain == "https://wiki.example.org/w/" + + +def test_settings_is_frozen(): + settings = Settings(domain="wiki.example.org") + with pytest.raises(ValidationError): + settings.domain = "other.example.org" From 4b7dd7e5670eee4a230751412ad756153a9f2387 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 28 Aug 2026 17:16:40 +0200 Subject: [PATCH 26/28] test: stop test_init_from_env_vars leaking OSW_CRED_FILEPATH - use monkeypatch.setenv so OSW_CRED_FILEPATH and OSW_DOMAIN are restored - the test unlinks its credential file, so the leaked path pointed every later test at a missing file - surfaced by the mcp de-isolation: tests/integration/test_mcp_server.py no longer skips for a missing SDK, so it hit the polluted environment --- tests/integration/test_express.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/integration/test_express.py b/tests/integration/test_express.py index 2c803e5..55e92d8 100644 --- a/tests/integration/test_express.py +++ b/tests/integration/test_express.py @@ -22,7 +22,6 @@ * test_upload_file """ -import os import uuid from contextlib import contextmanager from pathlib import Path @@ -102,11 +101,13 @@ def test_init_with_domain(wiki_domain, wiki_username, wiki_password, mocker): osw_express.shut_down() -def test_init_from_env_vars(wiki_domain, wiki_username, wiki_password): +def test_init_from_env_vars(monkeypatch, wiki_domain, wiki_username, wiki_password): + # monkeypatch, not os.environ: the file is unlinked at the end of this test, so + # a leaked OSW_CRED_FILEPATH would point every later test at a missing file. cred_filepath = Path.cwd() / "accounts.pwd.yaml" - os.environ["OSW_CRED_FILEPATH"] = str(cred_filepath) + monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_filepath)) create_credentials_file(cred_filepath, wiki_domain, wiki_username, wiki_password) - os.environ["OSW_DOMAIN"] = wiki_domain + monkeypatch.setenv("OSW_DOMAIN", wiki_domain) osw_express = osw.express.OswExpress() osw_express_and_credentials(osw_express, wiki_domain, wiki_username, wiki_password) From f4ef72cdd2e73e42f01d36717f25b8239d1ab8c1 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 28 Aug 2026 17:28:33 +0200 Subject: [PATCH 27/28] test: do not assume the first ask-query hit carries jsondata - SMW ask results have no defined order and Category:Item can hold pages without a jsondata slot - scan all returned titles, require at least one with the slot --- tests/integration/test_mcp_server.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py index d6598fc..da8136a 100644 --- a/tests/integration/test_mcp_server.py +++ b/tests/integration/test_mcp_server.py @@ -61,15 +61,21 @@ def test_search_schema_and_read(mcp_tools): category_schema = mcp_tools["get_category_schema"](category="Category:Item") assert "exists" in category_schema - if found["titles"]: - title = found["titles"][0] - entity = mcp_tools["get_entity"](title=title) - assert entity["title"] == title - assert entity["exists"] is True - - page_slots = mcp_tools["list_page_slots"](title=title) - assert page_slots["exists"] is True - assert any(s["key"] == "jsondata" for s in page_slots["slots"]) + # An ask query has no defined result order and a category can hold pages + # without a jsondata slot, so check every hit rather than trusting the first. + titles = found["titles"] + if titles: + with_jsondata = [] + for title in titles: + entity = mcp_tools["get_entity"](title=title) + assert entity["title"] == title + assert entity["exists"] is True + + page_slots = mcp_tools["list_page_slots"](title=title) + assert page_slots["exists"] is True + if any(s["key"] == "jsondata" for s in page_slots["slots"]): + with_jsondata.append(title) + assert with_jsondata, f"no jsondata slot on any of {titles}" def test_delete_guard_blocks_untracked(mcp_tools): From 56711ed878b260288ba6100c9fdf2623544d43b7 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Mon, 31 Aug 2026 16:59:04 +0200 Subject: [PATCH 28/28] fix(service): validate read_only via pydantic instead of truthy set - route OSW_READ_ONLY through Settings so an unparseable value raises - a typo like "ture" previously yielded False, silently enabling writes - "y"/"t" now parse as true; all documented spellings keep working - blank/whitespace-only still falls back to the default, as for the ints - drop the now-unused _TRUTHY set Follow-up to #143. --- src/osw/service/config.py | 13 ++++++---- tests/test_service_config.py | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/osw/service/config.py b/src/osw/service/config.py index 030c106..71f2766 100644 --- a/src/osw/service/config.py +++ b/src/osw/service/config.py @@ -21,8 +21,6 @@ from osw.auth import CredentialManager -_TRUTHY = {"1", "true", "yes", "on"} - # Environment variable names. Each tuple lists the canonical ``OSW_*`` name # first, followed by every alias that must keep working. ``OSW_CRED_FILEPATH`` # is canonical (rather than an ``OSW_MCP_``-prefixed name) because @@ -417,11 +415,16 @@ def load(strict: bool = True) -> Settings: password=password, cred_filepath=cred_filepath, sparql_endpoint=_first_env(ENV_SPARQL_ENDPOINT), - read_only=(_first_env(ENV_READ_ONLY) or "").lower() in _TRUTHY, state_dir=_first_env(ENV_STATE_DIR), ) - # An unset or blank/whitespace-only integer variable falls back to the - # model default; pass the raw string only when there is one to validate. + # An unset or blank/whitespace-only variable falls back to the model + # default; pass the raw string only when there is one to validate. Letting + # pydantic parse read_only rather than testing membership in a truthy set + # matters because the default is fail-open: a typo like "ture" would + # otherwise silently leave writes enabled on a server meant to be read-only. + read_only_raw = _first_env(ENV_READ_ONLY) + if read_only_raw is not None and read_only_raw.strip(): + kwargs["read_only"] = read_only_raw max_results_raw = _first_env(ENV_MAX_RESULTS) if max_results_raw is not None and max_results_raw.strip(): kwargs["max_results"] = max_results_raw diff --git a/tests/test_service_config.py b/tests/test_service_config.py index c4bbbdb..48478c6 100644 --- a/tests/test_service_config.py +++ b/tests/test_service_config.py @@ -588,6 +588,54 @@ def test_error_names_the_alias_that_was_set(monkeypatch): assert "OSW_MCP_MAX_RESULTS" in str(exc.value) +def test_misspelled_read_only_raises(monkeypatch): + # A typo must not silently enable writes: read_only is the one flag whose + # fail-open default is dangerous, so an unparseable value has to be loud. + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_READ_ONLY", "ture") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_READ_ONLY" in str(exc.value) + + +@pytest.mark.parametrize("raw", ["1", "true", "TRUE", "yes", "on", "y", "t"]) +def test_read_only_truthy_spellings(monkeypatch, raw): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_READ_ONLY", raw) + assert config.load().read_only is True + + +@pytest.mark.parametrize("raw", ["0", "false", "FALSE", "no", "off", "n", "f"]) +def test_read_only_falsy_spellings(monkeypatch, raw): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_READ_ONLY", raw) + assert config.load().read_only is False + + +def test_blank_read_only_falls_back_to_default(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_READ_ONLY", " ") + assert config.load().read_only is False + + +def test_read_only_error_names_the_alias_that_was_set(monkeypatch): + monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org") + monkeypatch.setenv("OSW_USERNAME", "alice") + monkeypatch.setenv("OSW_PASSWORD", "secret") + monkeypatch.setenv("OSW_MCP_READ_ONLY", "disabled") + with pytest.raises(RuntimeError) as exc: + config.load() + assert "OSW_MCP_READ_ONLY" in str(exc.value) + + def test_domain_with_whitespace_rejected(): with pytest.raises(ValidationError): Settings(domain="wiki.example.org has a space")