diff --git a/README.md b/README.md index 40bad96..8015f2f 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,10 @@ 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/). +Note that `osw[mcp]` is not part of `osw[all]` and has to be installed +explicitly, see [MCP server](#mcp-server). ## Quickstart @@ -39,6 +41,110 @@ 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]" +``` + +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): + +```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 +``` + +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 +``` + +**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`): + +```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..e1f52ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,13 @@ dataimport = [ "openpyxl", ] UI = ["pysimplegui"] +mcp = [ + # 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", +] workflow = [ "prefect>=2.20.25,<3.0", # prefect 2.20.25 is the final 2.x release (no backports). Its @@ -79,22 +86,41 @@ workflow = [ "anyio>=4.4.0,<4.7", ] tutorial = ["osw[dataimport]"] +# 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) +osw-mcp = "osw.mcp.server:main" + [build-system] 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]", +] +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", @@ -291,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" @@ -300,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] @@ -343,6 +387,9 @@ pybars3-wheel = "pybars" psycopg2 = "psycopg2" openpyxl = "openpyxl" pysimplegui = "PySimpleGUI" +mcp = "mcp" +# 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..21a47fe --- /dev/null +++ b/src/osw/mcp/config.py @@ -0,0 +1,357 @@ +"""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 pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +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]: + """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 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 + # kept only to build the SPARQL client; never returned by any tool + 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 + + 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), + "cred_filepath_configured": bool(self.cred_filepath), + } + + +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 _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 _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``. + + 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 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: + load_dotenv(env_file) + else: + load_dotenv() + + domain = _first_env(ENV_DOMAIN) + username = _first_env(ENV_USERNAME) + password = _first_env(ENV_PASSWORD) + 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 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] + 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), 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 and domain: + _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, + 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 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 new file mode 100644 index 0000000..97ce89a --- /dev/null +++ b/src/osw/mcp/connection.py @@ -0,0 +1,123 @@ +"""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 . import config +from .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. Call select_instance first; " + f"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.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. + + 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=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__} + + +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. + """ + 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/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..93b8ed8 --- /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 import MCPServer + +from . import config, connection +from .tools import register_all + + +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 = MCPServer("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..f67d9aa --- /dev/null +++ b/src/osw/mcp/tools/__init__.py @@ -0,0 +1,22 @@ +"""MCP tool groups for the osw-mcp server.""" + +from __future__ import annotations + +from . import entities, files, instances, 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. + 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) + entities.register(mcp, include_writes=include_writes) + 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 new file mode 100644 index 0000000..eec41c9 --- /dev/null +++ b/src/osw/mcp/tools/entities.py @@ -0,0 +1,230 @@ +"""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"], + ) + 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) 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/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/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..03282a3 --- /dev/null +++ b/src/osw/mcp/tools/search.py @@ -0,0 +1,105 @@ +"""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): + 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) 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..d299124 --- /dev/null +++ b/src/osw/mcp/tools/status.py @@ -0,0 +1,60 @@ +"""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 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 diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py new file mode 100644 index 0000000..f9b8a6b --- /dev/null +++ b/tests/integration/test_mcp_server.py @@ -0,0 +1,91 @@ +"""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 + +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 + + +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..5c8ba8a --- /dev/null +++ b/tests/test_mcp_config.py @@ -0,0 +1,209 @@ +"""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") + +from osw.mcp import config + +_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", +] + + +@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() + + +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_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-a.example.org": {"username": "alice", "password": "secret"}, + "wiki-b.example.org": {"username": "bob", "password": "secret2"}, + }, + ) + monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file)) + 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_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..cfa65de --- /dev/null +++ b/tests/test_mcp_tools.py @@ -0,0 +1,239 @@ +"""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 +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 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_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") + 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..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" }, @@ -1056,6 +1097,32 @@ http2 = [ { name = "h2" }, ] +[[package]] +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/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/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]] name = "humanize" version = "4.16.0" @@ -1097,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 = [ @@ -1246,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 = [ @@ -1475,6 +1542,44 @@ 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 = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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 = "pyjwt", extra = ["crypto"], marker = "extra == 'extra-3-osw-mcp'" }, + { 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/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/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]] name = "mdurl" version = "0.1.2" @@ -1737,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 = [ @@ -1800,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 = [ @@ -1875,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" } @@ -1910,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" @@ -1981,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" }, @@ -1989,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" }, @@ -2027,6 +2140,10 @@ db = [ { name = "psycopg2" }, { name = "sqlalchemy" }, ] +mcp = [ + { name = "mcp" }, + { name = "python-dotenv" }, +] s3 = [ { name = "boto3" }, ] @@ -2042,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" }, @@ -2057,8 +2175,8 @@ dev = [ { name = "mike" }, { name = "mkdocstrings-python" }, { name = "mwparserfromhell" }, - { name = "osw", extra = ["workflow"] }, { name = "pre-commit" }, + { name = "prefect" }, { name = "psycopg2-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2070,6 +2188,12 @@ dev = [ { name = "ty" }, { name = "zensical" }, ] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] [package.metadata] requires-dist = [ @@ -2084,6 +2208,7 @@ requires-dist = [ { name = "httpx" }, { name = "isort" }, { name = "jsonpath-ng" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2" }, { name = "mwclient", specifier = ">=0.11.0" }, { name = "mwparserfromhell", marker = "extra == 'wikitext'" }, { name = "numpy" }, @@ -2100,6 +2225,7 @@ requires-dist = [ { name = "pydantic", extras = ["email"], specifier = ">=2.12.0" }, { name = "pyld" }, { name = "pysimplegui", marker = "extra == 'ui'" }, + { name = "python-dotenv", marker = "extra == 'mcp'", specifier = ">=1.0" }, { name = "pyyaml" }, { name = "rdflib" }, { name = "requests" }, @@ -2108,10 +2234,11 @@ requires-dist = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -provides-extras = ["wikitext", "db", "s3", "dataimport", "ui", "workflow", "tutorial", "all"] +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" }, @@ -2121,8 +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 = ["workflow"] }, { name = "pre-commit", specifier = ">=4.0.0" }, + { name = "prefect", specifier = ">=2.20.25,<3.0" }, { name = "psycopg2-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2134,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" @@ -2171,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 = [ @@ -2187,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'" }, @@ -2287,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" }, @@ -2304,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" }, @@ -2316,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" }, @@ -2330,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" }, @@ -2564,6 +2693,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" @@ -2622,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 = [ @@ -2640,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 = [ @@ -2700,6 +2846,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" @@ -2887,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" } @@ -2910,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" } @@ -3186,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 = [ @@ -3404,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" } @@ -3441,6 +3592,32 @@ asyncio = [ { name = "greenlet" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette" }, +] +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/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]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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" } +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" @@ -3518,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"