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..3866a67 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ pip install osw
```
Optional extras (`osw[wikitext]`, `osw[DB]`, `osw[S3]`, `osw[dataimport]`,
-`osw[UI]`, `osw[all]`) are described in the
+`osw[UI]`, `osw[mcp]`, `osw[all]`) are described in the
[Get Started guide](https://opensemanticlab.github.io/osw-python/get-started/).
## Quickstart
@@ -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
+`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..4bb68a6
--- /dev/null
+++ b/docs/cli-and-mcp.md
@@ -0,0 +1,339 @@
+# 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 also part of `osw[all]`. The other extras are listed in the
+[Get Started guide](get-started.md#optional-extras).
+
+Both adapters need an instance and credentials. The quickest start is a
+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.
+- **`mcp` is an extra, not a base dependency.** The SDK pulls in a server stack
+ (starlette, uvicorn, sse-starlette) that nothing in the Python API or the CLI
+ needs, so only users who actually run the server pay for it.
+
+## Notes for developers
+
+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..efc4f2a 100644
--- a/docs/get-started.md
+++ b/docs/get-started.md
@@ -30,6 +30,7 @@
| `osw[S3]` | Interact with S3 stores per S3FileController |
| `osw[dataimport]` | Additional tools to import data |
| `osw[UI]` | To use a helper UI to work with entity slots |
+| `osw[mcp]` | [MCP server](cli-and-mcp.md#mcp-server) for agent clients |
| `osw[all]` | All of the above |
Install multiple extras with `pip install osw[opt1,opt2]`.
diff --git a/pyproject.toml b/pyproject.toml
index 3697050..19201ba 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
@@ -81,7 +91,14 @@ workflow = [
"anyio>=4.9,<4.14",
]
tutorial = ["osw[dataimport]"]
-all = ["osw[dataimport,DB,UI,S3,wikitext]"]
+all = ["osw[dataimport,DB,UI,S3,wikitext,mcp]"]
+
+[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"]
@@ -95,8 +112,10 @@ dev = [
"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]",
+ # 3.x in CI, whose server API breaks the prefect-2.20-targeted tests.
+ # mcp is included so the MCP server and its tests run in the same
+ # environment as everything else.
+ "osw[workflow,mcp]",
"geopy",
"deepl",
"sqlalchemy",
@@ -302,6 +321,9 @@ 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, src/osw/service and src/osw/cli are checked: the mcp extra is
+# part of the dev group, so the SDK imports resolve.
exclude = [
"src/osw/model/entity.py",
"examples",
@@ -340,11 +362,14 @@ opensemantic-core = "opensemantic"
opensemantic-base = "opensemantic"
pybars3-wheel = "pybars"
"backports.strenum" = "backports"
-# extras packages not installed in the dev env, mapped explicitly so
-# deptry does not have to guess ("Assuming ..." warnings)
+# extras packages whose import name deptry would otherwise have to guess
+# ("Assuming ..." warnings)
psycopg2 = "psycopg2"
openpyxl = "openpyxl"
pysimplegui = "PySimpleGUI"
+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/controller/file/memory.py b/src/osw/controller/file/memory.py
index 26e5de7..5bd9658 100644
--- a/src/osw/controller/file/memory.py
+++ b/src/osw/controller/file/memory.py
@@ -1,27 +1,26 @@
import shutil
-from io import StringIO
+from io import BytesIO
from typing import IO, Any, Dict, List, Optional
+from pydantic.v1 import Field
+
from osw.controller.file.base import FileController
from osw.core import model
class InMemoryController(FileController, model.LocalFile):
- """File controller for local files"""
+ """File controller for in-memory streams"""
label: Optional[List[model.Label]] = [model.Label(text="Unnamed stream")]
"""the label of the stream, e.g., the name of the file the stream
originates from. Defaults to 'Unnamed stream'."""
- stream: IO
- """the stream to the file"""
+ stream: Any = Field(default_factory=BytesIO)
+ """the stream to the file, any file-like object. Defaults to an empty
+ binary buffer. Byte-oriented, to match the get/put counterparts."""
class Config:
arbitrary_types_allowed = True
- def __init__(self, **kwargs):
- self.stream = StringIO()
- super().__init__(**kwargs)
-
def get(self) -> IO:
return self.stream
diff --git a/src/osw/express.py b/src/osw/express.py
index a865a82..a54aa72 100644
--- a/src/osw/express.py
+++ b/src/osw/express.py
@@ -231,7 +231,7 @@ def download_file(
def upload_file(
self,
- source: Union["LocalFileController", "WikiFileController", str, Path],
+ source: Union["LocalFileController", "WikiFileController", str, Path, IO],
url_or_title: Optional[str] = None,
overwrite: OVERWRITE_CLASS_OPTIONS = OverwriteOptions.true,
delete_after_use: bool = False,
@@ -247,7 +247,7 @@ def upload_file(
----------
source
The source file to upload. Can be a LocalFileController, WikiFileController,
- str or Path.
+ str, Path or an open file-like object.
url_or_title
The URL or full page title of the WikiFile page to upload the file to. Used to
overwrite autogenerated full page title on the target domain. If it is
@@ -635,7 +635,9 @@ def __init__(
data["path"] = Path(source)
data["source"] = Path(source)
data["source_file_controller"] = LocalFileController(path=data.get("path"))
- elif isinstance(source, IO):
+ # duck-typed: typing.IO is not runtime-checkable, isinstance(BytesIO(), IO)
+ # is False, so an explicit isinstance check would never match a stream
+ elif hasattr(source, "read"):
data["source_file_controller"] = InMemoryController(stream=source)
else:
raise ValueError(
@@ -736,7 +738,7 @@ def __init__(
def osw_upload_file(
- source: Union[LocalFileController, WikiFileController, str, Path],
+ source: Union[LocalFileController, WikiFileController, str, Path, IO],
url_or_title: Optional[str] = None,
overwrite: OVERWRITE_CLASS_OPTIONS = OverwriteOptions.true,
delete_after_use: bool = False,
@@ -756,7 +758,7 @@ def osw_upload_file(
----------
source
The source file to upload. Can be a LocalFileController, WikiFileController,
- str or Path.
+ str, Path or an open file-like object.
url_or_title
The URL or full page title of the WikiFile page to upload the file to. Used to
overwrite autogenerated full page title on the target domain. If it is
diff --git a/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..b15f5cc
--- /dev/null
+++ b/src/osw/mcp/server.py
@@ -0,0 +1,162 @@
+"""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
+
+from mcp.server import MCPServer
+from mcp.types import ToolAnnotations
+
+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..71f2766
--- /dev/null
+++ b/src/osw/service/config.py
@@ -0,0 +1,588 @@
+"""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 pathlib import Path
+from typing import Optional
+from urllib.parse import urlparse
+
+import yaml
+from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
+
+from osw.auth import CredentialManager
+
+# 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
+
+
+# Which env variable tuple feeds each Settings field, used to name the offending
+# variable when pydantic rejects a value.
+_ENV_BY_FIELD: dict[str, tuple[str, ...]] = {
+ "domain": ENV_DOMAIN,
+ "username": ENV_USERNAME,
+ "password": ENV_PASSWORD,
+ "cred_filepath": ENV_CRED_FILEPATH,
+ "sparql_endpoint": ENV_SPARQL_ENDPOINT,
+ "read_only": ENV_READ_ONLY,
+ "state_dir": ENV_STATE_DIR,
+ "max_results": ENV_MAX_RESULTS,
+ "max_chars": ENV_MAX_CHARS,
+}
+
+
+def _env_name_for(field_name: str) -> str:
+ """Name the env variable that actually supplied ``field_name``.
+
+ Falls back to the canonical name so the operator always gets something
+ actionable to fix.
+ """
+ names = _ENV_BY_FIELD.get(field_name, ())
+ if not names:
+ return field_name
+ for name in names:
+ if os.getenv(name):
+ return name
+ return names[0]
+
+
+class Settings(BaseModel):
+ """Resolved, validated server settings."""
+
+ model_config = ConfigDict(frozen=True)
+
+ # domain is optional: with a usable credential file, no domain need be
+ # configured via the environment; the active instance is then chosen from
+ # the credential file (auto-selected, or picked with the CLI's --instance).
+ 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 = Field(default=100, gt=0)
+ max_chars: int = Field(default=100_000, gt=0)
+
+ @field_validator("domain")
+ @classmethod
+ def _validate_domain(cls, value: Optional[str]) -> Optional[str]:
+ if value is None:
+ return value
+ if not value.strip():
+ raise ValueError("must not be empty or whitespace-only")
+ if any(char.isspace() for char in value):
+ raise ValueError("must not contain whitespace")
+ if any(ord(char) < 32 for char in value):
+ raise ValueError("must not contain control characters")
+ return value
+
+ @field_validator("sparql_endpoint")
+ @classmethod
+ def _validate_sparql_endpoint(cls, value: Optional[str]) -> Optional[str]:
+ if value is None:
+ return value
+ parsed = urlparse(value)
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
+ raise ValueError(
+ "must be a valid http(s) URL (e.g. 'https://wiki.example.org/sparql')"
+ )
+ return value
+
+ @field_validator("state_dir")
+ @classmethod
+ def _validate_state_dir(cls, value: Optional[str]) -> Optional[str]:
+ if value is None:
+ return value
+ if not value.strip():
+ raise ValueError("must not be empty or whitespace-only")
+ return value
+
+ @field_validator("cred_filepath")
+ @classmethod
+ def _validate_cred_filepath(cls, value: Optional[str]) -> Optional[str]:
+ # Control characters are deliberately not rejected here: load() already
+ # produces a much better, hint-carrying error for that case via
+ # _escape_hint(), and that check runs before Settings is constructed.
+ if value is None:
+ return value
+ if not value.strip():
+ raise ValueError("must not be empty or whitespace-only")
+ return value
+
+ def redacted(self) -> dict:
+ """A dict view safe for logging / the status tool (no password)."""
+ 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 _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 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 environment variable holding a value the
+ settings model rejects (an unparseable or non-positive integer, a
+ malformed SPARQL endpoint URL, a domain containing whitespace), and a
+ missing ``python-dotenv`` for an explicitly configured env file.
+
+ Raises
+ ------
+ 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)
+
+ kwargs: dict = dict(
+ domain=domain,
+ username=username,
+ password=password,
+ cred_filepath=cred_filepath,
+ sparql_endpoint=_first_env(ENV_SPARQL_ENDPOINT),
+ state_dir=_first_env(ENV_STATE_DIR),
+ )
+ # An unset or blank/whitespace-only variable falls back to the model
+ # default; pass the raw string only when there is one to validate. Letting
+ # pydantic parse read_only rather than testing membership in a truthy set
+ # matters because the default is fail-open: a typo like "ture" would
+ # otherwise silently leave writes enabled on a server meant to be read-only.
+ read_only_raw = _first_env(ENV_READ_ONLY)
+ if read_only_raw is not None and read_only_raw.strip():
+ kwargs["read_only"] = read_only_raw
+ max_results_raw = _first_env(ENV_MAX_RESULTS)
+ if max_results_raw is not None and max_results_raw.strip():
+ kwargs["max_results"] = max_results_raw
+ max_chars_raw = _first_env(ENV_MAX_CHARS)
+ if max_chars_raw is not None and max_chars_raw.strip():
+ kwargs["max_chars"] = max_chars_raw
+
+ try:
+ return Settings(**kwargs)
+ except ValidationError as exc:
+ details = []
+ for err in exc.errors():
+ field_name = str(err["loc"][0]) if err["loc"] else ""
+ details.append(
+ f"{_env_name_for(field_name)}={err.get('input')!r}: {err['msg']}"
+ )
+ raise RuntimeError("Invalid OSW configuration: " + "; ".join(details)) from exc
+
+
+_settings: Optional[Settings] = None
+
+
+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_express.py b/tests/integration/test_express.py
index 2c803e5..55e92d8 100644
--- a/tests/integration/test_express.py
+++ b/tests/integration/test_express.py
@@ -22,7 +22,6 @@
* test_upload_file
"""
-import os
import uuid
from contextlib import contextmanager
from pathlib import Path
@@ -102,11 +101,13 @@ def test_init_with_domain(wiki_domain, wiki_username, wiki_password, mocker):
osw_express.shut_down()
-def test_init_from_env_vars(wiki_domain, wiki_username, wiki_password):
+def test_init_from_env_vars(monkeypatch, wiki_domain, wiki_username, wiki_password):
+ # monkeypatch, not os.environ: the file is unlinked at the end of this test, so
+ # a leaked OSW_CRED_FILEPATH would point every later test at a missing file.
cred_filepath = Path.cwd() / "accounts.pwd.yaml"
- os.environ["OSW_CRED_FILEPATH"] = str(cred_filepath)
+ monkeypatch.setenv("OSW_CRED_FILEPATH", str(cred_filepath))
create_credentials_file(cred_filepath, wiki_domain, wiki_username, wiki_password)
- os.environ["OSW_DOMAIN"] = wiki_domain
+ monkeypatch.setenv("OSW_DOMAIN", wiki_domain)
osw_express = osw.express.OswExpress()
osw_express_and_credentials(osw_express, wiki_domain, wiki_username, wiki_password)
diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py
new file mode 100644
index 0000000..da8136a
--- /dev/null
+++ b/tests/integration/test_mcp_server.py
@@ -0,0 +1,85 @@
+"""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
+
+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
+
+ # An ask query has no defined result order and a category can hold pages
+ # without a jsondata slot, so check every hit rather than trusting the first.
+ titles = found["titles"]
+ if titles:
+ with_jsondata = []
+ for title in titles:
+ entity = mcp_tools["get_entity"](title=title)
+ assert entity["title"] == title
+ assert entity["exists"] is True
+
+ page_slots = mcp_tools["list_page_slots"](title=title)
+ assert page_slots["exists"] is True
+ if any(s["key"] == "jsondata" for s in page_slots["slots"]):
+ with_jsondata.append(title)
+ assert with_jsondata, f"no jsondata slot on any of {titles}"
+
+
+def test_delete_guard_blocks_untracked(mcp_tools):
+ # 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..1871373
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,429 @@
+"""Unit tests for the osw CLI (src/osw/cli).
+
+The CLI never imports the mcp SDK, and no network is touched --
+``osw.service.context.OswExpress`` is
+patched wherever a test actually reaches a command's body.
+"""
+
+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_in_memory_upload.py b/tests/test_in_memory_upload.py
new file mode 100644
index 0000000..9713c06
--- /dev/null
+++ b/tests/test_in_memory_upload.py
@@ -0,0 +1,76 @@
+"""Unit tests for uploading a file from an in-memory stream (issue #140).
+
+Fully offline: no network, no live wiki. The upload path is cut short by
+replacing ``WikiFileController.from_other`` with a stub that records the
+source controller it was handed, so the dispatch in ``UploadFileResult`` can
+be checked without touching a wiki.
+"""
+
+from __future__ import annotations
+
+from io import BytesIO
+from unittest.mock import MagicMock
+
+import pytest
+
+import osw.express
+from osw.controller.file.memory import InMemoryController
+
+
+def test_controller_accepts_a_caller_supplied_stream():
+ stream = BytesIO(b"payload")
+ controller = InMemoryController(stream=stream)
+ assert controller.get() is stream
+ assert controller.get().read() == b"payload"
+
+
+def test_controller_defaults_to_an_empty_binary_buffer():
+ controller = InMemoryController()
+ assert isinstance(controller.stream, BytesIO)
+ assert controller.stream.getvalue() == b""
+
+
+def test_controller_put_copies_into_the_stream():
+ controller = InMemoryController()
+ controller.put(BytesIO(b"payload"))
+ assert controller.stream.getvalue() == b"payload"
+
+
+def test_upload_wraps_a_stream_in_an_in_memory_controller(monkeypatch):
+ """A BytesIO must reach WikiFileController as an InMemoryController.
+
+ Guards the duck-typed source check in UploadFileResult.__init__: an
+ ``isinstance(source, IO)`` test never matches, because typing.IO is not
+ runtime-checkable.
+ """
+ stream = BytesIO(b"payload")
+ seen = {}
+
+ class _StopBeforeUpload(Exception):
+ pass
+
+ def _fake_from_other(other, osw, **data):
+ seen["source_file_controller"] = other
+ raise _StopBeforeUpload
+
+ monkeypatch.setattr(
+ osw.express.WikiFileController,
+ "from_other",
+ staticmethod(_fake_from_other),
+ )
+
+ with pytest.raises(_StopBeforeUpload):
+ osw.express.UploadFileResult(
+ source=stream,
+ osw_express=MagicMock(),
+ target_fpt="File:Test.bin",
+ )
+
+ controller = seen["source_file_controller"]
+ assert isinstance(controller, InMemoryController)
+ assert controller.get() is stream
+
+
+def test_upload_rejects_a_source_that_is_not_file_like():
+ with pytest.raises(ValueError, match="must be a LocalFileController"):
+ osw.express.UploadFileResult(source=object(), osw_express=MagicMock())
diff --git a/tests/test_mcp_registration.py b/tests/test_mcp_registration.py
new file mode 100644
index 0000000..d708251
--- /dev/null
+++ b/tests/test_mcp_registration.py
@@ -0,0 +1,107 @@
+"""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
+
+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..6dfa5ac
--- /dev/null
+++ b/tests/test_mcp_server.py
@@ -0,0 +1,158 @@
+"""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
+
+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..49ac041
--- /dev/null
+++ b/tests/test_no_paths_on_mcp_surface.py
@@ -0,0 +1,91 @@
+"""Guard tests: no filesystem path may ever reach the MCP surface.
+
+Offline: importing ``osw.cli.ops`` (to register the CLI-only, path-taking
+operations, so the negative check below cannot pass vacuously) and
+``osw.service.ops`` touches the network nowhere.
+"""
+
+from __future__ import annotations
+
+import inspect
+import subprocess
+import sys
+from unittest.mock import MagicMock
+
+# 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():
+ 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..48478c6
--- /dev/null
+++ b/tests/test_service_config.py
@@ -0,0 +1,653 @@
+"""Unit tests for osw.service.config (fail-fast credential validation)."""
+
+import sys
+
+import pytest
+import yaml
+from pydantic import ValidationError
+
+from osw.service import config
+from osw.service.config import Settings
+
+_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
+
+
+# -- Settings validation (pydantic) ------------------------------------------
+
+
+def test_blank_max_results_falls_back_to_default(monkeypatch):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_MAX_RESULTS", " ")
+ settings = config.load()
+ assert settings.max_results == 100
+
+
+def test_zero_max_results_raises(monkeypatch):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_MAX_RESULTS", "0")
+ with pytest.raises(RuntimeError) as exc:
+ config.load()
+ assert "OSW_MAX_RESULTS" in str(exc.value)
+
+
+def test_malformed_sparql_endpoint_raises(monkeypatch):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_SPARQL_ENDPOINT", "not a url")
+ with pytest.raises(RuntimeError) as exc:
+ config.load()
+ assert "OSW_SPARQL_ENDPOINT" in str(exc.value)
+
+
+def test_valid_sparql_endpoint_loads(monkeypatch):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_SPARQL_ENDPOINT", "https://wiki.example.org/sparql")
+ settings = config.load()
+ assert settings.sparql_endpoint == "https://wiki.example.org/sparql"
+
+
+def test_error_names_the_alias_that_was_set(monkeypatch):
+ # Only the alias is set (not the canonical name), so the error must name
+ # the alias, not the canonical variable, for the operator to find it.
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_MCP_MAX_RESULTS", "notanumber")
+ with pytest.raises(RuntimeError) as exc:
+ config.load()
+ assert "OSW_MCP_MAX_RESULTS" in str(exc.value)
+
+
+def test_misspelled_read_only_raises(monkeypatch):
+ # A typo must not silently enable writes: read_only is the one flag whose
+ # fail-open default is dangerous, so an unparseable value has to be loud.
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_READ_ONLY", "ture")
+ with pytest.raises(RuntimeError) as exc:
+ config.load()
+ assert "OSW_READ_ONLY" in str(exc.value)
+
+
+@pytest.mark.parametrize("raw", ["1", "true", "TRUE", "yes", "on", "y", "t"])
+def test_read_only_truthy_spellings(monkeypatch, raw):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_READ_ONLY", raw)
+ assert config.load().read_only is True
+
+
+@pytest.mark.parametrize("raw", ["0", "false", "FALSE", "no", "off", "n", "f"])
+def test_read_only_falsy_spellings(monkeypatch, raw):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_READ_ONLY", raw)
+ assert config.load().read_only is False
+
+
+def test_blank_read_only_falls_back_to_default(monkeypatch):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_READ_ONLY", " ")
+ assert config.load().read_only is False
+
+
+def test_read_only_error_names_the_alias_that_was_set(monkeypatch):
+ monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
+ monkeypatch.setenv("OSW_USERNAME", "alice")
+ monkeypatch.setenv("OSW_PASSWORD", "secret")
+ monkeypatch.setenv("OSW_MCP_READ_ONLY", "disabled")
+ with pytest.raises(RuntimeError) as exc:
+ config.load()
+ assert "OSW_MCP_READ_ONLY" in str(exc.value)
+
+
+def test_domain_with_whitespace_rejected():
+ with pytest.raises(ValidationError):
+ Settings(domain="wiki.example.org has a space")
+
+
+def test_domain_as_full_url_accepted():
+ # get_active_domain() relies on a full URL being a legal domain value.
+ settings = Settings(domain="https://wiki.example.org/w/")
+ assert settings.domain == "https://wiki.example.org/w/"
+
+
+def test_settings_is_frozen():
+ settings = Settings(domain="wiki.example.org")
+ with pytest.raises(ValidationError):
+ settings.domain = "other.example.org"
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..afbfab7
--- /dev/null
+++ b/tests/test_service_instances.py
@@ -0,0 +1,244 @@
+"""Unit tests for multi-instance selection in osw.service (config + Context).
+
+These are fully offline: no network, no live wiki, and no MCP SDK, since
+osw.service is deliberately SDK-free.
+"""
+
+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..83a11ca
--- /dev/null
+++ b/tests/test_service_ops_files.py
@@ -0,0 +1,219 @@
+"""Unit tests for osw.service.ops.files (Operation.fn called directly).
+
+Fully offline: ``WikiFileController``
+is replaced with a fake factory that records its constructor arguments, so
+every test can inspect the title/namespace a real controller would have
+derived without touching a wiki.
+"""
+
+from 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 c9673db..010fdc7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1035,6 +1035,19 @@ 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", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" },
+ { name = "truststore", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" },
+]
+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"
@@ -1055,6 +1068,32 @@ http2 = [
{ name = "h2" },
]
+[[package]]
+name = "httpx2"
+version = "2.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", 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"
@@ -1474,6 +1513,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.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "httpx2" },
+ { name = "jsonschema" },
+ { name = "mcp-types" },
+ { name = "opentelemetry-api" },
+ { name = "pydantic" },
+ { name = "pyjwt", extra = ["crypto"] },
+ { name = "python-multipart" },
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
+ { name = "sse-starlette" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+ { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d4/6e/21fb8e5d579dbe21d96ea4d5034200d46d8bdf2261053b5bd041f3c2f612/mcp-2.1.1.tar.gz", hash = "sha256:50b7ba1ebbe117008ea7bdd288234043e69c20b403d6851d19661e6d431a75ef", size = 3984589, upload-time = "2026-08-25T16:14:02.376Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/50/af/8644cc5fa26a59afd2df2e98eeb19e72926887fa4b7441aba4ff661140db/mcp-2.1.1-py3-none-any.whl", hash = "sha256:1c6c31c5d6471c58db76af3af8af67f46d11d01f0a59077d0a308cbdb3d3e915", size = 357912, upload-time = "2026-08-25T16:13:59.024Z" },
+]
+
+[[package]]
+name = "mcp-types"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/dd/1c4417dc0b722c23a1669032d5f044e41170fe5d4773b488a50fcce98c32/mcp_types-2.1.1.tar.gz", hash = "sha256:77dcbe48fba73cca71a673f2646a5f037a017b7a0a07ac89cec1113028890eda", size = 66674, upload-time = "2026-08-25T16:14:03.861Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/d0/242e63c510f4a17381f55b1549a3f94f5687a0595984febd2b6f87a687a0/mcp_types-2.1.1-py3-none-any.whl", hash = "sha256:26f9f7f03f2a5730717a5b98e2ab7eb640ac352d05a00cdc725c311864778295", size = 69656, upload-time = "2026-08-25T16:14:00.667Z" },
+]
+
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -1909,6 +1986,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"
@@ -2003,6 +2092,7 @@ dependencies = [
{ name = "requests" },
{ name = "sparqlwrapper" },
{ name = "tqdm" },
+ { name = "typer" },
{ name = "typing-extensions" },
]
@@ -2011,10 +2101,12 @@ all = [
{ name = "boto3" },
{ name = "deepl" },
{ name = "geopy" },
+ { name = "mcp" },
{ name = "mwparserfromhell" },
{ name = "openpyxl" },
{ name = "psycopg2" },
{ name = "pysimplegui" },
+ { name = "python-dotenv" },
{ name = "sqlalchemy" },
]
dataimport = [
@@ -2026,6 +2118,10 @@ db = [
{ name = "psycopg2" },
{ name = "sqlalchemy" },
]
+mcp = [
+ { name = "mcp" },
+ { name = "python-dotenv" },
+]
s3 = [
{ name = "boto3" },
]
@@ -2056,7 +2152,7 @@ dev = [
{ name = "mike" },
{ name = "mkdocstrings-python" },
{ name = "mwparserfromhell" },
- { name = "osw", extra = ["workflow"] },
+ { name = "osw", extra = ["mcp", "workflow"] },
{ name = "pre-commit" },
{ name = "psycopg2-binary" },
{ name = "pytest" },
@@ -2083,6 +2179,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" },
@@ -2092,22 +2189,24 @@ requires-dist = [
{ name = "opensemantic-base", specifier = ">=0.42.7" },
{ name = "opensemantic-core", specifier = ">=0.57.4" },
{ name = "osw", extras = ["dataimport"], marker = "extra == 'tutorial'" },
- { name = "osw", extras = ["dataimport", "db", "ui", "s3", "wikitext"], marker = "extra == 'all'" },
+ { name = "osw", extras = ["dataimport", "db", "ui", "s3", "wikitext", "mcp"], marker = "extra == 'all'" },
{ name = "prefect", marker = "extra == 'workflow'", specifier = ">=2.20.25,<3.0" },
{ name = "psycopg2", marker = "extra == 'db'" },
{ name = "pybars3-wheel" },
{ 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 = [
@@ -2120,7 +2219,7 @@ dev = [
{ name = "mike", git = "https://github.com/squidfunk/mike.git?rev=2.2.0%2Bzensical-0.1.0" },
{ name = "mkdocstrings-python", specifier = ">=1.0.3" },
{ name = "mwparserfromhell" },
- { name = "osw", extras = ["workflow"] },
+ { name = "osw", extras = ["workflow", "mcp"] },
{ name = "pre-commit", specifier = ">=4.0.0" },
{ name = "psycopg2-binary" },
{ name = "pytest" },
@@ -2563,6 +2662,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"
@@ -2699,6 +2815,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.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
+]
+
[[package]]
name = "python-gitlab"
version = "8.4.0"
@@ -3440,6 +3565,32 @@ asyncio = [
{ name = "greenlet" },
]
+[[package]]
+name = "sse-starlette"
+version = "3.4.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { 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.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
+]
+
[[package]]
name = "text-unidecode"
version = "1.3"
@@ -3524,6 +3675,15 @@ 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" },