diff --git a/.gitignore b/.gitignore
index dc1ba37..a362bb8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,5 +69,8 @@ playground
/osw_files/
*/accounts.pwd.yaml
/accounts.pwd.yaml
-.ign
-.claude
+
+# Local folders
+.ign/
+.claude/
+graphify-out/
diff --git a/README.md b/README.md
index 40bad96..3c19612 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,19 @@ More runnable scripts live in [examples/](examples/), and the
[Basics tutorial](docs/tutorials/basics.ipynb) walks through the
OpenSemanticLab data model.
+## CLI and MCP tools
+
+Installing `osw` also installs an `osw` command line client, and the separate
+`osw[mcp]` extra adds an MCP server that exposes a live instance to agent
+clients such as Claude Code:
+
+```bash
+osw search ask '[[Category:Item]]' --limit 5
+```
+
+Commands, tools and their configuration are described in the
+[CLI and MCP guide](https://opensemanticlab.github.io/osw-python/cli-and-mcp/).
+
## Contributing
Contributions are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md).
diff --git a/docs/cli-and-mcp.md b/docs/cli-and-mcp.md
new file mode 100644
index 0000000..94f170d
--- /dev/null
+++ b/docs/cli-and-mcp.md
@@ -0,0 +1,343 @@
+# CLI and MCP tools
+
+Besides the Python API, osw ships two adapters that talk to a live instance:
+the `osw` command line client, and an MCP server for agent clients such as
+Claude Code. Both run the same operations from one shared, SDK-free core
+(`osw.service`), so a command and its matching tool behave identically. They
+differ in exactly one way: only the CLI accepts filesystem paths.
+
+## Setup
+
+Install one of the two; the second includes the first:
+
+```bash
+uv tool install osw # the `osw` command
+uv tool install "osw[mcp]" # the same, plus the `osw-mcp` server
+```
+
+
+Other ways to install
+
+```bash
+pip install "osw[mcp]" # into the active environment
+uv add "osw[mcp]" # as a dependency of the current uv project
+uvx --from "osw[mcp]" osw-mcp # run the server without installing it
+```
+
+`uvx` is what the registration examples further down use, so the server needs
+no install of its own.
+
+
+
+`osw[mcp]` is not part of `osw[all]`, see [Design notes](#design-notes). The
+other extras are listed in the
+[Get Started guide](get-started.md#optional-extras).
+
+Both adapters need an instance and credentials. The quickest start is a
+gitignored `.env` file in your project root:
+
+```dotenv
+OSW_DOMAIN=wiki-dev.open-semantic-lab.org
+OSW_USERNAME=your-user
+OSW_PASSWORD=your-password
+```
+
+The CLI finds that file by searching upward from the working directory, so
+`osw status` now reports the instance and connection state. The MCP server does
+not search: its settings come from the `env` block of its registration, see
+[Registering a server](#registering-a-server). Everything that can be set is
+listed under [Configuration](#configuration).
+
+## Command line
+
+```bash
+osw status
+osw search ask '[[Category:Item]]' --limit 5
+osw entity get 'Item:OSW1234...' --json | jq .
+osw file cat 'File:Example.csv' # inline text
+osw file download 'File:Example.csv' --target-dir ./tmp # to disk
+```
+
+Commands are grouped by subject:
+
+| Group | Commands |
+| --- | --- |
+| `entity` | `get`, `put`, `export`, `delete` |
+| `file` | `info`, `cat`, `write`, `download`, `upload` |
+| `search` | `ask`, `text`, `instances`, `sparql` |
+| `slot` | `list`, `get`, `set` |
+| `schema` | `get` |
+| `instance` | `list` |
+| `ledger` | `path` |
+| top level | `status` |
+
+Global options apply to every command:
+
+- `--instance IRI` picks the instance. Optional: it is only required when
+ `OSW_DOMAIN` is not set and the configured credential file holds more than
+ one iri.
+- `--json` / `-j` writes machine-readable JSON to stdout and keeps osw's own
+ progress output on stderr, so it pipes cleanly into `jq`.
+- `--read-only` refuses write operations.
+- `--verbose` / `-v` shows full tracebacks instead of a one-line message.
+
+Failures exit non-zero with a short message on stderr and no traceback.
+
+## MCP server
+
+`osw[mcp]` ships an [MCP](https://modelcontextprotocol.io) server that exposes a
+live OpenSemanticLab instance to MCP clients such as Claude Code. It wraps
+`OswExpress` and provides tools to search (semantic / SPARQL / full-text),
+introspect category schemas, read entities and every page slot, create/update
+and delete entities, and read and write file pages as text. The transport is
+stdio; SSE and HTTP are not supported.
+
+**No filesystem access:** no MCP tool takes or returns a local path. File
+content moves inline as text (`get_file_info`, `read_file_text`,
+`write_file_text`), and everything path-based lives in the CLI instead
+(`osw file download`, `osw file upload`, `osw ledger path`).
+
+**One server per instance:** each server process is pinned to exactly one OSL
+instance for its whole lifetime; there is no tool to switch at runtime.
+`OSW_DOMAIN` must be set, either in the server entry's `env` block or in the
+`.env` file that entry names. Without it the server refuses to start rather than
+register tools that would all fail.
+
+### Registering a server
+
+A server entry can carry its settings in two ways:
+
+- **Directly in the entry's `env` block.** Every variable from the
+ [reference table](#variable-reference) can be set there, so no `.env` file is
+ needed at all.
+- **In a `.env` file**, named by `OSW_ENV_FILE` in the `env` block. Useful when
+ several tools share one settings file, or when the client config is committed
+ and the settings file is not.
+
+Prefer the `env` block naming `OSW_CRED_FILEPATH` and `OSW_DOMAIN`: the
+destination instance is spelled out in the entry itself, so it is visible at a
+glance and in a diff rather than one indirection away. The secret stays out of
+the client config either way, since a credential file contributes a path and an
+instance name and nothing else. Never put `OSW_PASSWORD` inline in a committed
+`.mcp.json`.
+
+```json
+{
+ "mcpServers": {
+ "osw": {
+ "type": "stdio",
+ "command": "uvx",
+ "args": ["--from", "osw[mcp]", "osw-mcp"],
+ "env": {
+ "OSW_CRED_FILEPATH": "/abs/path/to/accounts.pwd.yaml",
+ "OSW_DOMAIN": "wiki-dev.open-semantic-lab.org"
+ }
+ }
+ }
+}
+```
+
+At startup the server checks that the credential file has an entry matching
+`OSW_DOMAIN` and, if not, names the iris the file does contain (never their
+secrets), so a typo surfaces immediately rather than on the first tool call.
+
+Registering the same entry from a shell is easiest with `add-json`, which takes
+it verbatim. Note that a Windows path needs forward slashes or doubled
+backslashes to be valid JSON:
+
+```bash
+claude mcp add-json osw '{"type":"stdio","command":"uvx","args":["--from","osw[mcp]","osw-mcp"],"env":{"OSW_CRED_FILEPATH":"/abs/path/to/accounts.pwd.yaml","OSW_DOMAIN":"wiki-dev.open-semantic-lab.org"}}'
+```
+
+### More than one instance
+
+Register one server per instance, each pinned to a single `OSW_DOMAIN`. The two
+entries below show both styles side by side: `osw-dev` puts everything in a
+`.env` file, `osw-prod` names the credential file and the domain directly. One
+credential file can serve any number of servers, since it is keyed by iri.
+
+```json
+{
+ "mcpServers": {
+ "osw-dev": {
+ "type": "stdio",
+ "command": "uvx",
+ "args": ["--from", "osw[mcp]", "osw-mcp"],
+ "env": { "OSW_ENV_FILE": "/abs/path/to/dev.env" }
+ },
+ "osw-prod": {
+ "type": "stdio",
+ "command": "uvx",
+ "args": ["--from", "osw[mcp]", "osw-mcp"],
+ "env": {
+ "OSW_CRED_FILEPATH": "/abs/path/to/accounts.pwd.yaml",
+ "OSW_DOMAIN": "wiki.open-semantic-lab.org",
+ "OSW_READ_ONLY": "true"
+ }
+ }
+ }
+}
+```
+
+`dev.env` has to pin the instance itself, since the server will not infer one:
+
+```dotenv
+OSW_DOMAIN=wiki-dev.open-semantic-lab.org
+OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml
+```
+
+The instance is then part of the tool name at every call site
+(`mcp__osw-prod__get_entity`), so the destination is visible in the permission
+prompt, read-only is settable per instance, and permissions can differ per
+instance:
+
+```json
+{
+ "permissions": {
+ "allow": ["mcp__osw-dev"],
+ "ask": ["mcp__osw-prod"]
+ }
+}
+```
+
+### Notes and caveats
+
+- `status` reports the active instance and connection state, never the password.
+- **Safe deletes:** the server records every entity it creates or modifies in a
+ local provenance ledger. It deletes those without extra prompting, but refuses
+ to delete anything it did not create unless the caller passes
+ `confirm_external_delete=true`.
+
+## Configuration
+
+Both adapters share the settings below.
+
+### Where settings come from
+
+Settings are read from the process environment. A `.env` file is one optional
+way to fill it, and a real environment variable always wins over the same name
+in a file.
+
+- `OSW_ENV_FILE` set: exactly that file is loaded, and nothing is searched for.
+- Unset, **CLI**: searches upward from the working directory, so a `.env` in a
+ project root applies to every `osw` command run anywhere inside it.
+- Unset, **MCP server**: searches nowhere. Its working directory is picked by
+ the MCP client, so an implicit search would tie the credentials it loads to
+ how the client happened to be launched.
+
+Both print the sources they resolved to stderr before connecting:
+
+```text
+[osw] env file : /home/me/project/.env (found from the working directory upward)
+[osw] credential file: /abs/path/to/accounts.pwd.yaml
+```
+
+### Credentials
+
+Keep credentials in a gitignored file. They are read once per process, into that
+process only, and never written back to disk.
+
+```dotenv
+OSW_DOMAIN=wiki-dev.open-semantic-lab.org
+OSW_USERNAME=your-user
+OSW_PASSWORD=your-password
+# optional
+OSW_SPARQL_ENDPOINT=https://.../sparql
+OSW_READ_ONLY=false # true hides all mutating tools
+```
+
+Alternatively, authenticate from an osw credential file, so the password is not
+duplicated into a second plaintext file:
+
+```dotenv
+OSW_DOMAIN=wiki-dev.open-semantic-lab.org
+OSW_CRED_FILEPATH=/abs/path/to/accounts.pwd.yaml
+```
+
+The file is the YAML format osw's `CredentialManager` already reads, keyed by
+iri, so deployments that configure it need no extra setup:
+
+```yaml
+wiki-dev.open-semantic-lab.org:
+ username: your-user
+ password: your-password
+```
+
+A credential file may hold several iris. The CLI selects one automatically if it
+is the only one, and otherwise wants `osw --instance `. The MCP server
+never selects one, see
+[One server per instance](#mcp-server).
+
+### Variable reference
+
+The canonical variable names are `OSW_*`. Older `OSW_MCP_*` and `OSL_*` names
+stay accepted so existing deployments keep working, and the first name that is
+set wins:
+
+| Canonical | Also accepted | Meaning |
+| --- | --- | --- |
+| `OSW_DOMAIN` | `OSL_DOMAIN` | Instance to connect to |
+| `OSW_USERNAME` | `OSL_USERNAME` | Login user |
+| `OSW_PASSWORD` | `OSL_PASSWORD` | Login password |
+| `OSW_CRED_FILEPATH` | `OSW_MCP_CRED_FILEPATH`, `OSL_CRED_FILEPATH` | YAML credential file, keyed by iri |
+| `OSW_ENV_FILE` | `OSW_MCP_ENV_FILE` | `.env` file to load |
+| `OSW_READ_ONLY` | `OSW_MCP_READ_ONLY` | `true` refuses every write |
+| `OSW_SPARQL_ENDPOINT` | | Endpoint for `sparql` queries |
+| `OSW_STATE_DIR` | `OSW_MCP_STATE_DIR` | Where the provenance ledger is kept |
+| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) |
+| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) |
+
+### Windows paths in a `.env` file
+
+Quote them with single quotes, or leave them unquoted. A double-quoted value is
+escape-decoded, so `\a` in a path silently becomes a BEL byte that renders as
+nothing:
+
+```dotenv
+OSW_CRED_FILEPATH='C:\Users\me\accounts.pwd.yaml' # ok
+OSW_CRED_FILEPATH=C:\Users\me\accounts.pwd.yaml # ok
+OSW_CRED_FILEPATH="C:\Users\me\accounts.pwd.yaml" # broken: \a is eaten
+```
+
+## Design notes
+
+Why the two adapters are shaped the way they are:
+
+- **No filesystem access on the MCP surface.** MCP does not imply a shared host:
+ a server can be containerised or remote, so a path argument is either
+ meaningless or a way to reach a filesystem nobody granted access to. A CLI
+ runs where the command was typed, under that user's own permissions, and an
+ agent calling it goes through whatever command permissions already apply.
+- **One instance per server process.** Which instance a tool call reaches has to
+ be readable from the configuration rather than inferred, so the server never
+ picks one for you, not even when the credential file holds exactly one iri.
+- **stdio only.** SSE is deprecated upstream, and HTTP would need a
+ per-connection auth model this server does not have: it holds one set of wiki
+ credentials, which every client would share.
+- **`osw[mcp]` outside `osw[all]`.** It needs `anyio>=4.9`, which conflicts with
+ the pin the `osw[workflow]` extra requires for prefect 2.x, so the two cannot
+ share an environment
+ ([#139](https://github.com/OpenSemanticLab/osw-python/issues/139)).
+ Installing the server standalone, for example via `uvx`, avoids the question
+ entirely.
+
+## Notes for developers
+
+To try an unreleased branch against a real client, point `uvx` at the checkout
+instead of at PyPI. Everything else about the registration stays the same:
+
+```bash
+uvx --reinstall --from "/abs/path/to/osw-python[mcp]" osw-mcp
+```
+
+`--reinstall` is what picks up your latest edits, since `uvx` caches the wheel
+it builds. In a JSON `args` array, a Windows path needs forward slashes or
+doubled backslashes.
+
+Prefer that over an editable install for the server. `create_or_update_entity`
+and `export_entity_jsonld` call `fetch_schema`, which regenerates
+`src/osw/model/entity.py` inside the installed package: `uvx` builds a
+non-editable wheel, so the write lands in the uv cache, while under
+`pip install -e` or `uv sync` it lands in your working tree. The read tools
+(`get_entity`, `get_slot`, `get_category_schema`, ...) read raw page slots and
+never trigger it.
diff --git a/docs/get-started.md b/docs/get-started.md
index 0d1292d..e01f2a2 100644
--- a/docs/get-started.md
+++ b/docs/get-started.md
@@ -31,6 +31,7 @@
| `osw[dataimport]` | Additional tools to import data |
| `osw[UI]` | To use a helper UI to work with entity slots |
| `osw[all]` | All of the above |
+| `osw[mcp]` | [MCP server](cli-and-mcp.md#mcp-server) for agent clients, not part of `osw[all]` |
Install multiple extras with `pip install osw[opt1,opt2]`.
diff --git a/pyproject.toml b/pyproject.toml
index ffa589b..883f321 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,6 +47,9 @@ dependencies = [
"dask",
"tqdm",
"pybars3-wheel",
+ # the osw CLI (src/osw/cli); a base dependency, not an extra, so `pip
+ # install osw` never ships a broken `osw` console script
+ "typer",
]
[project.urls]
@@ -69,6 +72,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 +89,48 @@ 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]
+# command-line access to a live OSL instance, built from the same
+# osw.service.registry the MCP server uses
+osw = "osw.cli.main:app"
+# stdio MCP server exposing a live OSL instance to MCP clients (e.g. Claude Code)
+osw-mcp = "osw.mcp.server:main"
+
[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).
+# tests/test_service_*.py run in a plain `uv sync` dev env (no mcp extra
+# needed). Run the MCP-extra tests (tests/test_mcp_*.py) with:
+# uv sync --extra mcp --group test --no-dev
+# uv run --extra mcp --group test --no-dev --no-sync python -m pytest tests/test_mcp_*.py
+test = [
"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]",
+ # not part of the mcp conflict set, so the shared service layer (which reads
+ # .env lazily) stays importable and testable in a plain `uv sync` env.
+ "python-dotenv>=1.0",
+]
+dev = [
+ { include-group = "test" },
+ # 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 +327,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,6 +349,11 @@ 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 is no longer excluded. The mcp extra still cannot be installed
+# alongside the dev group (issue #139), so the two SDK imports in server.py
+# carry an inline `ty: ignore[unresolved-import]`; everything else there, and
+# all of src/osw/service and src/osw/cli, is checked.
exclude = [
"src/osw/model/entity.py",
"examples",
@@ -343,6 +397,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/cli/__init__.py b/src/osw/cli/__init__.py
new file mode 100644
index 0000000..0a237f9
--- /dev/null
+++ b/src/osw/cli/__init__.py
@@ -0,0 +1,9 @@
+"""osw: a command-line client assembled from the same ``osw.service.registry``
+that ``osw-mcp`` uses.
+
+Every operation is registered once (see :mod:`osw.service.ops`) and exposed
+identically by every adapter; this package's only job is to turn that
+registry into a ``typer`` command tree.
+"""
+
+from __future__ import annotations
diff --git a/src/osw/cli/main.py b/src/osw/cli/main.py
new file mode 100644
index 0000000..932bfb2
--- /dev/null
+++ b/src/osw/cli/main.py
@@ -0,0 +1,202 @@
+"""Entry point for the ``osw`` CLI.
+
+Run via the ``osw`` console script or ``python -m osw.cli.main``. The command
+tree is assembled once, at import time, by looping over
+:func:`osw.service.registry.iter_operations`; building it never touches
+credentials or the network. The :class:`~osw.service.context.Context` for a
+given invocation is built lazily, inside each command's callback, so
+``osw --help`` (and friends) work with no configuration present at all.
+"""
+
+from __future__ import annotations
+
+import inspect
+from typing import Any, Optional, get_type_hints
+
+import typer
+
+# Registers the CLI-only, path-taking operations (file download/upload, ledger
+# path). Imported here -- and nowhere in osw.mcp -- so a path-taking operation
+# can never reach the MCP registry.
+import osw.cli.ops
+
+# Registers every operation in osw.service.registry.REGISTRY as a side effect.
+import osw.service.ops # noqa: F401
+from osw.service import config, errors
+from osw.service.context import Context, Policy
+from osw.service.errors import OpError
+from osw.service.params import json_value
+from osw.service.registry import Operation, bind, iter_operations
+from osw.wtsite import SLOTS
+
+from .render import render
+
+app = typer.Typer(no_args_is_help=True, add_completion=False)
+
+
+@app.callback()
+def _callback(
+ ctx: typer.Context,
+ instance: Optional[str] = typer.Option(
+ None,
+ "--instance",
+ help="Iri of the OSL instance to use for this command, when more "
+ "than one is configured (e.g. via a credential file).",
+ ),
+ as_json: bool = typer.Option(
+ False, "--json", "-j", help="Emit machine-readable JSON on stdout."
+ ),
+ read_only: bool = typer.Option(
+ False, "--read-only", help="Refuse write operations."
+ ),
+ verbose: bool = typer.Option(
+ False, "--verbose", "-v", help="Show full tracebacks on unexpected errors."
+ ),
+) -> None:
+ """osw: command-line access to an OpenSemanticLab (OSW) instance.
+
+ Connection settings and credentials come from the environment or a
+ .env file (see ``osw.service.config``). Pass --instance to pick which
+ configured instance this invocation talks to; unlike the MCP server, the
+ CLI is stateless, so the choice only applies to this one command.
+ """
+ # The CLI's working directory is the one the user typed the command in, so
+ # searching it upward for a .env is what they mean. The MCP server leaves
+ # this off: its working directory is chosen by the MCP client.
+ config.set_env_file_discovery(True)
+ ctx.obj = {
+ "instance": instance,
+ "as_json": as_json,
+ "read_only": read_only,
+ "verbose": verbose,
+ }
+
+
+def _op_params(op: Operation) -> list[inspect.Parameter]:
+ """The op's CLI-facing parameters (its signature, minus ``ctx``).
+
+ Mirrors :func:`osw.service.registry.bind`'s annotation resolution, but
+ only needs ``op.fn`` -- no ``Context`` -- so it is safe to call at
+ app-build time.
+ """
+ try:
+ hints = get_type_hints(op.fn, include_extras=True)
+ except Exception:
+ hints = {}
+ sig = inspect.signature(op.fn)
+ params = [
+ p.replace(annotation=hints.get(p.name, p.annotation))
+ for p in list(sig.parameters.values())[1:] # drop ctx
+ ]
+
+ if op.name == "set_slot":
+ # set_slot's `content: Union[str, dict, list]` is left unmarked in
+ # the core (osw.service.ops.slots): typer has no support for
+ # arbitrary Union types (verified empirically -- building a command
+ # with this annotation raises AssertionError at app-build time). The
+ # CLI instead takes `content` as a plain string and coerces it to
+ # JSON at invocation time in `_run`, but only when the sibling
+ # `slot` argument's content model is "json" (see SLOTS); a blanket
+ # JSON parser would silently turn plain-text content like "123"
+ # into an int.
+ params = [
+ p.replace(annotation=str) if p.name == "content" else p for p in params
+ ]
+
+ return params
+
+
+def _run(op: Operation, typer_ctx: typer.Context, kwargs: dict[str, Any]) -> None:
+ opts = typer_ctx.obj or {}
+
+ if op.name == "set_slot":
+ slot = kwargs.get("slot")
+ content_model = SLOTS.get(slot, {}).get("content_model")
+ content = kwargs.get("content")
+ if content_model == "json" and isinstance(content, str):
+ kwargs["content"] = json_value(content)
+
+ try:
+ instance = opts.get("instance")
+ if instance:
+ try:
+ config.set_active_instance(instance)
+ except ValueError as exc:
+ raise errors.UnknownInstance(str(exc)) from exc
+
+ # Before load(), so a misconfiguration that makes loading raise still
+ # reports which files were read.
+ config.log_config_sources()
+ settings = config.load(strict=False)
+ policy = Policy(
+ capture_stdout=bool(opts.get("as_json")),
+ errors_as_dicts=False,
+ allow_writes=not opts.get("read_only"),
+ allow_interactive=True,
+ )
+ context = Context(settings, policy)
+ bound = bind(op, context)
+ result = bound(**kwargs)
+ except OpError as exc:
+ typer.echo(f"{exc.type}: {exc}", err=True)
+ raise typer.Exit(exc.exit_code)
+ except Exception as exc:
+ if opts.get("verbose"):
+ raise
+ typer.echo(f"{type(exc).__name__}: {exc}", err=True)
+ raise typer.Exit(1)
+
+ typer.echo(render(result, as_json=bool(opts.get("as_json"))))
+
+
+def _make_command(op: Operation):
+ """Build the typer command callable for ``op``."""
+ op_params = _op_params(op)
+ ctx_param = inspect.Parameter(
+ "typer_ctx",
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
+ annotation=typer.Context,
+ )
+
+ def command(**kwargs: Any) -> None:
+ typer_ctx = kwargs.pop("typer_ctx")
+ _run(op, typer_ctx, kwargs)
+
+ command.__name__ = op.fn.__name__
+ command.__doc__ = inspect.getdoc(op.fn)
+ command.__signature__ = inspect.Signature(parameters=[ctx_param, *op_params])
+ annotations = {p.name: p.annotation for p in op_params}
+ annotations["typer_ctx"] = typer.Context
+ command.__annotations__ = annotations
+ return command
+
+
+_groups: dict[str, typer.Typer] = {}
+
+# One line per command group. Without these ``osw --help`` lists eight bare
+# group names with nothing next to them; a group missing an entry still works.
+_GROUP_HELP = {
+ "entity": "Read, write, export and delete entities.",
+ "file": "Wiki file pages: metadata, inline text, and local transfer.",
+ "instance": "Inspect the OSL instances this process can connect to.",
+ "ledger": "The local provenance ledger of pages written from here.",
+ "schema": "Category JSON Schemas.",
+ "search": "Query the instance: semantic, full-text or SPARQL.",
+ "slot": "Read and write individual page slots.",
+}
+
+for _op in iter_operations(surface="cli"):
+ _command = _make_command(_op)
+ if _op.group is None:
+ app.command(name=_op.command)(_command)
+ else:
+ _sub = _groups.get(_op.group)
+ if _sub is None:
+ _sub = typer.Typer()
+ _groups[_op.group] = _sub
+ app.add_typer(_sub, name=_op.group, help=_GROUP_HELP.get(_op.group))
+ _sub.command(name=_op.command)(_command)
+
+
+if __name__ == "__main__":
+ app()
diff --git a/src/osw/cli/ops.py b/src/osw/cli/ops.py
new file mode 100644
index 0000000..665cbf4
--- /dev/null
+++ b/src/osw/cli/ops.py
@@ -0,0 +1,167 @@
+"""CLI-only operations that name a filesystem path.
+
+This is the only module in the codebase allowed to do so: every operation
+here declares ``surfaces=frozenset({"cli"})``, so none of it is ever visible
+to ``iter_operations(surface="mcp")`` and the registry's path-name validator
+never even runs against it (that validator only inspects the ``mcp``
+surface). A path argument is meaningful here because the CLI runs under the
+invoking user's own shell permissions; it would be meaningless -- or a
+filesystem escape hatch -- on an MCP client that may not share a host with
+the server.
+
+Imported by ``osw.cli.main`` (and nowhere else) before the command-tree loop,
+so these commands are registered without ``osw.mcp`` ever importing this
+module.
+"""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Optional
+
+from osw.controller.file.wiki import WikiFileController
+from osw.core import OverwriteOptions
+from osw.service import config, errors
+from osw.service.context import Context
+from osw.service.ledger import LedgerRecord
+from osw.service.registry import operation
+from osw.utils.wiki import title_from_full_title
+from osw.wtsite import WtSite
+
+
+class _RenamedFile:
+ """Proxy over a file object that allows overriding its ``.name``.
+
+ ``WikiFileController.put()`` derives the upload's suffix/label from
+ ``file.name``, but a real ``open()``-returned file object's ``.name`` (its
+ open-time path) is not a writable attribute. This proxy delegates
+ everything else to the wrapped file object.
+ """
+
+ def __init__(self, fh, name: str) -> None:
+ self._fh = fh
+ self.name = name
+
+ def __getattr__(self, item):
+ return getattr(self._fh, item)
+
+
+def _file_controller(ctx: Context, title: Optional[str] = None) -> WikiFileController:
+ """Build a ``WikiFileController``, optionally bound to a full title."""
+ if title:
+ return WikiFileController(
+ osw=ctx.osw, title=title_from_full_title(title), namespace="File"
+ )
+ return WikiFileController(osw=ctx.osw)
+
+
+@operation(
+ group="file",
+ cli_name="download",
+ surfaces=frozenset({"cli"}),
+ read_only_hint=True,
+ idempotent_hint=True,
+)
+def download_file(
+ ctx: Context,
+ title: str,
+ target_dir: Optional[str] = None,
+ overwrite: bool = False,
+) -> dict:
+ """Download a wiki file to the local filesystem.
+
+ ``title`` is a full ``File:`` page title. Streams the file in chunks so a
+ large file never lands in memory at once.
+ """
+ page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0]
+ if not page.exists:
+ raise errors.NotFound(f"File '{title}' does not exist.")
+
+ wf = _file_controller(ctx, title)
+ dest_dir = Path(target_dir) if target_dir else Path.cwd()
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ dest_path = dest_dir / wf.title
+ if dest_path.exists() and not overwrite:
+ raise FileExistsError(
+ f"'{dest_path}' already exists. Pass --overwrite to replace it."
+ )
+ stream = wf.get()
+ try:
+ with open(dest_path, "wb") as fh:
+ shutil.copyfileobj(stream, fh)
+ finally:
+ stream.close()
+ return {"title": title, "path": str(dest_path)}
+
+
+@operation(
+ group="file",
+ cli_name="upload",
+ surfaces=frozenset({"cli"}),
+ writes=True,
+ destructive_hint=False,
+ idempotent_hint=True,
+ records=lambda r: [LedgerRecord(title=r["title"], op="create", slots=["jsondata"])],
+)
+def upload_file(
+ ctx: Context,
+ source_path: str,
+ target_title: Optional[str] = None,
+ name: Optional[str] = None,
+ overwrite: bool = True,
+) -> dict:
+ """Upload a local file to the wiki as a WikiFile page.
+
+ ``source_path`` is a path on the local disk. ``target_title`` is an
+ optional full ``File:`` page title (otherwise auto-generated). Records
+ the created page in the provenance ledger.
+ """
+ src = Path(source_path)
+ if not src.is_file():
+ raise errors.NotFound(f"Local file '{source_path}' does not exist.")
+
+ wf = _file_controller(ctx, target_title)
+ overwrite_opt = OverwriteOptions.true if overwrite else OverwriteOptions.false
+ with open(src, "rb") as fh:
+ stream = _RenamedFile(fh, name or src.name)
+ wf.put(stream, overwrite=overwrite_opt)
+
+ return {
+ "title": f"{wf.namespace}:{wf.title}",
+ "url": wf.url,
+ }
+
+
+@operation(
+ group="ledger",
+ cli_name="path",
+ surfaces=frozenset({"cli"}),
+ read_only_hint=True,
+ idempotent_hint=True,
+)
+def ledger_path(ctx: Context) -> dict:
+ """Print the local path of the provenance ledger file for the active instance."""
+ return {"path": str(ctx.ledger.path)}
+
+
+@operation(
+ group="instance",
+ cli_name="list",
+ surfaces=frozenset({"cli"}),
+ read_only_hint=True,
+ idempotent_hint=True,
+)
+def list_instances(ctx: Context) -> dict:
+ """List the OSL instances this process can connect to.
+
+ Reports the iris available from the env-configured domain and/or a
+ configured credential file, and which one (if any) is currently active
+ for this invocation (see --instance). Never returns usernames, passwords,
+ or any other credential value.
+ """
+ return {
+ "iris": config.available_iris(),
+ "active_iri": config.get_active_iri(),
+ "active_domain": config.get_active_domain(),
+ }
diff --git a/src/osw/cli/render.py b/src/osw/cli/render.py
new file mode 100644
index 0000000..5787f22
--- /dev/null
+++ b/src/osw/cli/render.py
@@ -0,0 +1,64 @@
+"""Rendering helpers for the ``osw`` CLI.
+
+Kept deliberately simple: this is not a table library, just enough structure
+to make operation results readable on a terminal (or, with ``--json``,
+machine-parseable).
+
+The matching input-side helper, ``json_value``, lives in
+:mod:`osw.service.params`: it is referenced from operation signatures, which
+must not import an adapter.
+"""
+
+from __future__ import annotations
+
+import json
+
+
+def render(result: dict, *, as_json: bool) -> str:
+ """Render an operation's result for the CLI.
+
+ With ``as_json``, a plain ``json.dumps``. Otherwise a compact
+ human-readable rendering: a ``{"titles": [...], "count": n, "truncated":
+ bool}``-shaped result prints one title per line plus a count/truncation
+ footer; any other dict renders as aligned ``key: value`` lines, with
+ nested structures (dicts/lists) dumped as indented JSON.
+ """
+ if as_json:
+ return json.dumps(result, indent=2, ensure_ascii=False)
+ if _is_title_list(result):
+ return _render_title_list(result)
+ return _render_dict(result)
+
+
+def _is_title_list(result: dict) -> bool:
+ return (
+ isinstance(result, dict)
+ and isinstance(result.get("titles"), list)
+ and "count" in result
+ )
+
+
+def _render_title_list(result: dict) -> str:
+ lines = [str(title) for title in result["titles"]]
+ count = result.get("count", len(result["titles"]))
+ footer = f"{count} result{'s' if count != 1 else ''}"
+ if result.get("truncated"):
+ footer += " (truncated)"
+ lines.append(footer)
+ return "\n".join(lines)
+
+
+def _render_dict(result: dict) -> str:
+ if not isinstance(result, dict):
+ return json.dumps(result, indent=2, ensure_ascii=False)
+ width = max((len(str(key)) for key in result), default=0)
+ lines = []
+ for key, value in result.items():
+ label = str(key).ljust(width)
+ if isinstance(value, (dict, list)):
+ nested = json.dumps(value, indent=2, ensure_ascii=False)
+ indented = "\n".join(f" {line}" for line in nested.splitlines())
+ lines.append(f"{label}:\n{indented}")
+ else:
+ lines.append(f"{label}: {value}")
+ return "\n".join(lines)
diff --git a/src/osw/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/server.py b/src/osw/mcp/server.py
new file mode 100644
index 0000000..331d888
--- /dev/null
+++ b/src/osw/mcp/server.py
@@ -0,0 +1,165 @@
+"""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.service.config`).
+"""
+
+from __future__ import annotations
+
+import atexit
+import inspect
+import sys
+from typing import Any, Optional
+
+# ty cannot resolve these: the mcp extra is uninstallable alongside the dev
+# group (anyio conflict, issue #139), so it is absent from the env ty runs in.
+# The rest of this module is type-checked; drop the ignores once #139 is fixed.
+from mcp.server import MCPServer # ty: ignore[unresolved-import]
+from mcp.types import ToolAnnotations # ty: ignore[unresolved-import]
+
+import osw
+import osw.service.ops
+from osw.service import config
+from osw.service.config import Settings
+from osw.service.context import Context, Policy
+from osw.service.registry import Operation, bind, iter_operations
+
+INSTRUCTIONS = """\
+This server is pinned to exactly one OpenSemanticLab (OSL) instance for its
+whole process lifetime; there is no tool to switch instances. Run one server
+process per instance (a separate registration, its own env file) if you need
+more than one.
+
+Entity and page titles are full MediaWiki page names, e.g. "Item:OSW1234...",
+never a bare id or label.
+
+Before creating or updating an entity, fetch its category's JSON Schema
+(get_category_schema) so the written jsondata validates against it.
+
+This server has no filesystem access: file content moves inline as text, not
+as a path. For anything path-based (uploading/downloading a local file, the
+provenance ledger's path), use the `osw` CLI instead.
+"""
+
+
+def _annotations(op: Operation) -> Optional[ToolAnnotations]:
+ """Build ``ToolAnnotations`` from ``op``'s four hints.
+
+ Returns ``None`` when every hint is unset, so a hint-less operation gets
+ no ``annotations`` at all rather than an all-``None`` object.
+
+ Built by explicit keyword, never ``**dict``: passing an unrecognized
+ keyword to ``ToolAnnotations`` (verified empirically against the
+ installed mcp SDK) is silently dropped rather than raising, so a
+ misspelled field name would otherwise fail with no error and leave the
+ hint permanently ``None``.
+ """
+ hints = (
+ op.read_only_hint,
+ op.destructive_hint,
+ op.idempotent_hint,
+ op.open_world_hint,
+ )
+ if all(hint is None for hint in hints):
+ return None
+ return ToolAnnotations(
+ read_only_hint=op.read_only_hint,
+ destructive_hint=op.destructive_hint,
+ idempotent_hint=op.idempotent_hint,
+ open_world_hint=op.open_world_hint,
+ )
+
+
+def _meta(op: Operation, settings: Settings) -> dict[str, Any]:
+ """Build the MCP ``_meta`` dict for ``op``.
+
+ ``anthropic/maxResultSizeChars`` always has a value: ``op``'s own limit
+ if it declares one, else the server-wide default. ``requiresUserInteraction``
+ is only present (and only ever ``True``) for operations that declare it.
+ ``op.extra_meta`` is merged last, so it can override either key.
+ """
+ meta: dict[str, Any] = {
+ "anthropic/maxResultSizeChars": op.max_result_size_chars or settings.max_chars,
+ }
+ if op.requires_user_interaction:
+ meta["anthropic/requiresUserInteraction"] = True
+ meta.update(op.extra_meta)
+ return meta
+
+
+def tool_kwargs(op: Operation, settings: Settings) -> dict[str, Any]:
+ """Keyword arguments for ``mcp.tool(...)`` for one operation."""
+ return {
+ "name": op.name,
+ "description": inspect.getdoc(op.fn),
+ "annotations": _annotations(op),
+ "meta": _meta(op, settings),
+ }
+
+
+def _build_server() -> tuple[MCPServer, Context]:
+ """Build the MCPServer and the Context its tools are bound to.
+
+ Loads and validates settings first so a missing-credential misconfiguration
+ fails fast (before any osw call that could trigger an interactive prompt).
+ Also fails fast unless a domain was configured *explicitly*: this server is
+ statically pinned to one OSL instance for its whole lifetime, and which one
+ that is has to be readable from the configuration rather than inferred.
+ Deliberately stricter than :func:`config.get_active_domain`, which the CLI
+ uses: there the instance is resolved per invocation and reported at
+ startup, and ``--instance`` can override it per command.
+ """
+ # Before get_settings(), so a misconfiguration that makes loading raise
+ # still reports which files were read. stderr, so it lands in the MCP
+ # client's server log without touching the JSON-RPC stream on stdout.
+ config.log_config_sources()
+ settings = config.get_settings()
+ domain = settings.domain
+ if domain is None:
+ available = ", ".join(config.available_iris()) or "(none)"
+ raise RuntimeError(
+ "No OSL instance configured. Set OSW_DOMAIN in this server's env "
+ "block, or in the .env file named by OSW_ENV_FILE. The server "
+ "never picks an instance for you, not even when a credential file "
+ "holds exactly one iri, because which instance a tool call reaches "
+ f"must be readable from the configuration. Available: {available}."
+ )
+ ctx = Context(
+ settings,
+ Policy(
+ capture_stdout=True,
+ errors_as_dicts=True,
+ allow_writes=not settings.read_only,
+ allow_interactive=False,
+ ),
+ )
+ mcp = MCPServer("osw", instructions=INSTRUCTIONS, version=osw.__version__)
+ for op in iter_operations(surface="mcp", include_writes=not settings.read_only):
+ mcp.tool(**tool_kwargs(op, settings))(bind(op, ctx))
+ return mcp, ctx
+
+
+def create_server() -> MCPServer:
+ """Build the MCPServer, registering tools per the read-only setting."""
+ mcp, _ctx = _build_server()
+ return mcp
+
+
+def main() -> None:
+ """Console-script entry point: build the server and serve over stdio."""
+ try:
+ mcp, ctx = _build_server()
+ except Exception as exc:
+ print(f"[osw-mcp] failed to start: {exc}", file=sys.stderr)
+ raise SystemExit(1) from exc
+
+ atexit.register(ctx.close)
+ try:
+ mcp.run(transport="stdio")
+ finally:
+ ctx.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/osw/service/__init__.py b/src/osw/service/__init__.py
new file mode 100644
index 0000000..33dac85
--- /dev/null
+++ b/src/osw/service/__init__.py
@@ -0,0 +1,6 @@
+"""osw.service: SDK-free shared core used by both ``osw-mcp`` and the ``osw`` CLI.
+
+Nothing in this package may import the ``mcp`` SDK or ``osw.cli``.
+"""
+
+from __future__ import annotations
diff --git a/src/osw/service/config.py b/src/osw/service/config.py
new file mode 100644
index 0000000..14dabac
--- /dev/null
+++ b/src/osw/service/config.py
@@ -0,0 +1,505 @@
+"""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
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+from urllib.parse import urlparse
+
+import yaml
+
+from osw.auth import CredentialManager
+
+_TRUTHY = {"1", "true", "yes", "on"}
+
+# Environment variable names. Each tuple lists the canonical ``OSW_*`` name
+# first, followed by every alias that must keep working. ``OSW_CRED_FILEPATH``
+# is canonical (rather than an ``OSW_MCP_``-prefixed name) because
+# ``osw.express`` already reads that exact name (see ``src/osw/express.py``,
+# search for ``CRED_FILEPATH``); the ``OSW_MCP_`` prefix used elsewhere was a
+# gratuitous divergence from that. ``OSL_*`` names are accepted as legacy
+# fallbacks, matching osw itself.
+ENV_DOMAIN = ("OSW_DOMAIN", "OSL_DOMAIN")
+ENV_USERNAME = ("OSW_USERNAME", "OSL_USERNAME")
+ENV_PASSWORD = ("OSW_PASSWORD", "OSL_PASSWORD")
+ENV_CRED_FILEPATH = ("OSW_CRED_FILEPATH", "OSW_MCP_CRED_FILEPATH", "OSL_CRED_FILEPATH")
+ENV_SPARQL_ENDPOINT = ("OSW_SPARQL_ENDPOINT",)
+ENV_READ_ONLY = ("OSW_READ_ONLY", "OSW_MCP_READ_ONLY")
+ENV_STATE_DIR = ("OSW_STATE_DIR", "OSW_MCP_STATE_DIR")
+ENV_MAX_RESULTS = ("OSW_MAX_RESULTS", "OSW_MCP_MAX_RESULTS")
+ENV_MAX_CHARS = ("OSW_MAX_CHARS", "OSW_MCP_MAX_CHARS")
+ENV_FILE = ("OSW_ENV_FILE", "OSW_MCP_ENV_FILE")
+
+
+def _first_env(names: tuple[str, ...]) -> Optional[str]:
+ """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 picked with the CLI's --instance).
+ domain: Optional[str]
+ # username/password are optional: a configured credential file is an
+ # alternative source of credentials (see ENV_CRED_FILEPATH).
+ 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(names: tuple[str, ...], default: int) -> int:
+ raw = _first_env(names)
+ if raw is None or raw.strip() == "":
+ return default
+ try:
+ return int(raw)
+ except ValueError:
+ # Name the variable that was actually set (not necessarily the
+ # canonical one), so the operator can find what to fix.
+ name = next((n for n in names if os.getenv(n) == raw), names[0])
+ raise RuntimeError(
+ f"Environment variable {name}={raw!r} is not a valid integer."
+ )
+
+
+def _escape_hint(value: str) -> str:
+ """Extra error text when ``value`` holds a control character, else "".
+
+ A double-quoted value in a ``.env`` file goes through escape decoding, so
+ a Windows path like ``"C:\\dir\\accounts.yaml"`` silently loses its ``\\a``
+ to a BEL byte. The result renders as nothing in a terminal, which makes the
+ resulting "does not exist" message look like it is naming the right path.
+ """
+ if not any(ord(char) < 32 for char in value):
+ return ""
+ return (
+ f" The configured path contains a control character ({value!r}). A "
+ "double-quoted value in a .env file is escape-decoded, so a Windows "
+ r"path loses sequences like \a, \b, \f, \n, \r, \t and \v. Use single "
+ "quotes, no quotes, forward slashes, or doubled backslashes."
+ )
+
+
+def _cred_file_iris(cred_filepath: str) -> list[str]:
+ """Return the top-level iri keys in a credential YAML file, best effort."""
+ try:
+ 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."
+ )
+
+
+# Whether to look for a .env file when none is configured explicitly. Off by
+# default, so a process only reads a file it was pointed at: the MCP server's
+# working directory is chosen by the MCP client, so searching it would make
+# which credentials get loaded depend on how the client was launched. The CLI
+# turns it on (see osw.cli.main), where the working directory is the one the
+# user typed the command in.
+_discover_env_file: bool = False
+
+# Where the .env file actually came from, for the startup banner. One of
+# "explicit", "discovered", "none" (searched, nothing found) or "not searched".
+_env_file_path: Optional[str] = None
+_env_file_origin: str = "not searched"
+
+
+def set_env_file_discovery(enabled: bool) -> None:
+ """Enable or disable the implicit ``.env`` search (default: disabled).
+
+ Must be called before settings are first loaded, since the file is read
+ exactly once per process; a call that would *change* the setting after
+ that raises rather than silently having no effect. Re-asserting the
+ current value is always allowed, so an adapter can call this on every
+ command without tracking whether it already did.
+ """
+ global _discover_env_file
+ if enabled != _discover_env_file and _settings is not None:
+ raise RuntimeError(
+ "set_env_file_discovery() must be called before settings are "
+ "loaded; they are already cached for this process."
+ )
+ _discover_env_file = enabled
+
+
+def _load_env_file() -> None:
+ """Load a .env file if one is configured or (when enabled) discoverable.
+
+ dotenv is optional (it ships with the ``mcp`` extra). An *explicitly*
+ configured env file with dotenv missing is an error, because the operator
+ asked for something that cannot happen. An implicit search is skipped
+ silently.
+
+ The implicit search starts at the current working directory and walks
+ upward. ``dotenv.load_dotenv()`` with no arguments would instead walk up
+ from the *calling module's* directory, which is this file: under an
+ editable install that is the osw checkout and under a normal install it is
+ site-packages. Neither is what a user standing in a project directory
+ means by "the .env file", hence the explicit ``usecwd=True``.
+ """
+ global _env_file_path, _env_file_origin
+ path = _first_env(ENV_FILE)
+ try:
+ import dotenv
+ except ImportError:
+ if path is None:
+ return
+ name = next((n for n in ENV_FILE if os.getenv(n) == path), ENV_FILE[0])
+ raise RuntimeError(
+ f"{name} is set (to '{path}') but python-dotenv is not installed. "
+ "Install the osw[mcp] extra, or the `test` dependency group, to "
+ "use an env file."
+ )
+ if path:
+ dotenv.load_dotenv(path)
+ _env_file_path, _env_file_origin = path, "explicit"
+ return
+ if not _discover_env_file:
+ return
+ found = dotenv.find_dotenv(usecwd=True)
+ if not found:
+ _env_file_origin = "none"
+ return
+ dotenv.load_dotenv(found)
+ _env_file_path, _env_file_origin = found, "discovered"
+
+
+def log_config_sources(stream=None) -> None:
+ """Print where configuration was read from, one line per source.
+
+ Loads the ``.env`` file first if that has not happened yet, and reads the
+ environment directly rather than a ``Settings``. Both so this can run
+ *before* settings are loaded: a misconfiguration makes loading raise, and
+ that is exactly when knowing which files were read matters most.
+
+ Always writes to ``stderr``: under MCP ``stdout`` carries the JSON-RPC
+ stream, and under ``osw --json`` it carries the result payload.
+ """
+ _load_env_file()
+ out = sys.stderr if stream is None else stream
+ described = {
+ "explicit": f"{_env_file_path} (from {ENV_FILE[0]})",
+ "discovered": f"{_env_file_path} (found from the working directory upward)",
+ "none": "none found (searched from the working directory upward)",
+ "not searched": f"not configured (set {ENV_FILE[0]} to use one)",
+ }[_env_file_origin]
+ print(f"[osw] env file : {described}", file=out)
+ cred_filepath = _first_env(ENV_CRED_FILEPATH)
+ if cred_filepath:
+ print(f"[osw] credential file: {cred_filepath}", file=out)
+
+
+def load(strict: bool = True) -> Settings:
+ """Load and validate settings from the environment.
+
+ Loads a ``.env`` file first: the path in ``OSW_ENV_FILE`` (or its
+ ``OSW_MCP_ENV_FILE`` alias) if set, otherwise a search from the current
+ working directory upward, but only when ``set_env_file_discovery(True)``
+ has enabled it (the CLI does; the MCP server does not).
+
+ Credentials can come from either ``OSW_USERNAME``/``OSW_PASSWORD`` (or
+ their ``OSL_*`` aliases) or from a credential file configured via
+ ``OSW_CRED_FILEPATH`` (or its ``OSW_MCP_CRED_FILEPATH`` / ``OSL_CRED_FILEPATH``
+ aliases). When a credential file is configured, it is validated here to
+ actually contain an entry for the configured domain.
+
+ Parameters
+ ----------
+ strict:
+ When ``True`` (the default), missing required credentials (no domain
+ + username/password and no usable credential file) raise
+ ``RuntimeError``. When ``False``, that specific check is skipped and a
+ best-effort ``Settings`` is returned instead, with whatever was found
+ (fields may be ``None``) -- useful for a status command that wants to
+ report "not configured" rather than crash. Every other error still
+ raises regardless of ``strict``: a configured credential file that
+ does not exist, a configured credential file with no entry matching a
+ configured domain, an unparseable integer environment variable, and a
+ missing ``python-dotenv`` for an explicitly configured env file.
+
+ Raises
+ ------
+ RuntimeError
+ If domain is missing and no usable credential file is configured, if
+ neither a usable credential file nor username/password are
+ configured (only when ``strict`` is ``True``), if a configured
+ credential file does not exist, or if a configured credential file has
+ no entry matching a configured domain. This keeps the osw interactive
+ credential prompt from ever being reached.
+ """
+ _load_env_file()
+
+ 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_CRED_FILEPATH (or its OSW_MCP_CRED_FILEPATH / "
+ "OSL_CRED_FILEPATH aliases) to a valid path, or remove it and "
+ "configure OSW_USERNAME/OSW_PASSWORD instead."
+ + _escape_hint(cred_filepath)
+ )
+ cred_file_usable = True
+
+ # A usable credential file makes the domain optional: which instance to
+ # use is then chosen later (auto-selected, or via the CLI's --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 and strict:
+ raise RuntimeError(
+ "Missing required OSW credential environment variables: "
+ + ", ".join(missing)
+ + ". Set them in your environment or a .env file "
+ "(pointed to by OSW_ENV_FILE / OSW_MCP_ENV_FILE), or configure a "
+ "credential file via OSW_CRED_FILEPATH (or its OSW_MCP_CRED_FILEPATH "
+ "/ OSL_CRED_FILEPATH aliases). The server refuses to start without "
+ "them to avoid an interactive credential prompt that would hang "
+ "the stdio transport."
+ )
+
+ if cred_file_usable and domain:
+ _verify_cred_file_has_domain(cred_filepath, domain)
+
+ return Settings(
+ domain=domain,
+ username=username,
+ password=password,
+ cred_filepath=cred_filepath,
+ sparql_endpoint=_first_env(ENV_SPARQL_ENDPOINT),
+ read_only=(_first_env(ENV_READ_ONLY) or "").lower() in _TRUTHY,
+ state_dir=_first_env(ENV_STATE_DIR),
+ max_results=_int_env(ENV_MAX_RESULTS, 100),
+ max_chars=_int_env(ENV_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
+ global _discover_env_file, _env_file_path, _env_file_origin
+ _settings = None
+ _active_iri = None
+ _active_resolved = False
+ _discover_env_file = False
+ _env_file_path = None
+ _env_file_origin = "not searched"
+
+
+# -- active-instance state ---------------------------------------------------
+#
+# 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 via ``set_active_instance`` (the CLI's ``--instance``
+# flag; the MCP server is pinned to one instance and never switches).
+
+_active_iri: Optional[str] = None
+_active_resolved: bool = False
+
+
+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 CLI's ``--instance`` flag).
+ """
+ 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/service/context.py b/src/osw/service/context.py
new file mode 100644
index 0000000..997bfbb
--- /dev/null
+++ b/src/osw/service/context.py
@@ -0,0 +1,163 @@
+"""Per-instance execution context shared by every osw.service adapter.
+
+Holds the connection state (``osw``, ``ledger``, the lock) on an object rather
+than in module-level globals, so a single process can hold more than one
+connected instance and tests can inject a fake ``osw``/``ledger`` instead of
+monkeypatching a module.
+
+The osw library prints progress to ``stdout`` (e.g. "Connecting to ..."). On
+the MCP stdio transport ``stdout`` is the JSON-RPC channel, so
+:meth:`Context.guard` redirects it to ``stderr`` for the duration of each osw
+call -- but only when ``policy.capture_stdout`` is set. A plain CLI run wants
+that progress output visible, so its policy leaves stdout alone.
+"""
+
+from __future__ import annotations
+
+import sys
+import threading
+from contextlib import contextmanager, redirect_stdout
+from dataclasses import dataclass
+from typing import Optional
+
+from osw.auth import CredentialManager
+from osw.express import OswExpress
+from osw.service import config, errors
+from osw.service.config import Settings
+from osw.service.ledger import Ledger
+from osw.wtsite import WtSite
+
+
+@dataclass(frozen=True)
+class Policy:
+ """How an adapter wants operations to behave."""
+
+ capture_stdout: bool = False # stdout is the JSON-RPC channel (MCP) or --json
+ errors_as_dicts: bool = False # a model needs a result; a shell needs an exit code
+ allow_writes: bool = True
+ allow_interactive: bool = False # a prompt would eat the JSON-RPC stream
+
+
+class Context:
+ """Everything a bound operation needs to run against one OSL instance.
+
+ ``osw`` and ``ledger`` are built lazily on first access; tests may instead
+ pre-set them (via the constructor or by assigning the attribute directly)
+ to inject a fake without monkeypatching a module.
+ """
+
+ def __init__(
+ self,
+ settings: Settings,
+ policy: Optional[Policy] = None,
+ *,
+ osw: Optional[OswExpress] = None,
+ ledger: Optional[Ledger] = None,
+ ) -> None:
+ self.settings = settings
+ self.policy = policy if policy is not None else Policy()
+ self._osw = osw
+ self._ledger = ledger
+ self._lock = threading.RLock()
+
+ def _require_active_domain(self) -> str:
+ """Return the active instance's domain, or raise a clear, actionable error."""
+ domain = config.get_active_domain()
+ if domain is None:
+ available = ", ".join(config.available_iris()) or "(none)"
+ raise errors.NotConfigured(
+ "No OSL instance selected. For a server process, set "
+ "OSW_DOMAIN (or OSW_ENV_FILE to point at a .env file that "
+ "sets it); for the CLI, pass --instance . "
+ f"Available: {available}."
+ )
+ return domain
+
+ @property
+ def osw(self) -> OswExpress:
+ """The shared ``OswExpress``, connecting on first use.
+
+ Credentials come from either of two sources, both already validated
+ by :func:`osw.service.config.load`:
+
+ * ``OSW_USERNAME`` / ``OSW_PASSWORD`` (or their ``OSL_*`` aliases),
+ read by osw from the environment; or
+ * a credential file (``settings.cred_filepath``), wrapped in a
+ ``CredentialManager`` and passed to ``OswExpress`` explicitly.
+ """
+ if self._osw is None:
+ domain = self._require_active_domain()
+ if self.settings.cred_filepath:
+ cred_mngr = CredentialManager(cred_filepath=self.settings.cred_filepath)
+ self._osw = OswExpress(domain=domain, cred_mngr=cred_mngr)
+ else:
+ self._osw = OswExpress(domain=domain)
+ return self._osw
+
+ @osw.setter
+ def osw(self, value: Optional[OswExpress]) -> None:
+ self._osw = value
+
+ @property
+ def ledger(self) -> Ledger:
+ """The shared provenance ledger, keyed on the active instance's domain."""
+ if self._ledger is None:
+ domain = self._require_active_domain()
+ self._ledger = Ledger(domain=domain, state_dir=self.settings.state_dir)
+ return self._ledger
+
+ @ledger.setter
+ def ledger(self, value: Optional[Ledger]) -> None:
+ self._ledger = value
+
+ @contextmanager
+ def guard(self):
+ """Serialize access to this context's instance for the call's duration.
+
+ Redirects ``stdout`` to ``stderr`` only when ``policy.capture_stdout``
+ is set (a plain CLI run wants osw's progress output visible).
+ """
+ with self._lock:
+ if self.policy.capture_stdout:
+ with redirect_stdout(sys.stderr):
+ yield
+ else:
+ yield
+
+ def limit(self, n: Optional[int]) -> int:
+ """Return ``n`` if given and truthy, else the configured default."""
+ return n or self.settings.max_results
+
+ def page(self, title: str):
+ """Return the page for ``title``.
+
+ Raises :class:`osw.service.errors.NotFound` if it does not exist.
+ """
+ page = self.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0]
+ if not page.exists:
+ raise errors.NotFound(f"Page '{title}' does not exist.")
+ return page
+
+ def require_write(self, op_name: str) -> None:
+ """Raise if this context's policy disallows writes."""
+ if not self.policy.allow_writes:
+ raise errors.ReadOnly(
+ f"Operation '{op_name}' is not permitted: writes are disabled "
+ "(set OSW_READ_ONLY=false to allow)."
+ )
+
+ def reset(self) -> None:
+ """Drop the held connection and ledger so the next access rebuilds them."""
+ with self._lock:
+ if self._osw is not None:
+ try:
+ with redirect_stdout(sys.stderr):
+ self._osw.close_connection()
+ except Exception as exc:
+ print(f"[osw] error closing connection: {exc!r}", file=sys.stderr)
+ self._osw = None
+ self._ledger = None
+
+ def close(self) -> None:
+ """Close the connection (e.g. on adapter shutdown)."""
+ self.reset()
diff --git a/src/osw/service/errors.py b/src/osw/service/errors.py
new file mode 100644
index 0000000..3c8d5a1
--- /dev/null
+++ b/src/osw/service/errors.py
@@ -0,0 +1,129 @@
+"""Stable-shaped operation errors shared by every osw.service adapter.
+
+Every operation failure is an :class:`OpError` subclass carrying a wire
+``type`` string (the shape an MCP client sees, unchanged from the
+hand-written error dicts the tool bodies returned before this module
+existed) and an ``exit_code`` (the process exit status a CLI adapter uses).
+
+Exit codes are grouped by category, not unique per subclass:
+
+* ``1`` -- generic / unexpected error (the ``OpError`` base default).
+* ``2`` -- not found: a page/entity expected to exist does not
+ (:class:`NotFound`).
+* ``3`` -- invalid input: an argument is malformed, does not validate, or
+ does not resolve (:class:`SchemaError`, :class:`ClassNotFound`,
+ :class:`ValidationError`, :class:`UnknownInstance`, :class:`InvalidSlot`,
+ :class:`InvalidContent`, :class:`SlotMissing`, :class:`BinaryContent`).
+* ``4`` -- refused/blocked: disallowed by a provenance or safety guard
+ (:class:`ExternalDeleteBlocked`, :class:`ReadOnly`).
+* ``5`` -- not configured: required configuration is missing
+ (:class:`NotConfigured`).
+"""
+
+from __future__ import annotations
+
+from typing import Optional
+
+
+class OpError(Exception):
+ """Base for operation failures with a stable wire shape and a CLI exit code."""
+
+ type: str = "Error"
+ exit_code: int = 1
+
+ def __init__(self, message: str, *, extra: Optional[dict] = None) -> None:
+ super().__init__(message)
+ self.extra: dict = dict(extra) if extra else {}
+
+ def payload(self) -> dict:
+ """The dict an MCP client receives. Must match today's shape exactly."""
+ return {**self.extra, "error": str(self), "type": self.type}
+
+
+class NotFound(OpError):
+ """A page or entity that was expected to exist does not."""
+
+ type = "NotFound"
+ exit_code = 2
+
+
+class SchemaError(OpError):
+ """A category's schema could not be fetched."""
+
+ type = "SchemaError"
+ exit_code = 3
+
+
+class ClassNotFound(OpError):
+ """No generated model class could be resolved for a category."""
+
+ type = "ClassNotFound"
+ exit_code = 3
+
+
+class ValidationError(OpError):
+ """A ``jsondata`` payload does not validate against its category."""
+
+ type = "ValidationError"
+ exit_code = 3
+
+
+class ExternalDeleteBlocked(OpError):
+ """A delete was refused because the page was not created by this server."""
+
+ type = "ExternalDeleteBlocked"
+ exit_code = 4
+
+
+class ReadOnly(OpError):
+ """A write was refused because writes are disabled for this context."""
+
+ type = "ReadOnly"
+ exit_code = 4
+
+
+class UnknownInstance(OpError):
+ """A requested instance iri is not among the configured/available ones."""
+
+ type = "UnknownInstance"
+ exit_code = 3
+
+
+class NotConfigured(OpError):
+ """Required configuration is missing (e.g. an active instance, a SPARQL
+ endpoint)."""
+
+ type = "NotConfigured"
+ exit_code = 5
+
+
+class InvalidSlot(OpError):
+ """A slot key is not one of the valid ``osw.wtsite.SLOTS`` keys."""
+
+ type = "InvalidSlot"
+ exit_code = 3
+
+
+class InvalidContent(OpError):
+ """A slot's content does not match its content model (json/wikitext)."""
+
+ type = "InvalidContent"
+ exit_code = 3
+
+
+class SlotMissing(OpError):
+ """A slot does not exist on a page and ``create_if_missing`` is false."""
+
+ type = "SlotMissing"
+ exit_code = 3
+
+
+class BinaryContent(OpError):
+ """A file's bytes do not decode under the requested text encoding.
+
+ Raised by ``read_file_text`` when the requested file is not text; the mcp
+ surface cannot return raw bytes, so the caller must use the CLI instead.
+ """
+
+ type = "BinaryContent"
+ exit_code = 3
diff --git a/src/osw/service/ledger.py b/src/osw/service/ledger.py
new file mode 100644
index 0000000..94ec49b
--- /dev/null
+++ b/src/osw/service/ledger.py
@@ -0,0 +1,155 @@
+"""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
+
+from pydantic import BaseModel
+
+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 LedgerRecord(BaseModel):
+ """One ledger entry an operation wants written after a successful write.
+
+ Mirrors the keyword arguments of :meth:`Ledger.record`, minus ``tool``,
+ which ``bind()`` fills in from the operation name.
+ """
+
+ title: str
+ op: str # the verb: "create", "update", "create_or_update"
+ change_id: Optional[str] = None
+ slots: Optional[List[str]] = None
+ uuid: Optional[str] = None
+ namespace: Optional[str] = None
+
+
+class Ledger:
+ """A JSON-backed record of pages created/modified by this server."""
+
+ 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/service/ops/__init__.py b/src/osw/service/ops/__init__.py
new file mode 100644
index 0000000..9c5c644
--- /dev/null
+++ b/src/osw/service/ops/__init__.py
@@ -0,0 +1,18 @@
+"""Operation implementations, one module per group.
+
+Importing this package registers every operation in
+:data:`osw.service.registry.REGISTRY`. It imports nothing from ``osw.mcp``,
+``osw.cli`` or the ``mcp`` SDK, so this package (and by extension
+``osw.service``) stays importable without the optional ``mcp`` extra and never
+depends on an adapter. ``typer`` is a base dependency, so op modules may import
+it directly to mark up a parameter's CLI form (see ``create_or_update_entity``'s
+``jsondata`` and :mod:`osw.service.params`); pydantic ignores ``Annotated``
+metadata it does not recognise, so the MCP JSON schema is unaffected.
+
+Import order fixes the order adapters see, so it is also the order tools are
+registered on the MCP server and commands are listed in ``osw --help``.
+"""
+
+from __future__ import annotations
+
+from . import entities, files, schema, search, slots, status
diff --git a/src/osw/service/ops/entities.py b/src/osw/service/ops/entities.py
new file mode 100644
index 0000000..a6229d6
--- /dev/null
+++ b/src/osw/service/ops/entities.py
@@ -0,0 +1,208 @@
+"""Entity operations: read entity JSON, export JSON-LD, create/update, delete."""
+
+from __future__ import annotations
+
+import sys
+from typing import Annotated, Optional
+
+import typer
+
+import osw.model.entity as model_entity
+from osw.core import OSW, AddOverwriteClassOptions, OverwriteOptions
+from osw.service import config, errors
+from osw.service.context import Context
+from osw.service.ledger import LedgerRecord
+from osw.service.params import json_value
+from osw.service.registry import operation
+from osw.service.serialization import maybe_truncate, to_jsonable
+from osw.wtsite import WtSite
+
+_OVERWRITE = {
+ "true": OverwriteOptions.true,
+ "false": OverwriteOptions.false,
+ "only empty": OverwriteOptions.only_empty,
+ "replace remote": AddOverwriteClassOptions.replace_remote,
+ "keep existing": AddOverwriteClassOptions.keep_existing,
+}
+
+
+def _parse_overwrite(value: str):
+ key = str(value).lower().strip()
+ if key not in _OVERWRITE:
+ raise ValueError(
+ f"Invalid overwrite '{value}'. Valid options: {list(_OVERWRITE)}"
+ )
+ return _OVERWRITE[key]
+
+
+def _resolve_category_class(category: str):
+ """Find the generated model class whose ``type`` default targets ``category``.
+
+ Avoids guessing the datamodel-code-generator class name; matches on the
+ ``type`` default (e.g. ``["Category:OSW..."]``) instead.
+ """
+ for obj in vars(model_entity).values():
+ if not isinstance(obj, type) or not hasattr(obj, "__fields__"):
+ continue
+ field = obj.__fields__.get("type")
+ default = getattr(field, "default", None) if field is not None else None
+ if default and category in default:
+ return obj
+ return None
+
+
+@operation(group="entity", cli_name="get", read_only_hint=True, idempotent_hint=True)
+def get_entity(ctx: Context, title: str) -> dict:
+ """Return an entity's stored JSON data (its ``jsondata`` slot).
+
+ ``title`` is a full page name, e.g. ``Item:OSW123...``. Reading the slot
+ directly does not modify any local files.
+ """
+ page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0]
+ if not page.exists:
+ return {"title": title, "exists": False, "jsondata": None}
+ content, truncated = maybe_truncate(
+ page.get_slot_content("jsondata"), ctx.settings.max_chars
+ )
+ return {
+ "title": title,
+ "exists": True,
+ "jsondata": content,
+ "url": page.get_url(),
+ "truncated": truncated,
+ }
+
+
+@operation(group="entity", cli_name="export", read_only_hint=True, idempotent_hint=True)
+def export_entity_jsonld(
+ ctx: Context, title: str, mode: str = "expand", build_rdf: bool = False
+) -> dict:
+ """Export an entity as JSON-LD (and optionally RDF/Turtle).
+
+ ``mode`` is one of expand | flatten | compact | frame. Note: this loads
+ the entity with schema auto-fetch, which regenerates the local generated
+ model module as a side effect.
+ """
+ result = ctx.osw.load_entity(
+ OSW.LoadEntityParam(titles=[title], autofetch_schema=True)
+ )
+ entities = result.entities
+ if not isinstance(entities, list):
+ entities = [entities]
+ if not entities:
+ raise errors.NotFound(f"Entity '{title}' not found.")
+ export = ctx.osw.export_jsonld(
+ OSW.ExportJsonLdParams(entities=entities, mode=mode, build_rdf_graph=build_rdf)
+ )
+ out = {"jsonld": to_jsonable(export.documents[0]) if export.documents else None}
+ if build_rdf and export.graph is not None:
+ out["rdf_turtle"] = export.graph.serialize(format="turtle")
+ return out
+
+
+@operation(
+ group="entity",
+ cli_name="put",
+ writes=True,
+ destructive_hint=False,
+ idempotent_hint=True,
+ records=lambda r: [
+ LedgerRecord(
+ title=t, op="create_or_update", change_id=r["change_id"], slots=["jsondata"]
+ )
+ for t in r["titles"]
+ ],
+)
+def create_or_update_entity(
+ ctx: Context,
+ category: str,
+ jsondata: Annotated[dict, typer.Option(parser=json_value)],
+ namespace: Optional[str] = None,
+ overwrite: str = "keep existing",
+ comment: Optional[str] = None,
+) -> dict:
+ """Create or update an entity of ``category`` from a ``jsondata`` payload.
+
+ ``category`` is a full category page name (e.g. ``Category:Item``); use
+ ``get_category_schema`` to learn the valid fields first. ``overwrite``
+ controls update behavior: one of true | false | only empty |
+ replace remote | keep existing. Records the resulting page(s) in the
+ provenance ledger so they can be deleted without extra confirmation.
+ """
+ fetch = ctx.osw.fetch_schema(
+ OSW.FetchSchemaParam(schema_title=category, mode="append")
+ )
+ if fetch.error_messages:
+ raise errors.SchemaError("; ".join(fetch.error_messages))
+ cls = _resolve_category_class(category)
+ if cls is None:
+ raise errors.ClassNotFound(
+ f"Could not resolve a model class for '{category}' after "
+ "fetching its schema. Check the category page name."
+ )
+ try:
+ entity = cls(**jsondata)
+ except Exception as exc:
+ raise errors.ValidationError(
+ f"jsondata does not validate against {category}: {exc}"
+ )
+ store = ctx.osw.store_entity(
+ OSW.StoreEntityParam(
+ entities=[entity],
+ namespace=namespace,
+ overwrite=_parse_overwrite(overwrite),
+ edit_comment=comment,
+ bot_edit=True,
+ )
+ )
+ titles = list(store.pages.keys())
+ domain = config.get_active_domain()
+ return {
+ "titles": titles,
+ "change_id": store.change_id,
+ "urls": [f"https://{domain}/wiki/{t}" for t in titles],
+ }
+
+
+@operation(
+ group="entity",
+ cli_name="delete",
+ writes=True,
+ destructive_hint=True,
+ requires_user_interaction=True,
+)
+def delete_entity(
+ ctx: Context,
+ title: str,
+ confirm_external_delete: bool = False,
+ comment: Optional[str] = None,
+) -> dict:
+ """Delete a page by full title, guarded by provenance.
+
+ Pages this server created/modified (tracked in the ledger) are deleted
+ without extra confirmation. Deleting any other page requires
+ ``confirm_external_delete=true``.
+ """
+ tracked = ctx.ledger.is_tracked(title)
+ if not tracked and not confirm_external_delete:
+ raise errors.ExternalDeleteBlocked(
+ f"Refusing to delete '{title}': it was not created by this "
+ "MCP server. Re-run with confirm_external_delete=true to "
+ "override.",
+ extra={"title": title},
+ )
+ page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0]
+ if not page.exists:
+ raise errors.NotFound(
+ f"Page '{title}' does not exist.",
+ extra={"title": title, "deleted": False},
+ )
+ if not tracked:
+ print(
+ f"[osw-mcp] WARNING: deleting externally-created page "
+ f"'{title}' (confirm_external_delete=True)",
+ file=sys.stderr,
+ )
+ page.delete(comment or "[osw-mcp] delete")
+ ctx.ledger.mark_deleted(title)
+ return {"title": title, "deleted": True}
diff --git a/src/osw/service/ops/files.py b/src/osw/service/ops/files.py
new file mode 100644
index 0000000..50f31af
--- /dev/null
+++ b/src/osw/service/ops/files.py
@@ -0,0 +1,153 @@
+"""Path-free wiki file content operations: info, read, write.
+
+``WikiFileController.get()`` returns a live stream and ``.put()`` accepts one
+(see ``osw.controller.file.wiki``), so these operations never touch the local
+filesystem: content moves between the wiki and the caller entirely in
+memory, in bounded chunks. Path-taking counterparts (download to disk, upload
+from disk) live in ``osw.cli.ops``, the only module allowed to name a path.
+"""
+
+from __future__ import annotations
+
+import codecs
+from io import BytesIO
+from typing import Optional
+
+from osw.controller.file.wiki import WikiFileController
+from osw.core import OverwriteOptions
+from osw.service import errors
+from osw.service.context import Context
+from osw.service.ledger import LedgerRecord
+from osw.service.registry import operation
+from osw.utils.wiki import title_from_full_title
+from osw.wtsite import WtSite
+
+
+def _file_controller(ctx: Context, title: str) -> WikiFileController:
+ """Build a ``WikiFileController`` bound to ``title`` (a full ``File:`` title)."""
+ return WikiFileController(
+ osw=ctx.osw, title=title_from_full_title(title), namespace="File"
+ )
+
+
+@operation(
+ group="file",
+ cli_name="info",
+ read_only_hint=True,
+ idempotent_hint=True,
+)
+def get_file_info(ctx: Context, title: str) -> dict:
+ """Return a wiki file's metadata: url, existence, size and media type.
+
+ ``title`` is a full ``File:`` page title. Reads only the headers of the
+ same download stream ``read_file_text`` uses; the file's content is
+ never pulled into memory.
+ """
+ page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0]
+ if not page.exists:
+ return {
+ "title": title,
+ "exists": False,
+ "url": None,
+ "size": None,
+ "media_type": None,
+ }
+
+ wf = _file_controller(ctx, title)
+ stream = wf.get()
+ try:
+ size = stream.headers.get("Content-Length")
+ media_type = stream.headers.get("Content-Type")
+ finally:
+ stream.close()
+ return {
+ "title": title,
+ "exists": True,
+ "url": wf.url,
+ "size": int(size) if size is not None else None,
+ "media_type": media_type,
+ }
+
+
+@operation(
+ group="file",
+ cli_name="cat",
+ read_only_hint=True,
+ idempotent_hint=True,
+)
+def read_file_text(
+ ctx: Context, title: str, encoding: str = "utf-8", limit: Optional[int] = None
+) -> dict:
+ """Read a wiki file's content as text, returned inline in the result.
+
+ Reads at most ``limit`` (or the server's configured max_chars) bytes plus
+ one, so an oversized file is never pulled fully into memory; truncation
+ is reported in the result rather than silently dropping content. If the
+ bytes do not decode under ``encoding``, use ``osw file download`` instead
+ to fetch the file to disk.
+ """
+ page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0]
+ if not page.exists:
+ raise errors.NotFound(f"File '{title}' does not exist.")
+
+ cap = limit if limit is not None else ctx.settings.max_chars
+ wf = _file_controller(ctx, title)
+ stream = wf.get()
+ try:
+ raw = stream.read(cap + 1)
+ finally:
+ stream.close()
+ truncated = len(raw) > cap
+ if truncated:
+ raw = raw[:cap]
+ try:
+ # Decoded incrementally, with final=False when the read was capped:
+ # `cap` counts bytes, so truncating can split a multi-byte character.
+ # A plain bytes.decode() would raise on that trailing fragment and a
+ # perfectly valid text file would be reported as binary. final=False
+ # buffers the fragment (and so discards it) while still raising on
+ # bytes that are genuinely undecodable.
+ content = codecs.getincrementaldecoder(encoding)().decode(raw, not truncated)
+ except UnicodeDecodeError as exc:
+ raise errors.BinaryContent(
+ f"File '{title}' is not valid {encoding} text; use "
+ "`osw file download` instead to fetch it to disk."
+ ) from exc
+ return {
+ "title": title,
+ "content": content,
+ "encoding": encoding,
+ "truncated": truncated,
+ }
+
+
+@operation(
+ group="file",
+ cli_name="write",
+ writes=True,
+ destructive_hint=False,
+ idempotent_hint=True,
+ records=lambda r: [LedgerRecord(title=r["title"], op="create", slots=["jsondata"])],
+)
+def write_file_text(
+ ctx: Context,
+ title: str,
+ content: str,
+ name: Optional[str] = None,
+ overwrite: bool = True,
+) -> dict:
+ """Write text content to a wiki file page, creating or overwriting it.
+
+ ``title`` is a full ``File:`` page title. ``name`` sets the uploaded
+ file's base name (defaults to the bare filename portion of ``title``).
+ Records the page in the provenance ledger.
+ """
+ wf = _file_controller(ctx, title)
+ stream = BytesIO(content.encode("utf-8"))
+ stream.name = name or title_from_full_title(title)
+ overwrite_opt = OverwriteOptions.true if overwrite else OverwriteOptions.false
+ wf.put(stream, overwrite=overwrite_opt)
+ return {
+ "title": f"{wf.namespace}:{wf.title}",
+ "url": wf.url,
+ }
diff --git a/src/osw/service/ops/schema.py b/src/osw/service/ops/schema.py
new file mode 100644
index 0000000..363feb1
--- /dev/null
+++ b/src/osw/service/ops/schema.py
@@ -0,0 +1,38 @@
+"""Schema introspection: fetch a category's JSON Schema so the model can build
+valid entities before writing them."""
+
+from __future__ import annotations
+
+from osw.service.context import Context
+from osw.service.registry import operation
+from osw.service.serialization import maybe_truncate
+from osw.wtsite import WtSite
+
+
+@operation(
+ group="schema",
+ cli_name="get",
+ read_only_hint=True,
+ idempotent_hint=True,
+ max_result_size_chars=200_000,
+)
+def get_category_schema(ctx: Context, category: str) -> dict:
+ """Return the JSON Schema of a category (its ``jsonschema`` slot).
+
+ ``category`` is a full category page name, e.g. ``Category:Item``. The
+ schema is read directly from the page slot, which - unlike fetching and
+ generating models - does not modify any local files. Use the returned
+ schema to construct a valid ``jsondata`` payload for
+ ``create_or_update_entity``.
+ """
+ page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[category])).pages[0]
+ if not page.exists:
+ return {"category": category, "exists": False, "schema": None}
+ schema = page.get_slot_content("jsonschema")
+ content, truncated = maybe_truncate(schema, ctx.settings.max_chars)
+ return {
+ "category": category,
+ "exists": True,
+ "schema": content,
+ "truncated": truncated,
+ }
diff --git a/src/osw/service/ops/search.py b/src/osw/service/ops/search.py
new file mode 100644
index 0000000..09ae11a
--- /dev/null
+++ b/src/osw/service/ops/search.py
@@ -0,0 +1,110 @@
+"""Search and query operations: semantic (SMW ask), full-text, instances, SPARQL."""
+
+from __future__ import annotations
+
+from typing import Optional
+
+from osw.core import OSW
+from osw.service import config, errors
+from osw.service.context import Context
+from osw.service.registry import operation
+from osw.service.serialization import cap_list, to_jsonable
+from osw.sparql_client_smw import SmwSparqlClient
+from osw.wtsite import WtSite
+
+
+@operation(
+ group="search",
+ cli_name="ask",
+ read_only_hint=True,
+ idempotent_hint=True,
+)
+def search_entities(ctx: Context, ask_query: str, limit: Optional[int] = None) -> dict:
+ """Run a Semantic MediaWiki 'ask' query and return matching page titles.
+
+ The query uses SMW ask syntax, e.g. ``[[Category:Item]]`` or
+ ``[[Category:Item]][[Keyword::sensor]]``. Returns full page titles.
+ """
+ lim = ctx.limit(limit)
+ titles = ctx.osw.site.semantic_search(
+ WtSite.SearchParam(query=ask_query, limit=lim)
+ )
+ capped, total, truncated = cap_list(titles, lim)
+ return {"titles": capped, "count": total, "truncated": truncated}
+
+
+@operation(
+ group="search",
+ cli_name="text",
+ read_only_hint=True,
+ idempotent_hint=True,
+)
+def full_text_search(ctx: Context, text: str, limit: Optional[int] = None) -> dict:
+ """Prefix/full-text search for pages whose title matches ``text``."""
+ lim = ctx.limit(limit)
+ titles = ctx.osw.site.prefix_search(WtSite.SearchParam(query=text, limit=lim))
+ capped, total, truncated = cap_list(titles, lim)
+ return {"titles": capped, "count": total, "truncated": truncated}
+
+
+@operation(
+ group="search",
+ cli_name="instances",
+ read_only_hint=True,
+ idempotent_hint=True,
+)
+def list_instances_of_category(
+ ctx: Context, category: str, limit: Optional[int] = None
+) -> dict:
+ """List full page titles of all instances of a category.
+
+ ``category`` is a full category page name, e.g. ``Category:Item``.
+ """
+ lim = ctx.limit(limit)
+ titles = ctx.osw.query_instances(
+ OSW.QueryInstancesParam(categories=category, limit=lim)
+ )
+ capped, total, truncated = cap_list(titles, lim)
+ return {"titles": capped, "count": total, "truncated": truncated}
+
+
+@operation(
+ group="search",
+ cli_name="sparql",
+ read_only_hint=True,
+ idempotent_hint=True,
+ open_world_hint=True,
+ max_result_size_chars=200_000,
+)
+def sparql_query(
+ ctx: Context, query: str, endpoint: Optional[str] = None, limit: int = 500
+) -> dict:
+ """Run a raw SPARQL query against the instance's SPARQL endpoint.
+
+ The endpoint defaults to ``OSW_SPARQL_ENDPOINT``; pass ``endpoint`` to
+ override. Returns ``{vars, bindings, count, truncated}``.
+ """
+ ep = endpoint or ctx.settings.sparql_endpoint
+ if not ep:
+ raise errors.NotConfigured(
+ "SPARQL endpoint not configured. Set OSW_SPARQL_ENDPOINT "
+ "or pass the 'endpoint' argument."
+ )
+
+ username, password = config.get_active_credentials()
+ client = SmwSparqlClient(
+ endpoint=ep,
+ domain=config.get_active_domain(),
+ auth="basic",
+ user=username,
+ password=password,
+ )
+ raw = client.sparqlQuery(query)
+ bindings = raw.get("results", {}).get("bindings", [])
+ capped, total, truncated = cap_list(bindings, limit)
+ return {
+ "vars": raw.get("head", {}).get("vars", []),
+ "bindings": to_jsonable(capped),
+ "count": total,
+ "truncated": truncated,
+ }
diff --git a/src/osw/service/ops/slots.py b/src/osw/service/ops/slots.py
new file mode 100644
index 0000000..bd05512
--- /dev/null
+++ b/src/osw/service/ops/slots.py
@@ -0,0 +1,140 @@
+"""Full multi-slot page access: list slots, read a slot, write a slot.
+
+OSW pages are multi-slot MediaWiki pages. The valid slot keys and their content
+models come from :data:`osw.wtsite.SLOTS` (main, jsondata, jsonschema, header,
+footer, template, header_template, footer_template, data_template,
+schema_template).
+"""
+
+from __future__ import annotations
+
+from typing import Optional, Union
+
+from osw.service import errors
+from osw.service.context import Context
+from osw.service.ledger import LedgerRecord
+from osw.service.registry import operation
+from osw.service.serialization import maybe_truncate
+from osw.wtsite import SLOTS, WtSite
+
+
+@operation(
+ group="slot",
+ cli_name="list",
+ read_only_hint=True,
+ idempotent_hint=True,
+)
+def list_page_slots(ctx: Context, title: str) -> dict:
+ """List the slots present on a page with their content models."""
+ page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0]
+ if not page.exists:
+ return {
+ "title": title,
+ "exists": False,
+ "slots": [],
+ "valid_slot_keys": list(SLOTS),
+ }
+ slots = []
+ for key in page._slots:
+ content = page.get_slot_content(key)
+ slots.append({
+ "key": key,
+ "content_model": page.get_slot_content_model(key),
+ "empty": content in (None, "", {}, []),
+ })
+ return {
+ "title": title,
+ "exists": True,
+ "slots": slots,
+ "valid_slot_keys": list(SLOTS),
+ }
+
+
+@operation(
+ group="slot",
+ cli_name="get",
+ read_only_hint=True,
+ idempotent_hint=True,
+)
+def get_slot(ctx: Context, title: str, slot: str) -> dict:
+ """Return the content of a single slot of a page.
+
+ ``slot`` must be one of the valid slot keys (see ``list_page_slots``).
+ """
+ if slot not in SLOTS:
+ raise errors.InvalidSlot(f"Unknown slot '{slot}'. Valid slots: {list(SLOTS)}")
+
+ page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0]
+ if not page.exists or slot not in page._slots:
+ return {"title": title, "slot": slot, "exists": False, "content": None}
+ content, truncated = maybe_truncate(
+ page.get_slot_content(slot), ctx.settings.max_chars
+ )
+ return {
+ "title": title,
+ "slot": slot,
+ "exists": True,
+ "content_model": page.get_slot_content_model(slot),
+ "content": content,
+ "truncated": truncated,
+ }
+
+
+@operation(
+ group="slot",
+ cli_name="set",
+ writes=True,
+ destructive_hint=False,
+ idempotent_hint=True,
+ records=lambda r: (
+ [LedgerRecord(title=r["title"], op="update", slots=[r["slot"]])]
+ if r.get("changed")
+ else []
+ ),
+)
+def set_slot(
+ ctx: Context,
+ title: str,
+ slot: str,
+ # Deliberately left without a typer marker: typer has no support for
+ # arbitrary Union types (verified empirically), and whether this is JSON
+ # depends on the sibling `slot` argument's content model, so a single
+ # static parser would be wrong. osw.cli.main handles the CLI coercion
+ # explicitly, after both arguments are known.
+ content: Union[str, dict, list],
+ comment: Optional[str] = None,
+ create_if_missing: bool = True,
+) -> dict:
+ """Write the content of a single slot and save the page.
+
+ JSON slots (jsondata, jsonschema) require an object/array; wikitext slots
+ require a string. Records the page in the provenance ledger.
+ """
+ if slot not in SLOTS:
+ raise errors.InvalidSlot(f"Unknown slot '{slot}'. Valid slots: {list(SLOTS)}")
+ content_model = SLOTS[slot]["content_model"]
+ if content_model == "json" and not isinstance(content, (dict, list)):
+ raise errors.InvalidContent(
+ f"Slot '{slot}' is JSON; content must be an object or array."
+ )
+ if content_model == "wikitext" and not isinstance(content, str):
+ raise errors.InvalidContent(
+ f"Slot '{slot}' is wikitext; content must be a string."
+ )
+
+ page = ctx.osw.site.get_page(WtSite.GetPageParam(titles=[title])).pages[0]
+ if slot not in page._slots:
+ if not create_if_missing:
+ raise errors.SlotMissing(
+ f"Slot '{slot}' does not exist on '{title}' and "
+ "create_if_missing is false."
+ )
+ page.create_slot(slot, content_model)
+ page.set_slot_content(slot, content)
+ page.edit(comment=comment or f"[osw-mcp] set_slot {slot}", bot_edit=True)
+ return {
+ "title": title,
+ "slot": slot,
+ "changed": True,
+ "url": page.get_url(),
+ }
diff --git a/src/osw/service/ops/status.py b/src/osw/service/ops/status.py
new file mode 100644
index 0000000..90073bb
--- /dev/null
+++ b/src/osw/service/ops/status.py
@@ -0,0 +1,59 @@
+"""Status / whoami operation: report connection and configuration (no secrets)."""
+
+from __future__ import annotations
+
+import sys
+
+from osw.service import config
+from osw.service.context import Context
+from osw.service.registry import operation
+
+
+def _osw_version():
+ try:
+ from importlib.metadata import version
+
+ return version("osw")
+ except Exception:
+ return None
+
+
+@operation(group=None, read_only_hint=True, idempotent_hint=True)
+def status(ctx: Context) -> dict:
+ """Report the active instance, user, mode and ledger info.
+
+ Performs a light connectivity check, but only when an instance is
+ selected. Never returns the password.
+ """
+ settings = ctx.settings
+ active_iri = config.get_active_iri()
+ active_domain = config.get_active_domain()
+ info = {
+ **settings.redacted(),
+ "active_iri": active_iri,
+ "active_domain": active_domain,
+ }
+ if active_iri is None:
+ available = ", ".join(config.available_iris()) or "(none)"
+ info["connected"] = False
+ info["message"] = (
+ "No OSL instance selected. For a server process, set OSW_DOMAIN "
+ "(or OSW_ENV_FILE to point at a .env file that sets it); for the "
+ f"CLI, pass --instance . Available: {available}."
+ )
+ return info
+ ledger = ctx.ledger
+ info["ledger_entry_count"] = ledger.entry_count()
+ info["osw_version"] = _osw_version()
+ try:
+ with ctx.guard():
+ _ = ctx.osw
+ info["connected"] = True
+ except Exception as exc:
+ print(
+ f"[osw-mcp] status connection check failed: {exc!r}",
+ file=sys.stderr,
+ )
+ info["connected"] = False
+ info["connection_error"] = str(exc)
+ return info
diff --git a/src/osw/service/params.py b/src/osw/service/params.py
new file mode 100644
index 0000000..92eaf44
--- /dev/null
+++ b/src/osw/service/params.py
@@ -0,0 +1,49 @@
+"""Parsers for operation parameters whose CLI form differs from their Python type.
+
+An operation declares its parameter surface once, so a parameter typed ``dict``
+needs a way to say how a shell should spell it. typer reads that from
+``Annotated[..., typer.Option(parser=...)]`` metadata on the parameter, and
+pydantic ignores metadata it does not recognise, so attaching a parser here
+leaves the MCP JSON schema untouched.
+
+This module lives in ``osw.service`` rather than ``osw.cli`` so the dependency
+runs adapter -> core: an op module must never import an adapter. typer is a base
+dependency, so importing it here costs nothing extra. ``typer.BadParameter`` is
+used deliberately -- click discards the message of a plain ``ValueError`` raised
+from a ``parser=`` callback and reports only the offending value.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+import typer
+
+
+def json_value(raw: str) -> Any:
+ """Typer parser for structured (JSON) CLI parameters.
+
+ Accepts a JSON literal, ``@path/to/file.json`` (read the file's
+ contents), or ``-`` (read from stdin).
+ """
+ if raw == "-":
+ source = "stdin"
+ text = sys.stdin.read()
+ elif raw.startswith("@"):
+ path = raw[1:]
+ source = path
+ try:
+ text = Path(path).read_text(encoding="utf-8")
+ except OSError as exc:
+ raise typer.BadParameter(f"Could not read '{path}': {exc}")
+ else:
+ source = "argument"
+ text = raw
+
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError as exc:
+ raise typer.BadParameter(f"Invalid JSON ({source}): {exc}")
diff --git a/src/osw/service/registry.py b/src/osw/service/registry.py
new file mode 100644
index 0000000..81fe473
--- /dev/null
+++ b/src/osw/service/registry.py
@@ -0,0 +1,198 @@
+"""Operation registry: one decorated function exposed identically by every
+osw.service adapter (MCP, CLI, ...).
+
+An :class:`Operation` pairs a plain function -- whose first parameter is a
+:class:`~osw.service.context.Context` and whose remaining parameters are its
+public parameter surface -- with the metadata each adapter needs (MCP tool
+annotations, CLI grouping, ledger recording). Adding an operation means
+writing one decorated function; no adapter needs editing.
+
+This module imports nothing from the ``mcp`` SDK, ``typer``, or ``osw.cli``.
+"""
+
+from __future__ import annotations
+
+import inspect
+import sys
+from typing import Any, Callable, Iterator, Literal, Optional, get_type_hints
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+from osw.service.context import Context
+from osw.service.errors import OpError
+from osw.service.ledger import LedgerRecord
+
+PATH_LIKE_NAMES = frozenset({
+ "path",
+ "paths",
+ "filepath",
+ "file_path",
+ "dir",
+ "directory",
+ "target_dir",
+ "target_path",
+ "source_path",
+ "dest",
+ "destination",
+ "output_path",
+ "outfile",
+ "local_path",
+})
+
+
+class Operation(BaseModel):
+ """One osw operation, exposed identically by every adapter.
+
+ ``fn``'s first parameter is a Context; its remaining parameters *are* the
+ public parameter surface. The MCP SDK derives its JSON schema from them and
+ typer derives its CLI options from them, so adding an operation means
+ writing one decorated function and editing no adapter.
+ """
+
+ model_config = ConfigDict(frozen=True, extra="forbid", arbitrary_types_allowed=True)
+
+ name: str
+ fn: Callable[..., dict]
+ group: Optional[str] = None # CLI first level, e.g. "entity"
+ cli_name: Optional[str] = None # CLI second level; defaults to name
+ summary: str = ""
+ writes: bool = False
+ surfaces: frozenset[Literal["mcp", "cli"]] = frozenset({"mcp", "cli"})
+ # ledger hook: given the fn's result, returns the entries to record after
+ # a successful write. ``tool`` is not part of ``LedgerRecord``; ``bind()``
+ # fills it in from the operation name.
+ records: Optional[Callable[[dict], list[LedgerRecord]]] = None
+
+ # MCP tool annotations: the spec's four hints, explicit and typed rather
+ # than a dict. The adapter maps these onto ToolAnnotations, so this
+ # module still imports nothing from the mcp SDK.
+ read_only_hint: Optional[bool] = None
+ destructive_hint: Optional[bool] = None
+ idempotent_hint: Optional[bool] = None
+ open_world_hint: Optional[bool] = None
+
+ # MCP _meta: open-ended by spec, so the two keys we use are typed and
+ # anything else goes through the escape hatch.
+ requires_user_interaction: bool = False
+ max_result_size_chars: Optional[int] = None
+ extra_meta: dict[str, Any] = Field(default_factory=dict)
+
+ @property
+ def command(self) -> str:
+ """The CLI second-level command name."""
+ return self.cli_name or self.name
+
+ @model_validator(mode="after")
+ def _validate(self) -> Operation:
+ params = list(inspect.signature(self.fn).parameters.values())
+ if not params:
+ raise ValueError(f"{self.name}: fn must take at least one parameter (ctx).")
+ if params[0].name != "ctx":
+ raise ValueError(
+ f"{self.name}: fn's first parameter must be named 'ctx', got "
+ f"{params[0].name!r}."
+ )
+ if self.records is not None and not self.writes:
+ raise ValueError(
+ f"{self.name}: records is set but writes is False; it would never fire."
+ )
+ if not (self.fn.__doc__ and self.fn.__doc__.strip()):
+ raise ValueError(
+ f"{self.name}: fn must have a non-empty docstring; it becomes "
+ "the MCP tool description and the CLI help."
+ )
+ if "mcp" in self.surfaces:
+ offending = [p.name for p in params[1:] if p.name in PATH_LIKE_NAMES]
+ if offending:
+ raise ValueError(
+ f"{self.name}: parameter(s) {', '.join(offending)} look "
+ "like filesystem paths and may not be exposed on the mcp "
+ "surface; no path may reach an MCP client."
+ )
+ return self
+
+
+REGISTRY: dict[str, Operation] = {}
+
+
+def operation(**kwargs: Any) -> Callable[[Callable[..., dict]], Callable[..., dict]]:
+ """Decorate ``fn`` as an :class:`Operation`, registering it in :data:`REGISTRY`.
+
+ Returns ``fn`` unchanged so it stays directly callable and unit-testable.
+ """
+
+ def deco(fn: Callable[..., dict]) -> Callable[..., dict]:
+ name = kwargs.get("name") or fn.__name__
+ if name in REGISTRY:
+ raise ValueError(
+ f"{name}: an operation with this name is already registered."
+ )
+ fields = {**kwargs, "name": name, "fn": fn}
+ REGISTRY[name] = Operation(**fields)
+ return fn
+
+ return deco
+
+
+def iter_operations(
+ *, surface: str, include_writes: bool = True
+) -> Iterator[Operation]:
+ """Yield registered operations available on ``surface``, in registration order."""
+ for op in REGISTRY.values():
+ if surface not in op.surfaces:
+ continue
+ if op.writes and not include_writes:
+ continue
+ yield op
+
+
+def bind(op: Operation, ctx: Context) -> Callable[..., dict]:
+ """Apply ``ctx`` to ``op.fn`` and hide it from the resulting signature."""
+
+ def bound(*args: Any, **kwargs: Any) -> dict:
+ try:
+ if op.writes:
+ ctx.require_write(op.name)
+ with ctx.guard():
+ result = op.fn(ctx, *args, **kwargs)
+ if op.writes and op.records is not None:
+ for rec in op.records(result):
+ ctx.ledger.record(
+ rec.title, tool=op.name, **rec.model_dump(exclude={"title"})
+ )
+ return result
+ except Exception as exc:
+ if not ctx.policy.errors_as_dicts:
+ raise
+ print(f"[osw] {op.name} failed: {exc!r}", file=sys.stderr)
+ if isinstance(exc, OpError):
+ return exc.payload()
+ return {"error": str(exc), "type": type(exc).__name__}
+
+ # Resolve annotations here, against the op module's globals. `bound` lives in
+ # this module, so a consumer calling get_type_hints() on it would otherwise
+ # try to resolve `from __future__ import annotations` strings against the
+ # wrong namespace. include_extras keeps Annotated[...] metadata intact.
+ try:
+ hints = get_type_hints(op.fn, include_extras=True)
+ except Exception: # unresolvable forward ref: leave the strings in place
+ hints = {}
+
+ sig = inspect.signature(op.fn)
+ params = [
+ p.replace(annotation=hints.get(p.name, p.annotation))
+ for p in list(sig.parameters.values())[1:] # drop ctx
+ ]
+ annotations = dict(getattr(op.fn, "__annotations__", {}))
+ annotations.update(hints)
+ annotations.pop("ctx", None)
+
+ bound.__name__ = op.fn.__name__
+ bound.__qualname__ = op.fn.__qualname__
+ bound.__doc__ = op.fn.__doc__
+ bound.__signature__ = sig.replace(
+ parameters=params,
+ return_annotation=hints.get("return", sig.return_annotation),
+ )
+ bound.__annotations__ = annotations
+ return bound
diff --git a/src/osw/service/serialization.py b/src/osw/service/serialization.py
new file mode 100644
index 0000000..780a169
--- /dev/null
+++ b/src/osw/service/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/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py
new file mode 100644
index 0000000..a120631
--- /dev/null
+++ b/tests/integration/test_mcp_server.py
@@ -0,0 +1,82 @@
+"""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")
+
+import osw.service.ops # noqa: F401 (registers the operations)
+from osw.service import config
+from osw.service.context import Context, Policy
+from osw.service.registry import bind, iter_operations
+
+
+@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()
+
+ ctx = Context(
+ config.get_settings(),
+ Policy(
+ capture_stdout=True,
+ errors_as_dicts=True,
+ allow_writes=True,
+ allow_interactive=False,
+ ),
+ )
+ tools = {
+ op.name: bind(op, ctx)
+ for op in iter_operations(surface="mcp", include_writes=True)
+ }
+
+ yield tools
+
+ ctx.close()
+ config.reset()
+
+
+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_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..d932458
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,429 @@
+"""Unit tests for the osw CLI (src/osw/cli).
+
+Runs in the plain dev env (no mcp extra needed): the CLI never imports the
+mcp SDK. No network is touched -- ``osw.service.context.OswExpress`` is
+patched wherever a test actually reaches a command's body.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+from unittest.mock import MagicMock
+
+import pytest
+import typer
+import yaml
+from typer.testing import CliRunner
+
+import osw.cli.main as cli_main
+from osw.cli.main import app
+from osw.cli.render import render
+from osw.core import OverwriteOptions
+from osw.service import config
+from osw.service.params import json_value
+from osw.service.registry import iter_operations
+
+_ALL_VARS = [
+ "OSW_DOMAIN",
+ "OSL_DOMAIN",
+ "OSW_USERNAME",
+ "OSL_USERNAME",
+ "OSW_PASSWORD",
+ "OSL_PASSWORD",
+ "OSW_CRED_FILEPATH",
+ "OSW_MCP_CRED_FILEPATH",
+ "OSL_CRED_FILEPATH",
+ "OSW_SPARQL_ENDPOINT",
+ "OSW_READ_ONLY",
+ "OSW_MCP_READ_ONLY",
+ "OSW_STATE_DIR",
+ "OSW_MCP_STATE_DIR",
+ "OSW_MAX_RESULTS",
+ "OSW_MCP_MAX_RESULTS",
+ "OSW_MAX_CHARS",
+ "OSW_MCP_MAX_CHARS",
+ "OSW_ENV_FILE",
+ "OSW_MCP_ENV_FILE",
+]
+
+
+@pytest.fixture(autouse=True)
+def _clean_env(monkeypatch, tmp_path):
+ """No real credentials, no real .env file, no leaked active instance."""
+ for var in _ALL_VARS:
+ monkeypatch.delenv(var, raising=False)
+ empty = tmp_path / "empty.env"
+ empty.write_text("", encoding="utf-8")
+ monkeypatch.setenv("OSW_ENV_FILE", str(empty))
+ config.reset()
+ yield
+ config.reset()
+
+
+@pytest.fixture
+def configured_env(monkeypatch, tmp_path):
+ """Just enough configuration for config.load(strict=False) to succeed."""
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "u")
+ monkeypatch.setenv("OSW_PASSWORD", "p")
+ monkeypatch.setenv("OSW_STATE_DIR", str(tmp_path / "state"))
+ config.reset()
+
+
+@pytest.fixture
+def runner():
+ return CliRunner(mix_stderr=False)
+
+
+def _error_lines(stderr: str) -> list[str]:
+ """``stderr`` minus the ``[osw]`` config banner every command prints."""
+ return [
+ line for line in stderr.strip().splitlines() if not line.startswith("[osw] ")
+ ]
+
+
+def _fake_osw_with_page(exists=True):
+ page = MagicMock()
+ page.exists = exists
+ fake_osw = MagicMock()
+ fake_osw.site.get_page.return_value.pages = [page]
+ return fake_osw, page
+
+
+# -- help works with no configuration present --------------------------------
+@pytest.mark.parametrize(
+ "args",
+ [["--help"], ["entity", "--help"], ["entity", "get", "--help"]],
+)
+def test_help_works_with_no_config_present(runner, args):
+ result = runner.invoke(app, args)
+ assert result.exit_code == 0, result.stderr
+
+
+# -- lazy Context -------------------------------------------------------------
+def test_context_is_not_built_at_import_or_help_time(monkeypatch, runner):
+ """Building the app / answering --help must never construct a Context."""
+ calls = []
+ orig_init = cli_main.Context.__init__
+
+ def spy_init(self, *args, **kwargs):
+ calls.append((args, kwargs))
+ return orig_init(self, *args, **kwargs)
+
+ monkeypatch.setattr(cli_main.Context, "__init__", spy_init)
+
+ result = runner.invoke(app, ["entity", "get", "--help"])
+
+ assert result.exit_code == 0
+ assert calls == []
+
+
+# -- command tree ---------------------------------------------------------------
+def test_every_cli_operation_is_registered_at_its_expected_path():
+ click_app = typer.main.get_command(app)
+ for op in iter_operations(surface="cli"):
+ if op.group is None:
+ assert op.command in click_app.commands, op.command
+ else:
+ assert op.group in click_app.commands, op.group
+ group_cmd = click_app.commands[op.group]
+ assert op.command in group_cmd.commands, (op.group, op.command)
+
+
+# -- successful command / rendering --------------------------------------------
+def test_successful_command_renders_to_stdout(runner, configured_env, monkeypatch):
+ fake_osw, page = _fake_osw_with_page()
+ page.get_slot_content.return_value = {"label": [{"text": "X"}]}
+ page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1"
+ monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw)
+
+ result = runner.invoke(app, ["entity", "get", "Item:OSW1"])
+
+ assert result.exit_code == 0, result.stderr
+ assert "Item:OSW1" in result.stdout
+ assert "exists" in result.stdout
+
+
+def test_json_flag_emits_parseable_json(runner, configured_env, monkeypatch):
+ fake_osw, page = _fake_osw_with_page()
+ page.get_slot_content.return_value = {"label": [{"text": "X"}]}
+ page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1"
+ monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw)
+
+ result = runner.invoke(app, ["--json", "entity", "get", "Item:OSW1"])
+
+ assert result.exit_code == 0, result.stderr
+ payload = json.loads(result.stdout)
+ assert payload == {
+ "title": "Item:OSW1",
+ "exists": True,
+ "jsondata": {"label": [{"text": "X"}]},
+ "url": "https://wiki.example.org/wiki/Item:OSW1",
+ "truncated": False,
+ }
+
+
+# -- OpError exit codes / clean error output ------------------------------------
+def test_op_error_exits_with_its_exit_code_and_no_traceback(runner, configured_env):
+ result = runner.invoke(app, ["search", "sparql", "SELECT * WHERE {?s ?p ?o}"])
+
+ assert result.exit_code == 5
+ assert _error_lines(result.stderr) == [
+ "NotConfigured: SPARQL endpoint not configured. Set "
+ "OSW_SPARQL_ENDPOINT or pass the 'endpoint' argument."
+ ]
+ assert "Traceback" not in result.stderr
+ assert "Traceback" not in result.stdout
+
+
+# -- --read-only ----------------------------------------------------------------
+def test_read_only_blocks_a_write_command(runner, configured_env):
+ result = runner.invoke(
+ app,
+ [
+ "--read-only",
+ "entity",
+ "put",
+ "Category:Item",
+ "--jsondata",
+ '{"label": [{"text": "x"}]}',
+ ],
+ )
+
+ assert result.exit_code == 4
+ assert _error_lines(result.stderr)[0].startswith("ReadOnly:")
+ assert "Traceback" not in result.stderr
+
+
+# -- set_slot's slot-dependent content coercion ---------------------------------
+# `content` is typed Union[str, dict, list] in the core and typer cannot express
+# a Union, so osw.cli.main coerces it after both arguments are known, consulting
+# the sibling `slot` argument's content model. Both directions matter: a JSON
+# slot given a raw string fails with InvalidContent, and a wikitext slot must not
+# have "123" silently parsed into an int.
+def test_set_slot_parses_content_for_a_json_slot(runner, configured_env, monkeypatch):
+ fake_osw, page = _fake_osw_with_page()
+ monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw)
+
+ result = runner.invoke(app, ["slot", "set", "Item:OSW1", "jsondata", '{"a": 1}'])
+
+ assert result.exit_code == 0, result.stderr
+ page.set_slot_content.assert_called_once_with("jsondata", {"a": 1})
+
+
+def test_set_slot_leaves_wikitext_content_a_string(runner, configured_env, monkeypatch):
+ fake_osw, page = _fake_osw_with_page()
+ monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw)
+
+ result = runner.invoke(app, ["slot", "set", "Item:OSW1", "main", "123"])
+
+ assert result.exit_code == 0, result.stderr
+ page.set_slot_content.assert_called_once_with("main", "123")
+
+
+# -- json_value -----------------------------------------------------------------
+def test_json_value_parses_a_literal():
+ assert json_value('{"a": 1}') == {"a": 1}
+
+
+def test_json_value_reads_a_file(tmp_path):
+ path = tmp_path / "data.json"
+ path.write_text('{"a": 1}', encoding="utf-8")
+ assert json_value(f"@{path}") == {"a": 1}
+
+
+def test_json_value_rejects_malformed_json():
+ with pytest.raises(typer.BadParameter):
+ json_value("not-json")
+
+
+# -- render -----------------------------------------------------------------
+def test_render_json_is_parseable():
+ result = {"a": 1, "b": [1, 2]}
+ assert json.loads(render(result, as_json=True)) == result
+
+
+def test_render_title_list_prints_titles_and_footer():
+ result = {"titles": ["Item:OSW1", "Item:OSW2"], "count": 2, "truncated": False}
+ rendered = render(result, as_json=False)
+ lines = rendered.splitlines()
+ assert lines[0] == "Item:OSW1"
+ assert lines[1] == "Item:OSW2"
+ assert "2" in lines[2]
+
+
+def test_render_dict_shows_key_value_lines():
+ result = {"title": "Item:OSW1", "exists": True}
+ rendered = render(result, as_json=False)
+ assert "title" in rendered
+ assert "Item:OSW1" in rendered
+ assert "exists" in rendered
+
+
+# -- CLI-only path-taking file commands (osw.cli.ops) ---------------------------
+# These are the only operations in the codebase allowed to name a path; they
+# are exercised here rather than in tests/test_service_ops_files.py.
+def test_download_file_writes_to_tmp_path(
+ runner, configured_env, monkeypatch, tmp_path
+):
+ fake_osw, _page = _fake_osw_with_page(exists=True)
+ monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw)
+
+ wf = MagicMock()
+ wf.title = "OSWabc123.txt"
+ wf.get.return_value = io.BytesIO(b"hello world")
+ monkeypatch.setattr("osw.cli.ops.WikiFileController", MagicMock(return_value=wf))
+
+ result = runner.invoke(
+ app,
+ ["file", "download", "File:OSWabc123.txt", "--target-dir", str(tmp_path)],
+ )
+
+ assert result.exit_code == 0, result.stderr
+ written = tmp_path / "OSWabc123.txt"
+ assert written.read_bytes() == b"hello world"
+
+
+def test_download_file_missing_page_raises_not_found(
+ runner, configured_env, monkeypatch, tmp_path
+):
+ fake_osw, _page = _fake_osw_with_page(exists=False)
+ monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw)
+
+ result = runner.invoke(
+ app,
+ ["file", "download", "File:doesnotexist.txt", "--target-dir", str(tmp_path)],
+ )
+
+ assert result.exit_code == 2 # NotFound
+ assert "NotFound" in result.stderr
+
+
+def test_upload_file_reads_from_tmp_path(runner, configured_env, monkeypatch, tmp_path):
+ fake_osw, _page = _fake_osw_with_page(exists=True)
+ monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw)
+
+ src = tmp_path / "photo.png"
+ src.write_bytes(b"binarydata")
+
+ wf = MagicMock()
+ wf.namespace = "File"
+ wf.title = "OSWxyz.png"
+ wf.url = "https://wiki.example.org/wiki/File:OSWxyz.png"
+ monkeypatch.setattr("osw.cli.ops.WikiFileController", MagicMock(return_value=wf))
+ captured = {}
+ wf.put.side_effect = lambda stream, **kwargs: captured.update(
+ name=stream.name, content=stream.read(), kwargs=kwargs
+ )
+
+ result = runner.invoke(app, ["file", "upload", str(src)])
+
+ assert result.exit_code == 0, result.stderr
+ wf.put.assert_called_once()
+ assert captured["name"] == "photo.png"
+ assert captured["content"] == b"binarydata"
+ assert captured["kwargs"] == {"overwrite": OverwriteOptions.true}
+
+
+def test_upload_file_honors_name_and_no_overwrite(
+ runner, configured_env, monkeypatch, tmp_path
+):
+ fake_osw, _page = _fake_osw_with_page(exists=True)
+ monkeypatch.setattr("osw.service.context.OswExpress", lambda **kwargs: fake_osw)
+
+ src = tmp_path / "photo.png"
+ src.write_bytes(b"binarydata")
+
+ wf = MagicMock()
+ wf.namespace = "File"
+ wf.title = "OSWxyz.png"
+ wf.url = "https://wiki.example.org/wiki/File:OSWxyz.png"
+ monkeypatch.setattr("osw.cli.ops.WikiFileController", MagicMock(return_value=wf))
+ captured = {}
+ wf.put.side_effect = lambda stream, **kwargs: captured.update(
+ name=stream.name, kwargs=kwargs
+ )
+
+ result = runner.invoke(
+ app,
+ ["file", "upload", str(src), "--name", "renamed.png", "--no-overwrite"],
+ )
+
+ assert result.exit_code == 0, result.stderr
+ assert captured["name"] == "renamed.png"
+ assert captured["kwargs"] == {"overwrite": OverwriteOptions.false}
+
+
+def test_upload_file_missing_source_raises_not_found(runner, configured_env, tmp_path):
+ result = runner.invoke(app, ["file", "upload", str(tmp_path / "nope.png")])
+
+ assert result.exit_code == 2 # NotFound
+ assert "NotFound" in result.stderr
+
+
+# -- ledger path ------------------------------------------------------------------
+def test_ledger_path_prints_the_ledger_file_path(runner, configured_env):
+ result = runner.invoke(app, ["ledger", "path"])
+
+ assert result.exit_code == 0, result.stderr
+ assert "path" in result.stdout
+
+
+# -- instance list / --instance ---------------------------------------------------
+def test_instance_list_never_leaks_credentials(runner, monkeypatch, tmp_path):
+ cred_file = tmp_path / "accounts.yaml"
+ cred_file.write_text(
+ yaml.safe_dump({
+ "wiki-a.example.org": {"username": "alice", "password": "supersecret"},
+ }),
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file))
+ config.reset()
+
+ result = runner.invoke(app, ["instance", "list"])
+
+ assert result.exit_code == 0, result.stderr
+ assert "wiki-a.example.org" in result.stdout
+ assert "supersecret" not in result.stdout
+ assert "alice" not in result.stdout
+
+
+def test_instance_flag_sets_active_instance(runner, monkeypatch, tmp_path):
+ cred_file = tmp_path / "accounts.yaml"
+ cred_file.write_text(
+ yaml.safe_dump({
+ "wiki-a.example.org": {"username": "a", "password": "b"},
+ "wiki-b.example.org": {"username": "c", "password": "d"},
+ }),
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file))
+ config.reset()
+
+ result = runner.invoke(
+ app, ["--instance", "wiki-b.example.org", "--json", "instance", "list"]
+ )
+
+ assert result.exit_code == 0, result.stderr
+ payload = json.loads(result.stdout)
+ assert payload["active_iri"] == "wiki-b.example.org"
+ assert payload["active_domain"] == "wiki-b.example.org"
+
+
+def test_instance_flag_unknown_iri_exits_cleanly(runner, monkeypatch, tmp_path):
+ cred_file = tmp_path / "accounts.yaml"
+ cred_file.write_text(
+ yaml.safe_dump({"wiki-a.example.org": {"username": "a", "password": "b"}}),
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file))
+ config.reset()
+
+ result = runner.invoke(app, ["--instance", "nope.example.org", "instance", "list"])
+
+ assert result.exit_code == 3 # UnknownInstance
+ assert result.stderr.strip().startswith("UnknownInstance:")
+ assert "wiki-a.example.org" in result.stderr
+ assert "Traceback" not in result.stderr
diff --git a/tests/test_mcp_registration.py b/tests/test_mcp_registration.py
new file mode 100644
index 0000000..7b0fd4f
--- /dev/null
+++ b/tests/test_mcp_registration.py
@@ -0,0 +1,111 @@
+"""Unit tests for osw.mcp.server's Operation -> mcp.tool() kwargs mapping
+(``_annotations``, ``_meta``, ``tool_kwargs``).
+
+Pure unit tests, offline, no network, no live wiki. Server-level
+registration-shape tests (which tools end up on a real ``MCPServer``) live in
+``tests/test_mcp_server.py``.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+pytest.importorskip("mcp", reason="requires the osw[mcp] extra")
+
+from mcp.types import ToolAnnotations
+
+from osw.mcp.server import _annotations, _meta, tool_kwargs
+from osw.service.config import Settings
+from osw.service.registry import Operation
+
+
+def _op(**kwargs) -> Operation:
+ def fn(ctx) -> dict:
+ """A test operation."""
+ return {}
+
+ fields = {"name": "an_op", "fn": fn, **kwargs}
+ return Operation(**fields)
+
+
+def _settings(**kwargs) -> Settings:
+ fields = {"domain": "wiki.example.org", **kwargs}
+ return Settings(**fields)
+
+
+# -- _annotations -------------------------------------------------------------
+def test_annotations_maps_every_hint_onto_its_named_field():
+ op = _op(
+ read_only_hint=True,
+ destructive_hint=False,
+ idempotent_hint=True,
+ open_world_hint=False,
+ )
+
+ annotations = _annotations(op)
+
+ assert isinstance(annotations, ToolAnnotations)
+ # Assert on the real attributes (not a dict), so a misspelled field name
+ # in _annotations -- silently absorbed by ToolAnnotations' extra-field
+ # tolerance -- leaves these ``None`` and the test fails.
+ assert annotations.read_only_hint is True
+ assert annotations.destructive_hint is False
+ assert annotations.idempotent_hint is True
+ assert annotations.open_world_hint is False
+
+
+def test_annotations_none_when_no_hint_is_set():
+ op = _op()
+
+ assert _annotations(op) is None
+
+
+# -- _meta ----------------------------------------------------------------------
+def test_meta_falls_back_to_settings_max_chars():
+ op = _op()
+ settings = _settings(max_chars=12_345)
+
+ meta = _meta(op, settings)
+
+ assert meta["anthropic/maxResultSizeChars"] == 12_345
+ assert "anthropic/requiresUserInteraction" not in meta
+
+
+def test_meta_honours_op_max_result_size_chars():
+ op = _op(max_result_size_chars=999)
+ settings = _settings(max_chars=12_345)
+
+ meta = _meta(op, settings)
+
+ assert meta["anthropic/maxResultSizeChars"] == 999
+
+
+def test_meta_sets_requires_user_interaction_only_when_declared():
+ plain = _meta(_op(), _settings())
+ interactive = _meta(_op(requires_user_interaction=True), _settings())
+
+ assert "anthropic/requiresUserInteraction" not in plain
+ assert interactive["anthropic/requiresUserInteraction"] is True
+
+
+def test_meta_extra_meta_merges_last():
+ op = _op(extra_meta={"anthropic/maxResultSizeChars": 1, "custom": "x"})
+ settings = _settings(max_chars=100)
+
+ meta = _meta(op, settings)
+
+ assert meta["anthropic/maxResultSizeChars"] == 1
+ assert meta["custom"] == "x"
+
+
+# -- tool_kwargs ------------------------------------------------------------------
+def test_tool_kwargs_uses_name_and_docstring():
+ op = _op()
+ settings = _settings()
+
+ kwargs = tool_kwargs(op, settings)
+
+ assert kwargs["name"] == "an_op"
+ assert kwargs["description"] == "A test operation."
+ assert kwargs["annotations"] is None
+ assert "anthropic/maxResultSizeChars" in kwargs["meta"]
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
new file mode 100644
index 0000000..d84c772
--- /dev/null
+++ b/tests/test_mcp_server.py
@@ -0,0 +1,161 @@
+"""Registration-shape tests for the osw-mcp server: which tools end up
+registered on a real ``MCPServer``, not what any individual tool body does
+(see ``tests/test_service_ops_*.py`` for that) and not the pure
+``Operation`` -> ``mcp.tool()`` kwargs mapping (see
+``tests/test_mcp_registration.py`` for that).
+
+These are fully offline: no network, no live wiki.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+import yaml
+
+pytest.importorskip("mcp", reason="requires the osw[mcp] extra")
+pytest.importorskip("dotenv", reason="requires the osw[mcp] extra")
+
+from osw.mcp import server
+from osw.service import config
+from osw.service.registry import iter_operations
+
+_ALL_VARS = [
+ "OSW_DOMAIN",
+ "OSL_DOMAIN",
+ "OSW_USERNAME",
+ "OSL_USERNAME",
+ "OSW_PASSWORD",
+ "OSL_PASSWORD",
+ "OSW_CRED_FILEPATH",
+ "OSW_MCP_CRED_FILEPATH",
+ "OSL_CRED_FILEPATH",
+ "OSW_READ_ONLY",
+ "OSW_MCP_READ_ONLY",
+ "OSW_MCP_ENV_FILE",
+]
+
+
+@pytest.fixture(autouse=True)
+def _clean_env(monkeypatch, tmp_path):
+ for var in _ALL_VARS:
+ monkeypatch.delenv(var, raising=False)
+ # Point dotenv at an empty file so it never picks up a real .env on disk.
+ empty = tmp_path / "empty.env"
+ empty.write_text("", encoding="utf-8")
+ monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty))
+ config.reset()
+ yield
+ config.reset()
+
+
+def _configure(monkeypatch, *, read_only: bool = False) -> None:
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "u")
+ monkeypatch.setenv("OSW_PASSWORD", "p")
+ monkeypatch.setenv("OSW_READ_ONLY", "true" if read_only else "false")
+ config.reset()
+
+
+def _tool_names(mcp) -> set[str]:
+ tools = asyncio.run(mcp.list_tools())
+ return {t.name for t in tools}
+
+
+def test_every_mcp_surface_op_is_registered_and_no_others(monkeypatch):
+ _configure(monkeypatch)
+
+ names = _tool_names(server.create_server())
+
+ expected = {op.name for op in iter_operations(surface="mcp", include_writes=True)}
+ assert expected # the comparison below must not pass vacuously
+ assert names == expected
+
+
+def test_jsondata_schema_unchanged_by_cli_typer_marker(monkeypatch):
+ """A typer marker in a core signature must not alter the MCP JSON schema.
+
+ ``create_or_update_entity``'s ``jsondata`` carries an
+ ``Annotated[dict, typer.Option(parser=json_value)]`` marker so the CLI
+ knows how to spell it. That only works because pydantic ignores
+ Annotated metadata it does not recognise; if that ever stops holding,
+ the schema shipped to a model silently changes.
+ """
+ _configure(monkeypatch)
+
+ tools = asyncio.run(server.create_server().list_tools())
+ tool = next(t for t in tools if t.name == "create_or_update_entity")
+
+ assert tool.input_schema["properties"]["jsondata"]["type"] == "object"
+
+
+def test_read_only_server_omits_writes_full_server_includes_them(monkeypatch):
+ _configure(monkeypatch, read_only=True)
+ names_read_only = _tool_names(server.create_server())
+
+ _configure(monkeypatch, read_only=False)
+ names_full = _tool_names(server.create_server())
+
+ assert "get_entity" in names_read_only # a reader survives read-only mode
+ assert "create_or_update_entity" not in names_read_only
+ assert "delete_entity" not in names_read_only
+ assert "create_or_update_entity" in names_full
+ assert "delete_entity" in names_full
+
+
+def test_annotations_and_meta_reach_the_sdk_for_a_representative_op(monkeypatch):
+ _configure(monkeypatch)
+
+ tools = {t.name: t for t in asyncio.run(server.create_server().list_tools())}
+
+ tool = tools["delete_entity"]
+ assert tool.annotations is not None
+ assert tool.annotations.destructive_hint is True
+ assert tool.meta["anthropic/requiresUserInteraction"] is True
+ assert "anthropic/maxResultSizeChars" in tool.meta
+
+
+def test_no_instance_switching_tools_registered(monkeypatch):
+ _configure(monkeypatch)
+
+ names = _tool_names(server.create_server())
+
+ # Assert something WAS registered first: the two absence checks below
+ # would otherwise pass on an empty list.
+ assert "get_entity" in names
+ assert "list_instances" not in names
+ assert "select_instance" not in names
+
+
+def _write_cred_file(tmp_path, iris):
+ cred_file = tmp_path / "accounts.yaml"
+ cred_file.write_text(
+ yaml.safe_dump({iri: {"username": "a", "password": "b"} for iri in iris}),
+ encoding="utf-8",
+ )
+ return cred_file
+
+
+def test_create_server_raises_when_no_domain_is_configured(monkeypatch, tmp_path):
+ # A credential file with more than one iri makes settings valid (no
+ # OSW_DOMAIN/OSW_USERNAME/OSW_PASSWORD required) but names no instance.
+ cred_file = _write_cred_file(tmp_path, ["wiki-a.example.org", "wiki-b.example.org"])
+ monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file))
+ config.reset()
+
+ with pytest.raises(RuntimeError, match="No OSL instance configured"):
+ server.create_server()
+
+
+def test_create_server_does_not_auto_select_a_single_iri(monkeypatch, tmp_path):
+ # config.get_active_domain() *would* resolve this one (the CLI relies on
+ # that), but the server must not: which instance its tools reach has to be
+ # readable from the configuration, not inferred from the credential file.
+ cred_file = _write_cred_file(tmp_path, ["wiki-only.example.org"])
+ monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file))
+ config.reset()
+ assert config.get_active_domain() == "wiki-only.example.org"
+
+ with pytest.raises(RuntimeError, match="No OSL instance configured"):
+ server.create_server()
diff --git a/tests/test_no_paths_on_mcp_surface.py b/tests/test_no_paths_on_mcp_surface.py
new file mode 100644
index 0000000..8d5eb07
--- /dev/null
+++ b/tests/test_no_paths_on_mcp_surface.py
@@ -0,0 +1,97 @@
+"""Guard tests: no filesystem path may ever reach the MCP surface.
+
+Runs in the plain dev env (no mcp extra needed): importing ``osw.cli.ops``
+(to register the CLI-only, path-taking operations, so the negative check
+below cannot pass vacuously) and ``osw.service.ops`` touches neither the
+``mcp`` SDK nor the network. Only ``test_mcp_server_never_imports_cli``
+needs the ``mcp`` extra (it imports ``osw.mcp.server`` itself), and
+self-skips without it.
+"""
+
+from __future__ import annotations
+
+import inspect
+import subprocess
+import sys
+from unittest.mock import MagicMock
+
+import pytest
+
+# Registers every operation, including the CLI-only path-taking ones, so
+# osw.service.registry.REGISTRY is fully populated for the checks below.
+import osw.cli.ops
+import osw.service.ops # noqa: F401
+from osw.service.config import Settings
+from osw.service.context import Context, Policy
+from osw.service.registry import PATH_LIKE_NAMES, REGISTRY, bind, iter_operations
+
+_CLI_ONLY_PATH_OPS = {"download_file", "upload_file"}
+
+
+def _params(fn):
+ """The op's parameters, minus ``ctx``."""
+ return list(inspect.signature(fn).parameters.values())[1:]
+
+
+def test_no_mcp_operation_names_a_path():
+ mcp_ops = list(iter_operations(surface="mcp"))
+ assert mcp_ops, "expected at least one operation on the mcp surface"
+
+ for op in mcp_ops:
+ offending = [p.name for p in _params(op.fn) if p.name in PATH_LIKE_NAMES]
+ assert not offending, f"{op.name}: path-like parameter(s) {offending}"
+
+ # The assertion above must not pass vacuously: the CLI-only download/
+ # upload operations DO name a path, and must NOT appear on the mcp
+ # surface.
+ mcp_names = {op.name for op in mcp_ops}
+ assert not (_CLI_ONLY_PATH_OPS & mcp_names)
+
+ cli_ops_by_name = {op.name: op for op in iter_operations(surface="cli")}
+ for name in _CLI_ONLY_PATH_OPS:
+ assert name in cli_ops_by_name, f"expected {name!r} to be registered"
+ op = cli_ops_by_name[name]
+ param_names = {p.name for p in _params(op.fn)}
+ assert param_names & PATH_LIKE_NAMES, (
+ f"{name}: expected at least one path-like parameter"
+ )
+ assert "mcp" not in op.surfaces
+
+
+def test_bound_operations_do_not_expose_ctx():
+ ctx = Context(
+ Settings(domain="wiki.example.org", username="u", password="p"),
+ Policy(),
+ osw=MagicMock(),
+ ledger=MagicMock(),
+ )
+ assert REGISTRY, "expected the registry to be populated"
+ for op in REGISTRY.values():
+ bound = bind(op, ctx)
+ assert "ctx" not in inspect.signature(bound).parameters
+
+
+def test_mcp_server_never_imports_cli():
+ pytest.importorskip("mcp", reason="requires the osw[mcp] extra")
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ "import sys\n"
+ "import osw.mcp.server\n"
+ "leaked = [m for m in sys.modules if m == 'osw.cli' "
+ "or m.startswith('osw.cli.')]\n"
+ "print('LEAKED:' + ','.join(leaked) if leaked else 'CLEAN')\n",
+ ],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ # Importing osw prints unrelated hints (e.g. about the wikitext extra) on
+ # stdout, so match the sentinel line rather than the whole stream.
+ sentinel = [
+ line
+ for line in result.stdout.splitlines()
+ if line.startswith(("CLEAN", "LEAKED:"))
+ ]
+ assert sentinel == ["CLEAN"], result.stdout + result.stderr
diff --git a/tests/test_service_config.py b/tests/test_service_config.py
new file mode 100644
index 0000000..44b7b2c
--- /dev/null
+++ b/tests/test_service_config.py
@@ -0,0 +1,533 @@
+"""Unit tests for osw.service.config (fail-fast credential validation)."""
+
+import sys
+
+import pytest
+import yaml
+
+from osw.service import config
+
+_ALL_VARS = [
+ "OSW_DOMAIN",
+ "OSL_DOMAIN",
+ "OSW_USERNAME",
+ "OSL_USERNAME",
+ "OSW_PASSWORD",
+ "OSL_PASSWORD",
+ "OSW_CRED_FILEPATH",
+ "OSW_MCP_CRED_FILEPATH",
+ "OSL_CRED_FILEPATH",
+ "OSW_SPARQL_ENDPOINT",
+ "OSW_READ_ONLY",
+ "OSW_MCP_READ_ONLY",
+ "OSW_STATE_DIR",
+ "OSW_MCP_STATE_DIR",
+ "OSW_MAX_RESULTS",
+ "OSW_MCP_MAX_RESULTS",
+ "OSW_MAX_CHARS",
+ "OSW_MCP_MAX_CHARS",
+ "OSW_ENV_FILE",
+ "OSW_MCP_ENV_FILE",
+]
+
+
+@pytest.fixture(autouse=True)
+def _clean_env(monkeypatch, tmp_path):
+ 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
+
+
+# -- canonical OSW_* names --------------------------------------------------
+
+
+def test_canonical_cred_filepath(monkeypatch, tmp_path):
+ cred_file = _write_cred_file(
+ tmp_path / "accounts.yaml",
+ {"wiki.example.org": {"username": "alice", "password": "secret"}},
+ )
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file))
+ settings = config.load()
+ assert settings.cred_filepath == str(cred_file)
+
+
+def test_canonical_read_only(monkeypatch):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_READ_ONLY", "true")
+ settings = config.load()
+ assert settings.read_only is True
+
+
+def test_canonical_state_dir(monkeypatch, tmp_path):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ state_dir = str(tmp_path / "state")
+ monkeypatch.setenv("OSW_STATE_DIR", state_dir)
+ settings = config.load()
+ assert settings.state_dir == state_dir
+
+
+def test_canonical_max_results(monkeypatch):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_MAX_RESULTS", "7")
+ settings = config.load()
+ assert settings.max_results == 7
+
+
+def test_canonical_max_chars(monkeypatch):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_MAX_CHARS", "12345")
+ settings = config.load()
+ assert settings.max_chars == 12345
+
+
+def test_canonical_env_file(monkeypatch, tmp_path):
+ env = tmp_path / "creds.env"
+ env.write_text(
+ "OSW_DOMAIN=fromfile.example.org\nOSW_USERNAME=fileuser\nOSW_PASSWORD=filepw\n",
+ encoding="utf-8",
+ )
+ monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False)
+ monkeypatch.setenv("OSW_ENV_FILE", str(env))
+ settings = config.load()
+ assert settings.domain == "fromfile.example.org"
+ assert settings.username == "fileuser"
+
+
+# -- OSW_MCP_* aliases not already covered above ----------------------------
+
+
+def test_alias_state_dir(monkeypatch, tmp_path):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ state_dir = str(tmp_path / "state")
+ monkeypatch.setenv("OSW_MCP_STATE_DIR", state_dir)
+ settings = config.load()
+ assert settings.state_dir == state_dir
+
+
+def test_alias_max_chars(monkeypatch):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_MCP_MAX_CHARS", "54321")
+ settings = config.load()
+ assert settings.max_chars == 54321
+
+
+# -- canonical wins when both canonical and alias are set --------------------
+
+
+def test_canonical_wins_over_alias(monkeypatch, tmp_path):
+ cred_file = _write_cred_file(
+ tmp_path / "accounts.yaml",
+ {"wiki.example.org": {"username": "alice", "password": "secret"}},
+ )
+ other_cred_file = _write_cred_file(
+ tmp_path / "other.yaml",
+ {"wiki.example.org": {"username": "alice", "password": "secret"}},
+ )
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file))
+ monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(other_cred_file))
+ monkeypatch.setenv("OSW_READ_ONLY", "true")
+ monkeypatch.setenv("OSW_MCP_READ_ONLY", "false")
+ monkeypatch.setenv("OSW_MAX_RESULTS", "1")
+ monkeypatch.setenv("OSW_MCP_MAX_RESULTS", "2")
+ monkeypatch.setenv("OSW_MAX_CHARS", "10")
+ monkeypatch.setenv("OSW_MCP_MAX_CHARS", "20")
+ state_dir = str(tmp_path / "state")
+ other_state_dir = str(tmp_path / "other-state")
+ monkeypatch.setenv("OSW_STATE_DIR", state_dir)
+ monkeypatch.setenv("OSW_MCP_STATE_DIR", other_state_dir)
+
+ settings = config.load()
+
+ assert settings.cred_filepath == str(cred_file)
+ assert settings.read_only is True
+ assert settings.max_results == 1
+ assert settings.max_chars == 10
+ assert settings.state_dir == state_dir
+
+
+def test_canonical_env_file_wins_over_alias(monkeypatch, tmp_path):
+ canonical_env = tmp_path / "canonical.env"
+ canonical_env.write_text("OSW_DOMAIN=canonical.example.org\n", encoding="utf-8")
+ alias_env = tmp_path / "alias.env"
+ alias_env.write_text("OSW_DOMAIN=alias.example.org\n", encoding="utf-8")
+ monkeypatch.setenv("OSW_ENV_FILE", str(canonical_env))
+ monkeypatch.setenv("OSW_MCP_ENV_FILE", str(alias_env))
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+
+ settings = config.load()
+
+ assert settings.domain == "canonical.example.org"
+
+
+# -- strict=False ------------------------------------------------------------
+
+
+def test_load_not_strict_returns_settings_without_raising(monkeypatch):
+ settings = config.load(strict=False)
+ assert settings.domain is None
+ assert settings.username is None
+ assert settings.password is None
+
+
+def test_load_not_strict_still_raises_on_invalid_int(monkeypatch):
+ monkeypatch.setenv("OSW_MAX_RESULTS", "notanumber")
+ with pytest.raises(RuntimeError):
+ config.load(strict=False)
+
+
+def test_load_not_strict_still_raises_on_missing_cred_file(monkeypatch, tmp_path):
+ missing = tmp_path / "does-not-exist.yaml"
+ monkeypatch.setenv("OSW_CRED_FILEPATH", str(missing))
+ with pytest.raises(RuntimeError) as exc:
+ config.load(strict=False)
+ assert str(missing) in str(exc.value)
+
+
+# -- _load_env_file / optional dotenv ----------------------------------------
+
+
+def test_load_env_file_raises_when_configured_and_dotenv_missing(monkeypatch, tmp_path):
+ env = tmp_path / "creds.env"
+ env.write_text("OSW_DOMAIN=wiki.example.org\n", encoding="utf-8")
+ monkeypatch.setenv("OSW_ENV_FILE", str(env))
+ monkeypatch.setitem(sys.modules, "dotenv", None)
+ with pytest.raises(RuntimeError) as exc:
+ config._load_env_file()
+ assert "OSW_ENV_FILE" in str(exc.value)
+ assert "python-dotenv" in str(exc.value)
+
+
+def test_load_env_file_silent_when_not_configured_and_dotenv_missing(
+ monkeypatch,
+):
+ monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False)
+ monkeypatch.delenv("OSW_ENV_FILE", raising=False)
+ monkeypatch.setitem(sys.modules, "dotenv", None)
+ # must not raise
+ config._load_env_file()
+
+
+# -- implicit .env discovery ----------------------------------------------------
+def test_no_implicit_env_search_by_default(monkeypatch, tmp_path):
+ """Discovery is off unless an adapter opts in, so a stray .env in the
+ working directory cannot decide which instance a server connects to."""
+ monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False)
+ monkeypatch.delenv("OSW_ENV_FILE", raising=False)
+ (tmp_path / ".env").write_text(
+ "OSW_DOMAIN=from-cwd.example.org\n", encoding="utf-8"
+ )
+ monkeypatch.chdir(tmp_path)
+
+ config._load_env_file()
+
+ assert config._first_env(config.ENV_DOMAIN) is None
+ assert config._env_file_origin == "not searched"
+
+
+def test_implicit_env_search_starts_at_the_working_directory(monkeypatch, tmp_path):
+ """The search must start at the CWD, not at this module's directory.
+
+ ``dotenv.load_dotenv()`` with no arguments walks up from the *calling
+ module's* file, which is osw/service/config.py: under an editable install
+ that is the osw checkout, so it would silently load the checkout's own
+ .env no matter where the user is standing.
+ """
+ monkeypatch.delenv("OSW_MCP_ENV_FILE", raising=False)
+ monkeypatch.delenv("OSW_ENV_FILE", raising=False)
+ nested = tmp_path / "project" / "sub"
+ nested.mkdir(parents=True)
+ (tmp_path / "project" / ".env").write_text(
+ "OSW_DOMAIN=from-cwd.example.org\n", encoding="utf-8"
+ )
+ monkeypatch.chdir(nested)
+ config.set_env_file_discovery(True)
+
+ config._load_env_file()
+
+ assert config._first_env(config.ENV_DOMAIN) == "from-cwd.example.org"
+ assert config._env_file_origin == "discovered"
+
+
+def test_explicit_env_file_wins_over_discovery(monkeypatch, tmp_path):
+ explicit = tmp_path / "explicit.env"
+ explicit.write_text("OSW_DOMAIN=explicit.example.org\n", encoding="utf-8")
+ (tmp_path / ".env").write_text(
+ "OSW_DOMAIN=from-cwd.example.org\n", encoding="utf-8"
+ )
+ monkeypatch.setenv("OSW_ENV_FILE", str(explicit))
+ monkeypatch.chdir(tmp_path)
+ config.set_env_file_discovery(True)
+
+ config._load_env_file()
+
+ assert config._first_env(config.ENV_DOMAIN) == "explicit.example.org"
+ assert config._env_file_origin == "explicit"
+
+
+def test_set_env_file_discovery_raises_only_on_a_late_change(monkeypatch):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "u")
+ monkeypatch.setenv("OSW_PASSWORD", "p")
+ config.get_settings() # populates the cache
+
+ config.set_env_file_discovery(False) # re-asserting the current value is fine
+
+ with pytest.raises(RuntimeError, match="before settings are loaded"):
+ config.set_env_file_discovery(True)
+
+
+# -- .env escape footgun --------------------------------------------------------
+def test_missing_cred_file_flags_an_escape_mangled_path(monkeypatch):
+ r"""A double-quoted Windows path in .env loses \a to a BEL byte.
+
+ The mangled path then renders as if it were the path the user typed, so
+ the plain "does not exist" message looks wrong rather than informative.
+ """
+ # What dotenv produces for OSW_CRED_FILEPATH="C:\dir\accounts.yaml":
+ # the \a is decoded to BEL, which prints as nothing.
+ mangled = "C:" + chr(92) + "dir" + chr(7) + "ccounts.yaml"
+ monkeypatch.setenv("OSW_CRED_FILEPATH", mangled)
+
+ with pytest.raises(RuntimeError) as exc:
+ config.load()
+
+ assert "control character" in str(exc.value)
+ assert "single quotes" in str(exc.value)
+
+
+def test_missing_cred_file_without_control_chars_has_no_escape_hint(
+ monkeypatch, tmp_path
+):
+ monkeypatch.setenv("OSW_CRED_FILEPATH", str(tmp_path / "nope.yaml"))
+
+ with pytest.raises(RuntimeError) as exc:
+ config.load()
+
+ assert "does not exist" in str(exc.value)
+ assert "control character" not in str(exc.value)
+
+
+# -- startup banner -------------------------------------------------------------
+def test_log_config_sources_reports_env_and_cred_file(monkeypatch, tmp_path, capsys):
+ cred = tmp_path / "accounts.yaml"
+ cred.write_text(
+ yaml.safe_dump({"wiki.example.org": {"username": "u", "password": "p"}}),
+ encoding="utf-8",
+ )
+ env = tmp_path / "creds.env"
+ env.write_text("", encoding="utf-8")
+ monkeypatch.setenv("OSW_ENV_FILE", str(env))
+ monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred))
+ config._load_env_file()
+
+ config.log_config_sources()
+
+ captured = capsys.readouterr()
+ # stdout is the JSON-RPC stream under MCP and the result payload under
+ # `osw --json`, so the banner must never appear there.
+ assert captured.out == ""
+ assert str(env) in captured.err
+ assert str(cred) in captured.err
+
+
+def test_log_config_sources_omits_cred_file_when_unconfigured(monkeypatch, capsys):
+ config._load_env_file()
+
+ config.log_config_sources()
+
+ captured = capsys.readouterr()
+ assert "env file" in captured.err
+ assert "credential file" not in captured.err
diff --git a/tests/test_service_context.py b/tests/test_service_context.py
new file mode 100644
index 0000000..63f89a2
--- /dev/null
+++ b/tests/test_service_context.py
@@ -0,0 +1,190 @@
+"""Unit tests for osw.service.context (Policy defaults and Context helpers).
+
+A fake ``osw`` object is injected directly into ``Context`` so these tests
+never touch the network.
+"""
+
+import sys
+from unittest.mock import MagicMock
+
+import pytest
+import yaml
+
+from osw.service import config, errors
+from osw.service.config import Settings
+from osw.service.context import Context, Policy
+
+_ALL_VARS = [
+ "OSW_DOMAIN",
+ "OSL_DOMAIN",
+ "OSW_USERNAME",
+ "OSL_USERNAME",
+ "OSW_PASSWORD",
+ "OSL_PASSWORD",
+ "OSW_CRED_FILEPATH",
+ "OSW_MCP_CRED_FILEPATH",
+ "OSL_CRED_FILEPATH",
+]
+
+
+@pytest.fixture(autouse=True)
+def _clean_env(monkeypatch, tmp_path):
+ for var in _ALL_VARS:
+ monkeypatch.delenv(var, raising=False)
+ empty = tmp_path / "empty.env"
+ empty.write_text("", encoding="utf-8")
+ monkeypatch.setenv("OSW_MCP_ENV_FILE", str(empty))
+ config.reset()
+ yield
+ config.reset()
+
+
+def _settings(**overrides) -> Settings:
+ defaults = dict(domain="wiki.example.org", username="u", password="p")
+ defaults.update(overrides)
+ return Settings(**defaults)
+
+
+def _osw_with_page(exists: bool):
+ page = MagicMock()
+ page.exists = exists
+ osw = MagicMock()
+ osw.site.get_page.return_value.pages = [page]
+ return osw, page
+
+
+# -- Policy -------------------------------------------------------------
+def test_policy_defaults():
+ policy = Policy()
+ assert policy.capture_stdout is False
+ assert policy.errors_as_dicts is False
+ assert policy.allow_writes is True
+ assert policy.allow_interactive is False
+
+
+# -- osw / ledger injection ----------------------------------------------
+def test_osw_can_be_preset_via_constructor():
+ fake = object()
+ ctx = Context(_settings(), osw=fake)
+ assert ctx.osw is fake
+
+
+def test_osw_can_be_preset_via_attribute():
+ ctx = Context(_settings())
+ fake = object()
+ ctx.osw = fake
+ assert ctx.osw is fake
+
+
+def test_ledger_can_be_preset_via_constructor():
+ fake = object()
+ ctx = Context(_settings(), ledger=fake)
+ assert ctx.ledger is fake
+
+
+def test_ledger_can_be_preset_via_attribute():
+ ctx = Context(_settings())
+ fake = object()
+ ctx.ledger = fake
+ assert ctx.ledger is fake
+
+
+def test_osw_property_raises_not_configured_when_no_active_domain(
+ monkeypatch, tmp_path
+):
+ cred_file = tmp_path / "accounts.yaml"
+ cred_file.write_text(
+ yaml.safe_dump({
+ "wiki-a.example.org": {"username": "a", "password": "b"},
+ "wiki-b.example.org": {"username": "c", "password": "d"},
+ }),
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_file))
+ config.reset() # two iris in the file: no auto-selection is possible
+
+ ctx = Context(_settings(domain=None, username=None, password=None))
+
+ with pytest.raises(errors.NotConfigured) as exc_info:
+ _ = ctx.osw
+ assert "OSW_DOMAIN" in str(exc_info.value)
+ assert "--instance" in str(exc_info.value)
+
+
+# -- limit ----------------------------------------------------------------
+def test_limit_falls_back_to_settings_max_results():
+ ctx = Context(_settings(), osw=object())
+ assert ctx.limit(None) == ctx.settings.max_results
+ assert ctx.limit(5) == 5
+
+
+# -- page -------------------------------------------------------------------
+def test_page_returns_existing_page():
+ osw, page = _osw_with_page(True)
+ ctx = Context(_settings(), osw=osw)
+ assert ctx.page("Item:OSW1") is page
+
+
+def test_page_raises_not_found_for_missing_page():
+ osw, _page = _osw_with_page(False)
+ ctx = Context(_settings(), osw=osw)
+ with pytest.raises(errors.NotFound):
+ ctx.page("Item:OSW1")
+
+
+# -- require_write ------------------------------------------------------
+def test_require_write_raises_when_writes_disallowed():
+ ctx = Context(_settings(), Policy(allow_writes=False), osw=object())
+ with pytest.raises(errors.ReadOnly) as exc_info:
+ ctx.require_write("create_or_update_entity")
+ assert "create_or_update_entity" in str(exc_info.value)
+ assert "OSW_READ_ONLY" in str(exc_info.value)
+ assert exc_info.value.type == "ReadOnly"
+ assert exc_info.value.exit_code == 4
+
+
+def test_require_write_allows_when_writes_allowed():
+ ctx = Context(_settings(), Policy(allow_writes=True), osw=object())
+ ctx.require_write("create_or_update_entity") # must not raise
+
+
+# -- guard ------------------------------------------------------------------
+def test_guard_redirects_stdout_when_capture_stdout_true():
+ ctx = Context(_settings(), Policy(capture_stdout=True), osw=object())
+ original_stdout = sys.stdout
+ with ctx.guard():
+ assert sys.stdout is sys.stderr
+ assert sys.stdout is not original_stdout
+ assert sys.stdout is original_stdout
+
+
+def test_guard_leaves_stdout_alone_when_capture_stdout_false():
+ ctx = Context(_settings(), Policy(capture_stdout=False), osw=object())
+ original_stdout = sys.stdout
+ with ctx.guard():
+ assert sys.stdout is original_stdout
+
+
+# -- reset / close --------------------------------------------------------
+def test_reset_closes_connection_and_drops_osw_and_ledger():
+ fake_osw = MagicMock()
+ ctx = Context(_settings(), osw=fake_osw, ledger=MagicMock())
+ ctx.reset()
+ fake_osw.close_connection.assert_called_once()
+ assert ctx._osw is None
+ assert ctx._ledger is None
+
+
+def test_reset_survives_close_connection_error():
+ fake_osw = MagicMock()
+ fake_osw.close_connection.side_effect = RuntimeError("boom")
+ ctx = Context(_settings(), osw=fake_osw)
+ ctx.reset() # must not raise
+ assert ctx._osw is None
+
+
+def test_close_calls_reset():
+ fake_osw = MagicMock()
+ ctx = Context(_settings(), osw=fake_osw)
+ ctx.close()
+ fake_osw.close_connection.assert_called_once()
diff --git a/tests/test_service_errors.py b/tests/test_service_errors.py
new file mode 100644
index 0000000..966fa96
--- /dev/null
+++ b/tests/test_service_errors.py
@@ -0,0 +1,128 @@
+"""Unit tests for osw.service.errors.
+
+Each ``OpError`` subclass must reproduce, key-for-key and value-for-value, the
+error dict shape the MCP tools returned before the move to ``osw.service``.
+"""
+
+from osw.service import errors
+
+
+def test_not_found_matches_export_entity_jsonld_shape():
+ title = "Item:OSW1"
+ exc = errors.NotFound(f"Entity '{title}' not found.")
+ assert exc.payload() == {
+ "error": f"Entity '{title}' not found.",
+ "type": "NotFound",
+ }
+ assert exc.exit_code == 2
+
+
+def test_not_found_matches_delete_entity_hybrid_shape():
+ title = "Item:OSW1"
+ exc = errors.NotFound(
+ f"Page '{title}' does not exist.",
+ extra={"title": title, "deleted": False},
+ )
+ assert exc.payload() == {
+ "title": title,
+ "deleted": False,
+ "error": f"Page '{title}' does not exist.",
+ "type": "NotFound",
+ }
+
+
+def test_external_delete_blocked_matches_delete_entity_shape():
+ title = "Item:OSWx"
+ message = (
+ f"Refusing to delete '{title}': it was not created by this "
+ "MCP server. Re-run with confirm_external_delete=true to override."
+ )
+ exc = errors.ExternalDeleteBlocked(message, extra={"title": title})
+ assert exc.payload() == {
+ "title": title,
+ "error": message,
+ "type": "ExternalDeleteBlocked",
+ }
+ assert exc.exit_code == 4
+
+
+def test_schema_error_matches_create_or_update_entity_shape():
+ exc = errors.SchemaError("boom1; boom2")
+ assert exc.payload() == {"error": "boom1; boom2", "type": "SchemaError"}
+ assert exc.exit_code == 3
+
+
+def test_class_not_found_matches_create_or_update_entity_shape():
+ category = "Category:Item"
+ message = (
+ f"Could not resolve a model class for '{category}' after "
+ "fetching its schema. Check the category page name."
+ )
+ exc = errors.ClassNotFound(message)
+ assert exc.payload() == {"error": message, "type": "ClassNotFound"}
+ assert exc.exit_code == 3
+
+
+def test_validation_error_matches_create_or_update_entity_shape():
+ category = "Category:Item"
+ message = f"jsondata does not validate against {category}: bad field"
+ exc = errors.ValidationError(message)
+ assert exc.payload() == {"error": message, "type": "ValidationError"}
+ assert exc.exit_code == 3
+
+
+def test_unknown_instance_matches_select_instance_shape():
+ message = "Unknown instance 'bogus'. Available: wiki.example.org"
+ exc = errors.UnknownInstance(message)
+ assert exc.payload() == {"error": message, "type": "UnknownInstance"}
+ assert exc.exit_code == 3
+
+
+def test_not_configured_matches_sparql_query_shape():
+ message = (
+ "SPARQL endpoint not configured. Set OSW_SPARQL_ENDPOINT "
+ "or pass the 'endpoint' argument."
+ )
+ exc = errors.NotConfigured(message)
+ assert exc.payload() == {"error": message, "type": "NotConfigured"}
+ assert exc.exit_code == 5
+
+
+def test_invalid_slot_matches_slots_shape():
+ valid = ["main", "jsondata"]
+ message = f"Unknown slot 'bogus'. Valid slots: {valid}"
+ exc = errors.InvalidSlot(message)
+ assert exc.payload() == {"error": message, "type": "InvalidSlot"}
+ assert exc.exit_code == 3
+
+
+def test_invalid_content_matches_set_slot_shape():
+ message = "Slot 'jsondata' is JSON; content must be an object or array."
+ exc = errors.InvalidContent(message)
+ assert exc.payload() == {"error": message, "type": "InvalidContent"}
+ assert exc.exit_code == 3
+
+
+def test_slot_missing_matches_set_slot_shape():
+ message = (
+ "Slot 'header' does not exist on 'Item:OSW1' and create_if_missing is false."
+ )
+ exc = errors.SlotMissing(message)
+ assert exc.payload() == {"error": message, "type": "SlotMissing"}
+ assert exc.exit_code == 3
+
+
+def test_read_only_matches_require_write_shape():
+ message = (
+ "Operation 'create_or_update_entity' is not permitted: writes are "
+ "disabled (set OSW_READ_ONLY=false to allow)."
+ )
+ exc = errors.ReadOnly(message)
+ assert exc.payload() == {"error": message, "type": "ReadOnly"}
+ assert exc.exit_code == 4
+
+
+def test_base_op_error_defaults():
+ exc = errors.OpError("generic failure")
+ assert exc.payload() == {"error": "generic failure", "type": "Error"}
+ assert exc.exit_code == 1
diff --git a/tests/test_service_instances.py b/tests/test_service_instances.py
new file mode 100644
index 0000000..043e775
--- /dev/null
+++ b/tests/test_service_instances.py
@@ -0,0 +1,245 @@
+"""Unit tests for multi-instance selection in osw.service (config + Context).
+
+These are fully offline: no network, no live wiki. They also need no MCP SDK:
+osw.service is deliberately SDK-free, so unlike tests/test_mcp_*.py these run
+in the default dev environment.
+"""
+
+import pytest
+import yaml
+
+from osw.service import config, errors
+from osw.service.context import Context, Policy
+from osw.service.registry import Operation, bind
+
+_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 _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)
+
+
+# -- Context.osw / bind() without an active instance -------------------------
+def test_get_osw_raises_when_no_instance_selected(monkeypatch, tmp_path):
+ cred_file = _write_cred_file(
+ tmp_path / "accounts.yaml",
+ {
+ "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))
+ ctx = Context(config.get_settings(), Policy())
+
+ with pytest.raises(errors.NotConfigured) as exc:
+ _ = ctx.osw
+ assert "No OSL instance selected" in str(exc.value)
+ assert "wiki-a.example.org" in str(exc.value)
+ assert "wiki-b.example.org" in str(exc.value)
+
+
+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))
+
+ def _touch_osw(ctx) -> dict:
+ """Test-only op: access ctx.osw to trigger active-domain resolution."""
+ _ = ctx.osw
+ return {"ok": True}
+
+ op = Operation(name="_touch_osw", fn=_touch_osw)
+ ctx = Context(config.get_settings(), Policy(errors_as_dicts=True))
+
+ result = bind(op, ctx)()
+
+ assert result["type"] == "NotConfigured"
+ 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"
+ )
+
+
+# -- Context.reset() drops the ledger -----------------------------------------
+def test_reset_drops_ledger_for_new_domain_after_switching(monkeypatch, tmp_path):
+ cred_file = _write_cred_file(
+ tmp_path / "accounts.yaml",
+ {
+ "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")
+ ctx = Context(config.get_settings(), Policy())
+
+ ledger_a = ctx.ledger
+ assert "wiki-a.example.org" in str(ledger_a.path)
+
+ config.set_active_instance("wiki-b.example.org")
+ ctx.reset()
+ ledger_b = ctx.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_service_ledger.py b/tests/test_service_ledger.py
new file mode 100644
index 0000000..d284bc7
--- /dev/null
+++ b/tests/test_service_ledger.py
@@ -0,0 +1,78 @@
+"""Unit tests for the osw.service provenance ledger."""
+
+from osw.service.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_service_ops.py b/tests/test_service_ops.py
new file mode 100644
index 0000000..255f1a2
--- /dev/null
+++ b/tests/test_service_ops.py
@@ -0,0 +1,96 @@
+"""Unit tests preserving the MCP-wrapper-level assertions from the old
+``osw.mcp.tools`` test suite (``test_mcp_tools.py``, now removed).
+
+Every operation body assertion from that file already lives in
+``tests/test_service_ops_.py`` (called directly, the way this module's
+sibling ``test_service_ops_files.py`` does), and the generic ``bind()`` /
+error-payload mechanics live in ``tests/test_service_registry.py`` and
+``tests/test_service_errors.py``. What is kept here is the handful of
+assertions that only made sense through the ``bind()`` wrapper -- e.g. an
+``OpError`` becoming a structured dict rather than raising -- exercised
+against the real, registered operations (not a synthetic ``fn``), so nothing
+here duplicates that coverage.
+"""
+
+from unittest.mock import MagicMock
+
+import osw.service.ops # noqa: F401 (registers the operations)
+from osw.service import registry
+from osw.service.config import Settings
+from osw.service.context import Context, Policy
+
+
+def _settings() -> Settings:
+ return Settings(domain="wiki.example.org", username="u", password="p")
+
+
+def _osw_with_page(exists=True):
+ page = MagicMock()
+ page.exists = exists
+ osw = MagicMock()
+ osw.site.get_page.return_value.pages = [page]
+ return osw, page
+
+
+def _bound(name: str, ctx: Context):
+ return registry.bind(registry.REGISTRY[name], ctx)
+
+
+# -- delete_entity: bind() turns its guard/hybrid errors into dicts ---------
+def test_delete_untracked_is_blocked_as_dict():
+ osw, page = _osw_with_page()
+ ledger = MagicMock()
+ ledger.is_tracked.return_value = False
+ ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=osw, ledger=ledger)
+
+ result = _bound("delete_entity", ctx)(title="Item:OSWx")
+
+ assert result["type"] == "ExternalDeleteBlocked"
+ osw.site.get_page.assert_not_called() # never even fetched the page
+ page.delete.assert_not_called()
+
+
+def test_delete_nonexistent_page_returns_hybrid_dict():
+ """delete_entity's NotFound carries {"title", "deleted": False} extras;
+ bind() must merge them with {"error", "type"} rather than dropping either
+ half of the shape."""
+ osw, page = _osw_with_page(exists=False)
+ ledger = MagicMock()
+ ledger.is_tracked.return_value = True
+ ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=osw, ledger=ledger)
+
+ result = _bound("delete_entity", ctx)(title="Item:OSWz")
+
+ assert result == {
+ "title": "Item:OSWz",
+ "deleted": False,
+ "error": "Page 'Item:OSWz' does not exist.",
+ "type": "NotFound",
+ }
+ page.delete.assert_not_called()
+
+
+def test_delete_tracked_is_allowed_as_dict():
+ osw, page = _osw_with_page()
+ ledger = MagicMock()
+ ledger.is_tracked.return_value = True
+ ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=osw, ledger=ledger)
+
+ result = _bound("delete_entity", ctx)(title="Item:OSWx")
+
+ assert result == {"title": "Item:OSWx", "deleted": True}
+ page.delete.assert_called_once()
+
+
+# -- real registry write flags, no mcp SDK required --------------------------
+def test_read_only_mcp_surface_omits_entity_writes():
+ """A read-only server must not register create_or_update_entity/delete_entity,
+ but must still register the reader; checked against the real registry
+ (not a synthetic op) so a mis-flagged ``writes=`` on a real operation
+ would be caught here too."""
+ names = {
+ op.name for op in registry.iter_operations(surface="mcp", include_writes=False)
+ }
+ assert "get_entity" in names
+ assert "create_or_update_entity" not in names
+ assert "delete_entity" not in names
diff --git a/tests/test_service_ops_entities.py b/tests/test_service_ops_entities.py
new file mode 100644
index 0000000..f099f4b
--- /dev/null
+++ b/tests/test_service_ops_entities.py
@@ -0,0 +1,237 @@
+"""Unit tests for osw.service.ops.entities (Operation.fn called directly).
+
+Importing ``osw.service.ops.entities`` registers its operations in
+``osw.service.registry.REGISTRY`` at import time, so this module must not
+clear the registry the way ``test_service_registry.py`` does.
+"""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from osw.service import errors, registry
+from osw.service.config import Settings
+from osw.service.context import Context, Policy
+from osw.service.ledger import LedgerRecord
+from osw.service.ops import entities
+
+
+def _settings() -> Settings:
+ return Settings(domain="wiki.example.org", username="u", password="p")
+
+
+def _osw_with_page(exists=True):
+ page = MagicMock()
+ page.exists = exists
+ osw = MagicMock()
+ osw.site.get_page.return_value.pages = [page]
+ return osw, page
+
+
+# -- get_entity --------------------------------------------------------------
+def test_get_entity_missing_page_returns_not_exists():
+ osw, _ = _osw_with_page(exists=False)
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = entities.get_entity(ctx, title="Item:OSW1")
+
+ assert result == {"title": "Item:OSW1", "exists": False, "jsondata": None}
+
+
+def test_get_entity_reads_jsondata_slot():
+ osw, page = _osw_with_page()
+ page.get_slot_content.return_value = {"label": [{"text": "X"}]}
+ page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1"
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = entities.get_entity(ctx, title="Item:OSW1")
+
+ assert result["exists"] is True
+ assert result["jsondata"] == {"label": [{"text": "X"}]}
+ page.get_slot_content.assert_called_with("jsondata")
+
+
+# -- export_entity_jsonld -----------------------------------------------------
+def test_export_entity_jsonld_returns_jsonld():
+ osw = MagicMock()
+ osw.load_entity.return_value = MagicMock(entities=[MagicMock()])
+ osw.export_jsonld.return_value = MagicMock(
+ documents=[{"@id": "Item:OSW1"}], graph=None
+ )
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = entities.export_entity_jsonld(ctx, title="Item:OSW1")
+
+ assert result == {"jsonld": {"@id": "Item:OSW1"}}
+
+
+def test_export_entity_jsonld_not_found_raises():
+ osw = MagicMock()
+ osw.load_entity.return_value = MagicMock(entities=[])
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ with pytest.raises(errors.NotFound):
+ entities.export_entity_jsonld(ctx, title="Item:OSW404")
+
+
+# -- create_or_update_entity ---------------------------------------------------
+def test_create_or_update_entity_uses_active_domain(monkeypatch):
+ osw = MagicMock()
+ osw.fetch_schema.return_value = MagicMock(error_messages=[])
+ osw.store_entity.return_value = MagicMock(
+ pages={"Item:OSW1": MagicMock()}, change_id="c1"
+ )
+ monkeypatch.setattr(
+ entities, "_resolve_category_class", lambda category: entities.model_entity.Item
+ )
+ monkeypatch.setattr(
+ entities.config, "get_active_domain", lambda: "wiki-b.example.org"
+ )
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = entities.create_or_update_entity(
+ ctx, category="Category:Item", jsondata={"label": [{"text": "Test"}]}
+ )
+
+ assert result["titles"] == ["Item:OSW1"]
+ assert result["change_id"] == "c1"
+ assert result["urls"] == ["https://wiki-b.example.org/wiki/Item:OSW1"]
+
+
+def test_create_or_update_entity_schema_error_raises():
+ osw = MagicMock()
+ osw.fetch_schema.return_value = MagicMock(error_messages=["bad schema"])
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ with pytest.raises(errors.SchemaError):
+ entities.create_or_update_entity(ctx, category="Category:Item", jsondata={})
+
+
+def test_create_or_update_entity_class_not_found_raises(monkeypatch):
+ osw = MagicMock()
+ osw.fetch_schema.return_value = MagicMock(error_messages=[])
+ monkeypatch.setattr(entities, "_resolve_category_class", lambda category: None)
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ with pytest.raises(errors.ClassNotFound):
+ entities.create_or_update_entity(ctx, category="Category:Bogus", jsondata={})
+
+
+def test_create_or_update_entity_validation_error_raises(monkeypatch):
+ osw = MagicMock()
+ osw.fetch_schema.return_value = MagicMock(error_messages=[])
+
+ class _Boom:
+ def __init__(self, **kwargs):
+ raise ValueError("nope")
+
+ monkeypatch.setattr(entities, "_resolve_category_class", lambda category: _Boom)
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ with pytest.raises(errors.ValidationError):
+ entities.create_or_update_entity(ctx, category="Category:Item", jsondata={})
+
+
+# -- records= (ledger hook) ----------------------------------------------------
+def test_create_or_update_entity_records_matches_old_inline_ledger_call():
+ op = registry.REGISTRY["create_or_update_entity"]
+
+ result = {
+ "titles": ["Item:OSW1", "Item:OSW2"],
+ "change_id": "c1",
+ "urls": [
+ "https://wiki.example.org/wiki/Item:OSW1",
+ "https://wiki.example.org/wiki/Item:OSW2",
+ ],
+ }
+
+ assert op.records(result) == [
+ LedgerRecord(
+ title="Item:OSW1", op="create_or_update", change_id="c1", slots=["jsondata"]
+ ),
+ LedgerRecord(
+ title="Item:OSW2", op="create_or_update", change_id="c1", slots=["jsondata"]
+ ),
+ ]
+
+
+def test_create_or_update_entity_records_empty_when_no_titles():
+ op = registry.REGISTRY["create_or_update_entity"]
+
+ assert op.records({"titles": [], "change_id": "c1", "urls": []}) == []
+
+
+def test_create_or_update_entity_schema_error_does_not_reach_bind_records():
+ op = registry.REGISTRY["create_or_update_entity"]
+ osw = MagicMock()
+ osw.fetch_schema.return_value = MagicMock(error_messages=["boom"])
+ fake_ledger = MagicMock()
+ ctx = Context(
+ _settings(), Policy(errors_as_dicts=True), osw=osw, ledger=fake_ledger
+ )
+ bound = registry.bind(op, ctx)
+
+ result = bound(category="Category:Item", jsondata={"label": [{"text": "Test"}]})
+
+ assert result["type"] == "SchemaError"
+ fake_ledger.record.assert_not_called()
+
+
+# -- delete_entity --------------------------------------------------------
+def test_delete_untracked_is_blocked():
+ osw, page = _osw_with_page()
+ ledger = MagicMock()
+ ledger.is_tracked.return_value = False
+ ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger)
+
+ with pytest.raises(errors.ExternalDeleteBlocked) as exc_info:
+ entities.delete_entity(ctx, title="Item:OSWx")
+
+ assert exc_info.value.payload()["title"] == "Item:OSWx"
+ osw.site.get_page.assert_not_called() # never even fetched the page
+ page.delete.assert_not_called()
+
+
+def test_delete_tracked_is_allowed():
+ osw, page = _osw_with_page()
+ ledger = MagicMock()
+ ledger.is_tracked.return_value = True
+ ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger)
+
+ result = entities.delete_entity(ctx, title="Item:OSWx")
+
+ assert result == {"title": "Item:OSWx", "deleted": True}
+ page.delete.assert_called_once()
+ ledger.mark_deleted.assert_called_once_with("Item:OSWx")
+
+
+def test_delete_external_with_confirm():
+ osw, page = _osw_with_page()
+ ledger = MagicMock()
+ ledger.is_tracked.return_value = False
+ ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger)
+
+ result = entities.delete_entity(
+ ctx, title="Item:OSWy", confirm_external_delete=True
+ )
+
+ assert result == {"title": "Item:OSWy", "deleted": True}
+ page.delete.assert_called_once()
+
+
+def test_delete_nonexistent_page_raises():
+ osw, page = _osw_with_page(exists=False)
+ ledger = MagicMock()
+ ledger.is_tracked.return_value = True
+ ctx = Context(_settings(), Policy(), osw=osw, ledger=ledger)
+
+ with pytest.raises(errors.NotFound) as exc_info:
+ entities.delete_entity(ctx, title="Item:OSWz")
+
+ assert exc_info.value.payload() == {
+ "title": "Item:OSWz",
+ "deleted": False,
+ "error": "Page 'Item:OSWz' does not exist.",
+ "type": "NotFound",
+ }
+ page.delete.assert_not_called()
diff --git a/tests/test_service_ops_files.py b/tests/test_service_ops_files.py
new file mode 100644
index 0000000..efe7d01
--- /dev/null
+++ b/tests/test_service_ops_files.py
@@ -0,0 +1,219 @@
+"""Unit tests for osw.service.ops.files (Operation.fn called directly).
+
+Runs in the plain dev env (no mcp extra, no network): ``WikiFileController``
+is replaced with a fake factory that records its constructor arguments, so
+every test can inspect the title/namespace a real controller would have
+derived without touching a wiki.
+"""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from osw.core import OverwriteOptions
+from osw.service import errors
+from osw.service.config import Settings
+from osw.service.context import Context, Policy
+from osw.service.ledger import LedgerRecord
+from osw.service.ops import files
+from osw.service.registry import REGISTRY
+
+
+class _WfFactory:
+ """Stands in for ``WikiFileController``, recording every instance made.
+
+ ``set_stream`` configures the ``.get()`` return value of instances made
+ *after* the call, mirroring how a real controller's stream only exists
+ once ``get()`` is invoked on it.
+ """
+
+ def __init__(self):
+ self.created: list = []
+ self._stream = None
+
+ def set_stream(self, stream) -> None:
+ self._stream = stream
+
+ def __call__(self, **kwargs):
+ wf = MagicMock()
+ wf.namespace = kwargs.get("namespace") or "File"
+ wf.title = kwargs.get("title")
+ wf.url = f"https://wiki.example.org/wiki/{wf.namespace}:{wf.title}"
+ if self._stream is not None:
+ wf.get.return_value = self._stream
+ self.created.append(wf)
+ return wf
+
+
+@pytest.fixture
+def wf_factory(monkeypatch) -> _WfFactory:
+ """Replace ``files.WikiFileController`` with a fake, recording instances."""
+ factory = _WfFactory()
+ monkeypatch.setattr(files, "WikiFileController", MagicMock(side_effect=factory))
+ return factory
+
+
+def _ctx() -> Context:
+ settings = Settings(domain="wiki.example.org", username="u", password="p")
+ return Context(settings, Policy(), osw=MagicMock(), ledger=MagicMock())
+
+
+def _set_page_exists(ctx: Context, exists: bool):
+ page = MagicMock()
+ page.exists = exists
+ ctx.osw.site.get_page.return_value.pages = [page]
+ return page
+
+
+# -- get_file_info --------------------------------------------------------------
+def test_get_file_info_success(wf_factory):
+ ctx = _ctx()
+ _set_page_exists(ctx, True)
+ stream = MagicMock()
+ stream.headers = {"Content-Length": "1234", "Content-Type": "image/png"}
+ wf_factory.set_stream(stream)
+
+ result = files.get_file_info(ctx, "File:OSWabc123.png")
+
+ wf = wf_factory.created[-1]
+ assert wf.title == "OSWabc123.png"
+ assert result == {
+ "title": "File:OSWabc123.png",
+ "exists": True,
+ "url": wf.url,
+ "size": 1234,
+ "media_type": "image/png",
+ }
+ stream.close.assert_called_once()
+
+
+def test_get_file_info_missing(wf_factory):
+ ctx = _ctx()
+ _set_page_exists(ctx, False)
+
+ result = files.get_file_info(ctx, "File:doesnotexist.png")
+
+ assert result == {
+ "title": "File:doesnotexist.png",
+ "exists": False,
+ "url": None,
+ "size": None,
+ "media_type": None,
+ }
+ assert wf_factory.created == [] # no controller built for a missing file
+
+
+# -- read_file_text ---------------------------------------------------------------
+def test_read_file_text_success(wf_factory):
+ ctx = _ctx()
+ _set_page_exists(ctx, True)
+ stream = MagicMock()
+ stream.read.return_value = b"hello world"
+ wf_factory.set_stream(stream)
+
+ result = files.read_file_text(ctx, "File:OSWabc.txt")
+
+ assert result == {
+ "title": "File:OSWabc.txt",
+ "content": "hello world",
+ "encoding": "utf-8",
+ "truncated": False,
+ }
+ stream.read.assert_called_once_with(ctx.settings.max_chars + 1)
+ stream.close.assert_called_once()
+
+
+def test_read_file_text_truncates(wf_factory):
+ ctx = _ctx()
+ _set_page_exists(ctx, True)
+ stream = MagicMock()
+ stream.read.return_value = b"x" * 6 # cap + 1 bytes, cap == limit == 5
+ wf_factory.set_stream(stream)
+
+ result = files.read_file_text(ctx, "File:OSWabc.txt", limit=5)
+
+ assert result["truncated"] is True
+ assert result["content"] == "x" * 5
+ stream.read.assert_called_once_with(6)
+
+
+def test_read_file_text_truncation_may_split_a_multibyte_character(wf_factory):
+ """A valid text file cut mid-character must not be reported as binary.
+
+ ``limit`` counts bytes, so truncating can land inside a multi-byte
+ character. The incomplete trailing sequence is dropped; raising
+ BinaryContent here would tell the user to download a file that reads
+ perfectly well.
+ """
+ ctx = _ctx()
+ _set_page_exists(ctx, True)
+ stream = MagicMock()
+ # 'ä' is two bytes starting at offset 9, so a cap of 10 splits it.
+ stream.read.return_value = ("a" * 9 + "ä" + "b" * 50).encode("utf-8")[:11]
+ wf_factory.set_stream(stream)
+
+ result = files.read_file_text(ctx, "File:OSWabc.txt", limit=10)
+
+ assert result["truncated"] is True
+ assert result["content"] == "a" * 9
+
+
+def test_read_file_text_missing_raises_not_found(wf_factory):
+ ctx = _ctx()
+ _set_page_exists(ctx, False)
+
+ with pytest.raises(errors.NotFound):
+ files.read_file_text(ctx, "File:doesnotexist.txt")
+ assert wf_factory.created == []
+
+
+def test_read_file_text_binary_raises_binary_content(wf_factory):
+ ctx = _ctx()
+ _set_page_exists(ctx, True)
+ stream = MagicMock()
+ stream.read.return_value = b"\xff\xfe\x00\x01"
+ wf_factory.set_stream(stream)
+
+ with pytest.raises(errors.BinaryContent):
+ files.read_file_text(ctx, "File:OSWabc.bin")
+
+
+# -- write_file_text ----------------------------------------------------------
+def test_write_file_text_success(wf_factory):
+ ctx = _ctx()
+
+ result = files.write_file_text(ctx, "File:OSWabc.txt", "hello")
+
+ wf = wf_factory.created[-1]
+ assert wf.title == "OSWabc.txt"
+ wf.put.assert_called_once()
+ (stream_arg,), put_kwargs = wf.put.call_args
+ assert stream_arg.read() == b"hello"
+ assert stream_arg.name == "OSWabc.txt"
+ assert put_kwargs == {"overwrite": OverwriteOptions.true}
+
+ assert result == {"title": "File:OSWabc.txt", "url": wf.url}
+
+
+def test_write_file_text_custom_name_and_no_overwrite(wf_factory):
+ ctx = _ctx()
+
+ files.write_file_text(
+ ctx, "File:OSWabc.txt", "hello", name="renamed.txt", overwrite=False
+ )
+
+ wf = wf_factory.created[-1]
+ (stream_arg,), put_kwargs = wf.put.call_args
+ assert stream_arg.name == "renamed.txt"
+ assert put_kwargs == {"overwrite": OverwriteOptions.false}
+
+
+def test_write_file_text_records_ledger_entry():
+ op = REGISTRY["write_file_text"]
+ result = {"title": "File:OSWabc.txt", "url": "https://example.org/x"}
+
+ records = op.records(result)
+
+ assert records == [
+ LedgerRecord(title="File:OSWabc.txt", op="create", slots=["jsondata"])
+ ]
diff --git a/tests/test_service_ops_schema.py b/tests/test_service_ops_schema.py
new file mode 100644
index 0000000..fbe7d83
--- /dev/null
+++ b/tests/test_service_ops_schema.py
@@ -0,0 +1,51 @@
+"""Unit tests for osw.service.ops.schema (Operation.fn called directly).
+
+Importing ``osw.service.ops.schema`` registers its operations in
+``osw.service.registry.REGISTRY`` at import time, so this module must not
+clear the registry the way ``test_service_registry.py`` does.
+"""
+
+from unittest.mock import MagicMock
+
+from osw.service.config import Settings
+from osw.service.context import Context, Policy
+from osw.service.ops import schema
+
+
+def _settings() -> Settings:
+ return Settings(domain="wiki.example.org", username="u", password="p")
+
+
+def test_get_category_schema_returns_schema_when_page_exists():
+ page = MagicMock()
+ page.exists = True
+ page.get_slot_content.return_value = {"type": "object"}
+ osw = MagicMock()
+ osw.site.get_page.return_value.pages = [page]
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = schema.get_category_schema(ctx, category="Category:Item")
+
+ assert result == {
+ "category": "Category:Item",
+ "exists": True,
+ "schema": {"type": "object"},
+ "truncated": False,
+ }
+ page.get_slot_content.assert_called_with("jsonschema")
+
+
+def test_get_category_schema_returns_not_exists_for_missing_page():
+ page = MagicMock()
+ page.exists = False
+ osw = MagicMock()
+ osw.site.get_page.return_value.pages = [page]
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = schema.get_category_schema(ctx, category="Category:Missing")
+
+ assert result == {
+ "category": "Category:Missing",
+ "exists": False,
+ "schema": None,
+ }
diff --git a/tests/test_service_ops_search.py b/tests/test_service_ops_search.py
new file mode 100644
index 0000000..e69c690
--- /dev/null
+++ b/tests/test_service_ops_search.py
@@ -0,0 +1,63 @@
+"""Unit tests for osw.service.ops.search (Operation.fn called directly).
+
+Importing ``osw.service.ops.search`` registers its operations in
+``osw.service.registry.REGISTRY`` at import time, so this module must not
+clear the registry the way ``test_service_registry.py`` does.
+"""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from osw.service import errors
+from osw.service.config import Settings
+from osw.service.context import Context, Policy
+from osw.service.ops import search
+
+
+def _settings() -> Settings:
+ return Settings(domain="wiki.example.org", username="u", password="p")
+
+
+def test_search_entities_calls_semantic_search():
+ osw = MagicMock()
+ osw.site.semantic_search.return_value = ["Item:OSW1", "Item:OSW2"]
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = search.search_entities(ctx, ask_query="[[Category:Item]]")
+
+ assert result["titles"] == ["Item:OSW1", "Item:OSW2"]
+ assert result["count"] == 2
+ osw.site.semantic_search.assert_called_once()
+
+
+def test_full_text_search_calls_prefix_search():
+ osw = MagicMock()
+ osw.site.prefix_search.return_value = ["Item:OSW1"]
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = search.full_text_search(ctx, text="OSW")
+
+ assert result["titles"] == ["Item:OSW1"]
+ assert result["count"] == 1
+ assert result["truncated"] is False
+ osw.site.prefix_search.assert_called_once()
+
+
+def test_list_instances_of_category_calls_query_instances():
+ osw = MagicMock()
+ osw.query_instances.return_value = ["Item:OSW1", "Item:OSW2"]
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = search.list_instances_of_category(ctx, category="Category:Item")
+
+ assert result["titles"] == ["Item:OSW1", "Item:OSW2"]
+ assert result["count"] == 2
+ osw.query_instances.assert_called_once()
+
+
+def test_sparql_query_without_endpoint_raises_not_configured():
+ ctx = Context(_settings(), Policy(), osw=MagicMock())
+
+ with pytest.raises(errors.NotConfigured):
+ search.sparql_query(ctx, query="SELECT * WHERE {?s ?p ?o}")
diff --git a/tests/test_service_ops_slots.py b/tests/test_service_ops_slots.py
new file mode 100644
index 0000000..5116f27
--- /dev/null
+++ b/tests/test_service_ops_slots.py
@@ -0,0 +1,229 @@
+"""Unit tests for osw.service.ops.slots (Operation.fn called directly).
+
+Importing ``osw.service.ops.slots`` registers its operations in
+``osw.service.registry.REGISTRY`` at import time, so this module must not
+clear the registry the way ``test_service_registry.py`` does.
+"""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from osw.service import errors, registry
+from osw.service.config import Settings
+from osw.service.context import Context, Policy
+from osw.service.ledger import LedgerRecord
+from osw.service.ops import slots
+
+
+def _settings() -> Settings:
+ return Settings(domain="wiki.example.org", username="u", password="p")
+
+
+def _osw_with_page(exists=True, present_slots=()):
+ page = MagicMock()
+ page.exists = exists
+ page._slots = list(present_slots)
+ osw = MagicMock()
+ osw.site.get_page.return_value.pages = [page]
+ return osw, page
+
+
+# -- list_page_slots --------------------------------------------------------
+def test_list_page_slots_missing_page():
+ osw, _ = _osw_with_page(exists=False)
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = slots.list_page_slots(ctx, title="Item:OSW1")
+
+ assert result == {
+ "title": "Item:OSW1",
+ "exists": False,
+ "slots": [],
+ "valid_slot_keys": list(slots.SLOTS),
+ }
+
+
+def test_list_page_slots_existing_page():
+ osw, page = _osw_with_page(present_slots=["main", "jsondata"])
+ page.get_slot_content.side_effect = lambda key: "" if key == "main" else {"a": 1}
+ page.get_slot_content_model.side_effect = lambda key: (
+ "wikitext" if key == "main" else "json"
+ )
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = slots.list_page_slots(ctx, title="Item:OSW1")
+
+ assert result["title"] == "Item:OSW1"
+ assert result["exists"] is True
+ assert result["slots"] == [
+ {"key": "main", "content_model": "wikitext", "empty": True},
+ {"key": "jsondata", "content_model": "json", "empty": False},
+ ]
+ assert result["valid_slot_keys"] == list(slots.SLOTS)
+
+
+# -- get_slot ----------------------------------------------------------------
+def test_get_slot_rejects_unknown_slot():
+ ctx = Context(_settings(), Policy(), osw=MagicMock())
+
+ with pytest.raises(errors.InvalidSlot):
+ slots.get_slot(ctx, title="Item:OSW1", slot="bogus")
+
+
+def test_get_slot_missing_page_returns_not_exists():
+ osw, _ = _osw_with_page(exists=False)
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = slots.get_slot(ctx, title="Item:OSW1", slot="jsondata")
+
+ assert result == {
+ "title": "Item:OSW1",
+ "slot": "jsondata",
+ "exists": False,
+ "content": None,
+ }
+
+
+def test_get_slot_missing_slot_returns_not_exists():
+ osw, _page = _osw_with_page(present_slots=["main"])
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = slots.get_slot(ctx, title="Item:OSW1", slot="jsondata")
+
+ assert result == {
+ "title": "Item:OSW1",
+ "slot": "jsondata",
+ "exists": False,
+ "content": None,
+ }
+
+
+def test_get_slot_existing_slot():
+ osw, page = _osw_with_page(present_slots=["jsondata"])
+ page.get_slot_content.return_value = {"label": [{"text": "X"}]}
+ page.get_slot_content_model.return_value = "json"
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = slots.get_slot(ctx, title="Item:OSW1", slot="jsondata")
+
+ assert result["exists"] is True
+ assert result["content_model"] == "json"
+ assert result["content"] == {"label": [{"text": "X"}]}
+ assert result["truncated"] is False
+ page.get_slot_content.assert_called_with("jsondata")
+
+
+# -- set_slot ------------------------------------------------------------
+def test_set_slot_rejects_unknown_slot():
+ ctx = Context(_settings(), Policy(), osw=MagicMock())
+
+ with pytest.raises(errors.InvalidSlot):
+ slots.set_slot(ctx, title="Item:OSW1", slot="bogus", content="x")
+
+
+def test_set_slot_rejects_wrong_content_type_json():
+ ctx = Context(_settings(), Policy(), osw=MagicMock())
+
+ with pytest.raises(errors.InvalidContent):
+ slots.set_slot(ctx, title="Item:OSW1", slot="jsondata", content="not-json")
+
+
+def test_set_slot_rejects_wrong_content_type_wikitext():
+ ctx = Context(_settings(), Policy(), osw=MagicMock())
+
+ with pytest.raises(errors.InvalidContent):
+ slots.set_slot(ctx, title="Item:OSW1", slot="main", content={"not": "a string"})
+
+
+def test_set_slot_missing_slot_without_create_raises_slot_missing():
+ osw, page = _osw_with_page(present_slots=[])
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ with pytest.raises(errors.SlotMissing):
+ slots.set_slot(
+ ctx,
+ title="Item:OSW1",
+ slot="jsondata",
+ content={"a": 1},
+ create_if_missing=False,
+ )
+ page.create_slot.assert_not_called()
+ page.set_slot_content.assert_not_called()
+
+
+def test_set_slot_creates_missing_slot_when_allowed():
+ osw, page = _osw_with_page(present_slots=[])
+ page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1"
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = slots.set_slot(ctx, title="Item:OSW1", slot="jsondata", content={"a": 1})
+
+ page.create_slot.assert_called_once_with("jsondata", "json")
+ page.set_slot_content.assert_called_once_with("jsondata", {"a": 1})
+ page.edit.assert_called_once()
+ assert result == {
+ "title": "Item:OSW1",
+ "slot": "jsondata",
+ "changed": True,
+ "url": "https://wiki.example.org/wiki/Item:OSW1",
+ }
+
+
+def test_set_slot_existing_slot_skips_create():
+ osw, page = _osw_with_page(present_slots=["jsondata"])
+ page.get_url.return_value = "https://wiki.example.org/wiki/Item:OSW1"
+ ctx = Context(_settings(), Policy(), osw=osw)
+
+ result = slots.set_slot(ctx, title="Item:OSW1", slot="jsondata", content={"a": 1})
+
+ page.create_slot.assert_not_called()
+ page.set_slot_content.assert_called_once_with("jsondata", {"a": 1})
+ assert result["changed"] is True
+
+
+# -- records= (ledger hook) -------------------------------------------------
+def test_set_slot_records_matches_old_inline_ledger_call():
+ op = registry.REGISTRY["set_slot"]
+
+ result = {
+ "title": "Item:OSW1",
+ "slot": "jsondata",
+ "changed": True,
+ "url": "https://wiki.example.org/wiki/Item:OSW1",
+ }
+
+ assert op.records(result) == [
+ LedgerRecord(title="Item:OSW1", op="update", slots=["jsondata"])
+ ]
+
+
+def test_set_slot_records_empty_when_not_changed():
+ op = registry.REGISTRY["set_slot"]
+
+ assert (
+ op.records({"title": "Item:OSW1", "slot": "jsondata", "changed": False}) == []
+ )
+
+
+def test_set_slot_records_empty_when_changed_key_absent():
+ op = registry.REGISTRY["set_slot"]
+
+ assert op.records({"title": "Item:OSW1", "slot": "jsondata"}) == []
+
+
+def test_set_slot_error_paths_do_not_reach_bind_records():
+ """The invalid-input/slot-missing paths raise, so bind() never calls
+ op.records for them -- matching the old code, which returned before
+ reaching ``ledger.record``."""
+ op = registry.REGISTRY["set_slot"]
+ fake_ledger = MagicMock()
+ ctx = Context(
+ _settings(), Policy(errors_as_dicts=True), osw=MagicMock(), ledger=fake_ledger
+ )
+ bound = registry.bind(op, ctx)
+
+ result = bound(title="Item:OSW1", slot="bogus", content="x")
+
+ assert result["type"] == "InvalidSlot"
+ fake_ledger.record.assert_not_called()
diff --git a/tests/test_service_ops_status.py b/tests/test_service_ops_status.py
new file mode 100644
index 0000000..40380e4
--- /dev/null
+++ b/tests/test_service_ops_status.py
@@ -0,0 +1,85 @@
+"""Unit tests for osw.service.ops.status (Operation.fn called directly).
+
+Importing ``osw.service.ops.status`` registers its operation in
+``osw.service.registry.REGISTRY`` at import time, so this module must not
+clear the registry the way ``test_service_registry.py`` does.
+"""
+
+from unittest.mock import MagicMock
+
+from osw.service import config
+from osw.service.context import Context, Policy
+from osw.service.ops import status
+
+_ALL_VARS = [
+ "OSW_DOMAIN",
+ "OSL_DOMAIN",
+ "OSW_USERNAME",
+ "OSL_USERNAME",
+ "OSW_PASSWORD",
+ "OSL_PASSWORD",
+ "OSW_CRED_FILEPATH",
+ "OSW_MCP_CRED_FILEPATH",
+ "OSL_CRED_FILEPATH",
+]
+
+
+def _clean_env(monkeypatch):
+ for var in _ALL_VARS:
+ monkeypatch.delenv(var, raising=False)
+
+
+def test_status_reports_active_instance_and_connects(monkeypatch):
+ _clean_env(monkeypatch)
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "u")
+ monkeypatch.setenv("OSW_PASSWORD", "p")
+ config.reset()
+ ledger = MagicMock()
+ ledger.path = "/tmp/ledger.json"
+ ledger.entry_count.return_value = 3
+ ctx = Context(config.get_settings(), Policy(), osw=MagicMock(), ledger=ledger)
+
+ result = status.status(ctx)
+
+ assert result["connected"] is True
+ assert "password" not in result
+ assert result["active_iri"] == "wiki.example.org"
+ assert result["ledger_entry_count"] == 3
+ config.reset()
+
+
+def test_status_no_active_instance_reports_message(monkeypatch):
+ _clean_env(monkeypatch)
+ config.reset()
+ monkeypatch.setattr(config, "get_settings", lambda: config.Settings(domain=None))
+ ctx = Context(config.get_settings(), Policy(), osw=MagicMock(), ledger=MagicMock())
+
+ result = status.status(ctx)
+
+ assert result["connected"] is False
+ assert result["active_iri"] is None
+ assert "message" in result
+ config.reset()
+
+
+def test_status_connection_failure_reports_connection_error(monkeypatch):
+ _clean_env(monkeypatch)
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "u")
+ monkeypatch.setenv("OSW_PASSWORD", "p")
+ config.reset()
+
+ from osw.service import context as context_module
+
+ def _raise(*args, **kwargs):
+ raise RuntimeError("boom")
+
+ monkeypatch.setattr(context_module, "OswExpress", _raise)
+ ctx = Context(config.get_settings(), Policy(), ledger=MagicMock())
+
+ result = status.status(ctx)
+
+ assert result["connected"] is False
+ assert "boom" in result["connection_error"]
+ config.reset()
diff --git a/tests/test_service_registry.py b/tests/test_service_registry.py
new file mode 100644
index 0000000..06ad68d
--- /dev/null
+++ b/tests/test_service_registry.py
@@ -0,0 +1,363 @@
+"""Unit tests for osw.service.registry (Operation validation, bind()).
+
+Registers test operations against a snapshot/restore of the global
+``REGISTRY`` so this file cannot pollute other test modules. A fake
+``osw``/``ledger`` is injected into ``Context`` so nothing here touches the
+network.
+"""
+
+import inspect
+import typing
+from unittest.mock import MagicMock
+
+import pytest
+
+from osw.service import errors, registry
+from osw.service.config import Settings
+from osw.service.context import Context, Policy
+from osw.service.ledger import LedgerRecord
+
+
+@pytest.fixture(autouse=True)
+def _clean_registry():
+ original = dict(registry.REGISTRY)
+ registry.REGISTRY.clear()
+ yield
+ registry.REGISTRY.clear()
+ registry.REGISTRY.update(original)
+
+
+def _settings() -> Settings:
+ return Settings(domain="wiki.example.org", username="u", password="p")
+
+
+def _underlying_message(exc_info) -> str:
+ """Pydantic wraps our ``raise ValueError`` in its own message; unwrap it."""
+ return str(exc_info.value.errors()[0]["ctx"]["error"])
+
+
+def _valid_fn(ctx, title: str) -> dict:
+ """Do a thing."""
+ return {"title": title}
+
+
+# -- Operation.command ------------------------------------------------------
+def test_command_defaults_to_name():
+ op = registry.Operation(name="foo", fn=_valid_fn)
+ assert op.command == "foo"
+
+
+def test_command_uses_cli_name_override():
+ op = registry.Operation(name="foo", fn=_valid_fn, cli_name="bar")
+ assert op.command == "bar"
+
+
+# -- validator ----------------------------------------------------------
+def test_validator_rejects_missing_ctx_param():
+ def fn():
+ """Doc."""
+ return {}
+
+ with pytest.raises(ValueError) as exc_info:
+ registry.Operation(name="no_params", fn=fn)
+ assert _underlying_message(exc_info).startswith("no_params:")
+
+
+def test_validator_rejects_first_param_not_named_ctx():
+ def fn(x):
+ """Doc."""
+ return {}
+
+ with pytest.raises(ValueError) as exc_info:
+ registry.Operation(name="bad_ctx", fn=fn)
+ assert _underlying_message(exc_info).startswith("bad_ctx:")
+
+
+def test_validator_rejects_records_without_writes():
+ with pytest.raises(ValueError) as exc_info:
+ registry.Operation(name="bad_records", fn=_valid_fn, records=lambda r: [])
+ assert _underlying_message(exc_info).startswith("bad_records:")
+
+
+def test_validator_requires_docstring():
+ def fn(ctx, title: str) -> dict:
+ return {}
+
+ with pytest.raises(ValueError) as exc_info:
+ registry.Operation(name="no_doc", fn=fn)
+ assert _underlying_message(exc_info).startswith("no_doc:")
+
+
+def test_validator_rejects_path_like_param_on_mcp_surface():
+ def fn(ctx, source_path: str) -> dict:
+ """Doc."""
+ return {}
+
+ with pytest.raises(ValueError) as exc_info:
+ registry.Operation(name="bad_path", fn=fn)
+ msg = _underlying_message(exc_info)
+ assert msg.startswith("bad_path:")
+ assert "source_path" in msg
+
+
+def test_validator_allows_path_like_param_on_cli_only_surface():
+ def fn(ctx, source_path: str) -> dict:
+ """Doc."""
+ return {}
+
+ op = registry.Operation(name="cli_only", fn=fn, surfaces=frozenset({"cli"}))
+ assert "source_path" in inspect.signature(op.fn).parameters
+
+
+def test_extra_forbid_rejects_misspelled_kwarg():
+ with pytest.raises(ValueError):
+ registry.Operation(name="typo", fn=_valid_fn, sumary="oops")
+
+
+# -- operation() decorator / REGISTRY ----------------------------------------
+def test_operation_decorator_registers_and_returns_fn_unchanged():
+ @registry.operation()
+ def my_op(ctx, title: str) -> dict:
+ """Do a thing."""
+ return {"title": title}
+
+ assert "my_op" in registry.REGISTRY
+ assert registry.REGISTRY["my_op"].fn is my_op
+ assert my_op(None, title="x") == {"title": "x"}
+
+
+def test_operation_decorator_name_override():
+ @registry.operation(name="custom_name")
+ def my_op(ctx, title: str) -> dict:
+ """Do a thing."""
+ return {}
+
+ assert "custom_name" in registry.REGISTRY
+ assert "my_op" not in registry.REGISTRY
+
+
+def test_operation_decorator_rejects_duplicate_name():
+ @registry.operation()
+ def dup(ctx, title: str) -> dict:
+ """Do a thing."""
+ return {}
+
+ with pytest.raises(ValueError):
+
+ @registry.operation(name="dup")
+ def other(ctx, title: str) -> dict:
+ """Do a thing."""
+ return {}
+
+
+# -- iter_operations ----------------------------------------------------
+def _register(name, **kwargs):
+ def fn(ctx, title: str) -> dict:
+ """Do a thing."""
+ return {"title": title}
+
+ kwargs.setdefault("surfaces", frozenset({"mcp", "cli"}))
+ registry.REGISTRY[name] = registry.Operation(name=name, fn=fn, **kwargs)
+
+
+def test_iter_operations_filters_by_surface():
+ _register("mcp_only", surfaces=frozenset({"mcp"}))
+ _register("cli_only", surfaces=frozenset({"cli"}))
+ names_mcp = {op.name for op in registry.iter_operations(surface="mcp")}
+ names_cli = {op.name for op in registry.iter_operations(surface="cli")}
+ assert "mcp_only" in names_mcp and "mcp_only" not in names_cli
+ assert "cli_only" in names_cli and "cli_only" not in names_mcp
+
+
+def test_iter_operations_filters_writes():
+ _register("reader", writes=False)
+ _register("writer", writes=True)
+ with_writes = {op.name for op in registry.iter_operations(surface="mcp")}
+ without_writes = {
+ op.name for op in registry.iter_operations(surface="mcp", include_writes=False)
+ }
+ assert "writer" in with_writes
+ assert "writer" not in without_writes
+ assert "reader" in without_writes
+
+
+def test_iter_operations_preserves_registration_order():
+ _register("first")
+ _register("second")
+ _register("third")
+ names = [op.name for op in registry.iter_operations(surface="mcp")]
+ assert names.index("first") < names.index("second") < names.index("third")
+
+
+# -- bind(): signature / annotations / doc preservation ----------------------
+def test_bind_signature_excludes_ctx():
+ def fn(ctx, title: str, limit: int = 5) -> dict:
+ """Do a thing."""
+ return {}
+
+ op = registry.Operation(name="op1", fn=fn)
+ ctx = Context(_settings(), osw=object())
+ bound = registry.bind(op, ctx)
+
+ sig = inspect.signature(bound)
+ assert list(sig.parameters) == ["title", "limit"]
+ assert "ctx" not in bound.__annotations__
+ assert bound.__doc__ == fn.__doc__
+ assert bound.__name__ == fn.__name__
+
+
+def test_bind_resolves_string_annotations_against_the_op_module():
+ """An op module using ``from __future__ import annotations`` stores its
+ annotations as strings. ``bound`` lives in registry.py, so a consumer calling
+ get_type_hints() on it would resolve them against the wrong globals; bind()
+ must therefore resolve them eagerly."""
+ ns: dict = {}
+ exec(
+ "from __future__ import annotations\n"
+ "from typing import Optional\n"
+ "class Marker: pass\n"
+ "def fn(ctx, thing: Optional[Marker] = None) -> dict:\n"
+ " '''Do a thing.'''\n"
+ " return {}\n",
+ ns,
+ )
+ fn, marker = ns["fn"], ns["Marker"]
+ assert fn.__annotations__["thing"] == "Optional[Marker]"
+
+ op = registry.Operation(name="op1", fn=fn)
+ bound = registry.bind(op, Context(_settings(), osw=object()))
+
+ expected = typing.Optional[marker]
+ assert bound.__annotations__["thing"] == expected
+ assert inspect.signature(bound).parameters["thing"].annotation == expected
+ # Marker is not in registry.py's globals, so this raised NameError before.
+ assert typing.get_type_hints(bound)["thing"] == expected
+
+
+# -- bind(): error handling --------------------------------------------------
+def test_bind_errors_as_dicts_true_returns_payload():
+ def fn(ctx, title: str) -> dict:
+ """Do a thing."""
+ raise errors.NotFound(f"Page '{title}' does not exist.")
+
+ op = registry.Operation(name="op_err", fn=fn)
+ ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=object())
+ bound = registry.bind(op, ctx)
+
+ result = bound(title="Item:X")
+
+ assert result == {"error": "Page 'Item:X' does not exist.", "type": "NotFound"}
+
+
+def test_bind_errors_as_dicts_false_reraises():
+ def fn(ctx, title: str) -> dict:
+ """Do a thing."""
+ raise errors.NotFound(f"Page '{title}' does not exist.")
+
+ op = registry.Operation(name="op_err2", fn=fn)
+ ctx = Context(_settings(), Policy(errors_as_dicts=False), osw=object())
+ bound = registry.bind(op, ctx)
+
+ with pytest.raises(errors.NotFound):
+ bound(title="Item:X")
+
+
+def test_bind_non_operror_exception_becomes_generic_dict():
+ def fn(ctx, title: str) -> dict:
+ """Do a thing."""
+ raise RuntimeError("boom")
+
+ op = registry.Operation(name="op_err3", fn=fn)
+ ctx = Context(_settings(), Policy(errors_as_dicts=True), osw=object())
+ bound = registry.bind(op, ctx)
+
+ result = bound(title="x")
+
+ assert result == {"error": "boom", "type": "RuntimeError"}
+
+
+def test_bind_calls_require_write_for_writing_ops():
+ def fn(ctx, title: str) -> dict:
+ """Do a thing."""
+ return {"title": title}
+
+ op = registry.Operation(name="writer_op", fn=fn, writes=True)
+ ctx = Context(
+ _settings(),
+ Policy(allow_writes=False, errors_as_dicts=True),
+ osw=object(),
+ )
+ bound = registry.bind(op, ctx)
+
+ result = bound(title="x")
+
+ assert result["type"] == "ReadOnly" # the ReadOnly OpError require_write raises
+
+
+# -- bind(): ledger recording -------------------------------------------
+def test_bind_invokes_ledger_once_per_returned_record_with_full_arguments():
+ def fn(ctx, title: str) -> dict:
+ """Do a thing."""
+ return {"titles": [title, title + "-2"]}
+
+ def _records(result: dict) -> list:
+ return [
+ LedgerRecord(
+ title=result["titles"][0],
+ op="create",
+ change_id="c1",
+ slots=["jsondata"],
+ ),
+ LedgerRecord(title=result["titles"][1], op="update", slots=["main"]),
+ ]
+
+ op = registry.Operation(
+ name="writer_records",
+ fn=fn,
+ writes=True,
+ records=_records,
+ )
+ fake_ledger = MagicMock()
+ ctx = Context(_settings(), osw=object(), ledger=fake_ledger)
+ bound = registry.bind(op, ctx)
+
+ bound(title="Item:A")
+
+ assert fake_ledger.record.call_count == 2
+
+ first = fake_ledger.record.call_args_list[0]
+ assert first.args == ("Item:A",)
+ assert first.kwargs == {
+ "tool": "writer_records",
+ "op": "create",
+ "change_id": "c1",
+ "slots": ["jsondata"],
+ "uuid": None,
+ "namespace": None,
+ }
+
+ second = fake_ledger.record.call_args_list[1]
+ assert second.args == ("Item:A-2",)
+ assert second.kwargs == {
+ "tool": "writer_records",
+ "op": "update",
+ "change_id": None,
+ "slots": ["main"],
+ "uuid": None,
+ "namespace": None,
+ }
+
+
+def test_bind_does_not_invoke_ledger_when_op_does_not_write():
+ def fn(ctx, title: str) -> dict:
+ """Do a thing."""
+ return {"titles": [title]}
+
+ op = registry.Operation(name="reader_op", fn=fn, writes=False)
+ fake_ledger = MagicMock()
+ ctx = Context(_settings(), osw=object(), ledger=fake_ledger)
+ bound = registry.bind(op, ctx)
+
+ bound(title="Item:A")
+
+ fake_ledger.record.assert_not_called()
diff --git a/tests/test_service_serialization.py b/tests/test_service_serialization.py
new file mode 100644
index 0000000..08e3872
--- /dev/null
+++ b/tests/test_service_serialization.py
@@ -0,0 +1,58 @@
+"""Unit tests for osw.service.serialization."""
+
+from pathlib import Path
+
+from osw.service.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/uv.lock b/uv.lock
index 6ce8fa9..8bce090 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" },
@@ -2004,6 +2117,7 @@ dependencies = [
{ name = "requests" },
{ name = "sparqlwrapper" },
{ name = "tqdm" },
+ { name = "typer" },
{ name = "typing-extensions" },
]
@@ -2027,6 +2141,10 @@ db = [
{ name = "psycopg2" },
{ name = "sqlalchemy" },
]
+mcp = [
+ { name = "mcp" },
+ { name = "python-dotenv" },
+]
s3 = [
{ name = "boto3" },
]
@@ -2042,12 +2160,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,19 +2176,27 @@ 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" },
{ name = "pytest-cov" },
{ name = "pytest-mock" },
+ { name = "python-dotenv" },
{ name = "python-semantic-release" },
{ name = "ruff" },
{ name = "sqlalchemy" },
{ name = "ty" },
{ name = "zensical" },
]
+test = [
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-cov" },
+ { name = "pytest-mock" },
+ { name = "python-dotenv" },
+]
[package.metadata]
requires-dist = [
@@ -2084,6 +2211,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,18 +2228,21 @@ 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" },
{ name = "sparqlwrapper" },
{ name = "sqlalchemy", marker = "extra == 'db'" },
{ name = "tqdm" },
+ { name = "typer" },
{ 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,19 +2252,27 @@ dev = [
{ name = "mike", git = "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/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" },
{ name = "pytest-cov" },
{ name = "pytest-mock" },
+ { name = "python-dotenv", specifier = ">=1.0" },
{ name = "python-semantic-release", specifier = ">=10.0.0" },
{ name = "ruff", specifier = ">=0.15.7" },
{ name = "sqlalchemy" },
{ name = "ty", specifier = ">=0.0.24" },
{ name = "zensical", specifier = ">=0.0.46" },
]
+test = [
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-cov" },
+ { name = "pytest-mock" },
+ { name = "python-dotenv", specifier = ">=1.0" },
+]
[[package]]
name = "packaging"
@@ -2171,9 +2310,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 +2324,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 +2422,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 +2439,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 +2451,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 +2465,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 +2699,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 +2774,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 +2792,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 +2852,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 +3048,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 +3071,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 +3347,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 +3561,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 +3598,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 +3701,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"
diff --git a/zensical.toml b/zensical.toml
index 50218f4..671f583 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -14,6 +14,7 @@ nav = [
{ "Home" = "index.md" },
{ "About" = "about.md" },
{ "Get Started" = "get-started.md" },
+ { "CLI and MCP tools" = "cli-and-mcp.md" },
{ "API Reference" = [
{ "Overview" = "api/index.md" },
{ "OSW" = "api/core.md" },