Skip to content

feat(mcp): add osw-mcp server exposing a live OSL instance - #133

Open
LukasGold wants to merge 4 commits into
mainfrom
feat/mcp-server
Open

feat(mcp): add osw-mcp server exposing a live OSL instance#133
LukasGold wants to merge 4 commits into
mainfrom
feat/mcp-server

Conversation

@LukasGold

@LukasGold LukasGold commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an in-repo MCP server (osw[mcp] extra, osw-mcp stdio console script) that
wraps OswExpress and exposes a live OpenSemanticLab instance to MCP clients such
as Claude Code.

Changes

New package src/osw/mcp/

  • server.py - create_server() / main(), MCPServer over stdio
  • config.py - env/.env/credential-file resolution, validation, active-instance state
  • connection.py - lazy OswExpress connection, stdout redirected to stderr
  • ledger.py - local JSON provenance ledger of pages the server created/modified
  • serialization.py - result trimming and JSON-safe conversion
  • tools/ - tool registration, split by domain

Tools

Module Tools
search.py search_entities (semantic/ask), full_text_search, list_instances_of_category, sparql_query
schema.py get_category_schema
entities.py get_entity, export_entity_jsonld, create_or_update_entity, delete_entity
slots.py list_page_slots, get_slot, set_slot
files.py download_file, upload_file
instances.py list_instances, select_instance
status.py status

Packaging (pyproject.toml)

  • New mcp extra: mcp>=2 (official SDK) and python-dotenv>=1.0
  • New console script osw-mcp = "osw.mcp.server:main"
  • The extra is not part of all and not in the dev group, see
    Dependency constraint below
  • Pytest stack moved into its own test group

Docs (README.md) - install, .env and credential-file setup, multi-instance
selection, .mcp.json and claude mcp add registration, safe-delete behaviour,
editable-checkout caveat.

Design decisions

  • Provenance-guarded deletes. The ledger records every page the server creates
    or modifies. Those delete without extra ceremony; anything untracked requires
    confirm_external_delete=true. Prevents an agent from removing wiki content it
    did not author. The ledger is keyed per domain and rebuilt when the active
    instance changes.
  • Fail-fast credentials. Resolved from env, .env or an osw credential file
    and validated at startup. The server never calls input(), since a prompt on
    stdio would corrupt the JSON-RPC channel; credential-file lookups therefore use
    CredentialFallback.none.
  • Credentials never transit MCP. list_instances and status return iris
    only. Instance choice is by name; no username or password is ever returned by a
    tool, so nothing lands in the agent's context.
  • Instance selection over reconfiguration. With several iris in a credential
    file, select_instance switches at runtime and rebuilds the connection.
    Registering the server once per instance (osw-dev, osw-prod) still works and
    keeps the instance visible in the tool name.
  • stdout isolation. osw's own prints are redirected to stderr for the same
    reason.
  • Read-only mode. OSW_MCP_READ_ONLY=true hides all mutating tools rather than
    failing them at call time, so the client never sees a tool it cannot use.

Configuration

OSW_DOMAIN, OSW_USERNAME, OSW_PASSWORD, OSW_MCP_CRED_FILEPATH
(OSL_CRED_FILEPATH as fallback), OSW_SPARQL_ENDPOINT, OSW_MCP_ENV_FILE,
OSW_MCP_READ_ONLY, OSW_MCP_STATE_DIR, OSW_MCP_MAX_RESULTS,
OSW_MCP_MAX_CHARS.

OSW_DOMAIN is required unless a credential file is configured, in which case the
active instance comes from the file (auto-selected when it holds exactly one iri).

Dependency constraint

mcp>=2 requires anyio>=4.9; osw[workflow] pins anyio<4.7 because prefect
2.20.25's GatherTaskGroup breaks on newer anyio. The two cannot share an
environment, so:

  • [tool.uv] conflicts declares mcp mutually exclusive with the workflow
    extra and with the dev group
  • mcp is excluded from all and from dev, keeping the workflow-compatible
    anyio as the default resolution
  • src/osw/mcp is excluded from ty and its tests use importorskip, since the
    imports are unresolvable in the default dev environment

Tracked in #139, which is the
reminder to re-check whether the anyio pin can be dropped.

Tests

  • tests/test_mcp_config.py, tests/test_mcp_ledger.py,
    tests/test_mcp_serialization.py, tests/test_mcp_tools.py,
    tests/test_mcp_instances.py - offline unit tests
  • tests/integration/test_mcp_server.py - against a live instance

The MCP unit tests need their own environment:

uv sync --extra mcp --group test --no-dev
uv run --extra mcp --group test --no-dev --no-sync python -m pytest tests/test_mcp_*.py

58 passed there; the default suite is 51 passed, 4 skipped, with ty and deptry
clean. CI does not yet run the mcp environment.

Known caveat

create_or_update_entity and export_entity_jsonld call fetch_schema, which
regenerates src/osw/model/entity.py inside the installed package. Harmless for a
normal pip install "osw[mcp]" (writes into site-packages), but running the server
from an editable checkout will modify the generated model file in the working tree.
The read tools do not trigger it.

Related

The store_entity parallel-upload fix that previously rode along on this branch was
dropped and is tracked separately in
#132.

@LukasGold
LukasGold requested a review from simontaurus July 20, 2026 10:59
@LukasGold LukasGold self-assigned this Jul 20, 2026
@LukasGold LukasGold added the enhancement New feature or request label Jul 20, 2026
Add an in-repo `osw[mcp]` extra and an `osw-mcp` stdio console script that
wraps OswExpress and serves it over the Model Context Protocol for clients
such as Claude Code.

Tools: semantic/SPARQL/full-text search, category schema introspection,
entity read + JSON-LD export, create/update/delete, full page-slot access,
and file up/download.

- Delete is provenance-guarded: a local JSON ledger records pages the server
  created/modified; deleting anything untracked requires
  confirm_external_delete=true.
- Credentials resolve from env/.env and are validated up front (fail fast,
  never prompts, so stdio is never corrupted by an input() call).
- osw stdout is redirected to stderr so it never leaks onto the JSON-RPC channel.
- OSW_MCP_READ_ONLY hides all mutating tools.
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Release preview

Merging this PR would release v2.1.0 (current: v2.0.0).

Changelog preview (truncated)
## v2.1.0 (2026-08-22)

### Documentation

- Add citation and authorship section to CONTRIBUTING.md
  ([`a3a8119`](https://github.com/OpenSemanticLab/osw-python/commit/a3a8119cd047cee741812b11e3f1f26c3f91c639))

### Features

- **mcp**: Add osw-mcp server exposing a live OSL instance
  ([`23db777`](https://github.com/OpenSemanticLab/osw-python/commit/23db777f632a3c0bf17a0ec97aaf4a330623a9cb))

- **mcp**: Authenticate from an osw credential file
  ([`a7c35a7`](https://github.com/OpenSemanticLab/osw-python/commit/a7c35a7929cdcc497a42724a1aa3a877f5bd47ca))

- **mcp**: Port server to mcp 2.x and isolate the extra
  ([`e219aae`](https://github.com/OpenSemanticLab/osw-python/commit/e219aae1600811bbde0208258f234f34057efcc7))

- **mcp**: Select between multiple OSL instances at runtime
  ([`d393a66`](https://github.com/OpenSemanticLab/osw-python/commit/d393a6632c636b3b2206f56ff85def2b1af1a191))

Preview via python-semantic-release and conventional commits.

@LukasOro

Copy link
Copy Markdown

mcp>=1.2 resolves to 2.0.0, and the server no longer starts

Trying the branch out, the server dies before the MCP handshake:

File ".../osw/mcp/server.py", line 13, in <module>
    from mcp.server.fastmcp import FastMCP
ModuleNotFoundError: No module named 'mcp.server.fastmcp'

pyproject.toml declares mcp>=1.2, which currently resolves to mcp==2.0.0.
In 2.x the vendored FastMCP is gone: there is no mcp.server.fastmcp, no
FastMCP export from mcp.server, and no separate fastmcp package is pulled
in as a dependency. MCP clients surface this only as -32000: Connection closed, so it takes a manual run of osw-mcp to see the traceback.

Workaround for anyone hitting this right now:

uvx --from "osw[mcp] @ git+https://github.com/OpenSemanticLab/osw-python.git@feat/mcp-server" \
    --with "mcp<2" osw-mcp

Rather than capping at <2, I would suggest porting to the 2.x API. It looks
close to mechanical:

  • mcp.server.mcpserver.MCPServer, also re-exported as mcp.server.MCPServer,
    replaces FastMCP.
  • MCPServer.tool() keeps the same decorator-with-parentheses form, so the 15
    @mcp.tool() registrations in src/osw/mcp/tools/ stay as they are.
  • MCPServer.run() still defaults to transport="stdio".

In practice that is the import and the constructor in src/osw/mcp/server.py,
plus the -> FastMCP return annotations, 6 references in total.

Verified against mcp==2.0.0.

@LukasOro

Copy link
Copy Markdown

Credential loading bypasses the CredentialManager file pattern

config.load() requires domain, username and password from the environment
(OSW_DOMAIN / OSW_USERNAME / OSW_PASSWORD, with OSL_* fallbacks) and
raises RuntimeError if any of the three is missing. Failing fast rather than
reaching osw's interactive prompt is the right call for a stdio server, so this
is about which sources are accepted, not about the validation itself.

Deployments that authenticate through osw's own CredentialManager with a
credential file do not have those variables at all. Our FastAPI service
configures OSL as:

OSL_DOMAIN=osl.demo.open-semantic-lab.org
OSL_CRED_FILEPATH=/path/to/osl/cred/file.json

OSL_DOMAIN is picked up by the existing fallback, but there is no username or
password anywhere in the environment, so the server refuses to start. The only
way to run it today is to copy the credentials back out of the credential file
into a second plaintext .env that exists purely for the MCP server. That
duplicates the secret and adds another file to keep out of version control,
which is the situation CredentialManager is there to avoid.

Would you consider accepting a credential file as a source, for example reading
OSL_CRED_FILEPATH (or a dedicated OSW_MCP_CRED_FILEPATH) and passing it to
CredentialManager, and falling back to the current env-var path when it is
unset? That would let existing osw deployments point the server at the
credential store they already maintain.

@LukasOro

Copy link
Copy Markdown

Multi-instance credentials: selecting an OSL instance per session

Following on from the credential-file comment above. Pinning one .env at
registration assumes a user only ever talks to a single OSL instance, whereas
dev, staging and production wikis are usually all in play.

The multi-account file already exists. CredentialManager's credential file
is keyed by iri and already holds many accounts (save_credentials_to_file
writes data[cred.iri], iri_in_file(iri) looks one up). So this is less "add a
file format" and more "read the one osw already has, and let the caller choose an
entry".

One correction on the interactive part. Prompting on the CLI cannot work for
a stdio server: stdin and stdout are the JSON-RPC transport, so a prompt would
corrupt the stream, and there is no TTY. That is what config.load()'s fail-fast
protects against today, and CredentialFallback.ask would hang the server for
the same reason. The MCP-native equivalent is elicitation, which the 2.x SDK
exposes (Elicit, AcceptedElicitation, DeclinedElicitation in
mcp.server.mcpserver). Another argument for the port suggested above.

Elicit the choice, never the secret. Elicited values pass through the MCP
client and into the agent's context. Selecting an iri by name is fine; entering a
password there is not. Passwords should stay in the credential file and never
transit MCP.

Concretely:

  • list_instances() returns the iris in the credential file, no secrets.
  • select_instance(iri) sets the active instance, calls connection.reset() so
    the cached OswExpress is rebuilt, and re-creates the Ledger. The ledger is
    already domain-scoped via _safe_domain(domain), so provenance stays separated.
  • status() reports the active iri. With several instances reachable that stops
    being a nicety.
  • Auto-select when unambiguous: if OSW_DOMAIN is set, or the file holds exactly
    one iri, skip the prompt.
  • If nothing is selected, tools should fail with "no instance selected, call
    select_instance first" rather than the server refusing to start. That does
    invert today's startup validation, so it is worth a deliberate decision.

Multiple registrations already cover part of this, with no code change:

claude mcp add osw-dev  --env OSW_MCP_ENV_FILE=... -- uvx ...
claude mcp add osw-prod --env OSW_MCP_ENV_FILE=... --env OSW_MCP_READ_ONLY=true -- uvx ...

That has two advantages over in-session switching: the instance is visible in the
tool name at every call site, and read-only can be set per instance, so production
stays read-only while dev is writable. In-session selection should preserve both,
ideally with a per-iri read-only setting.

Supplying a .env path at runtime still works as a fallback for one-off instances
not in the credential file, though it is the weakest option since it puts secrets
on disk in a second place.

@LukasGold LukasGold changed the title feat/mcp-server feat(mcp): add osw-mcp server exposing a live OSL instance Aug 22, 2026
- FastMCP replaced by MCPServer, extra now requires mcp>=2
- mcp dropped from the all extra and the dev group: it needs
  anyio>=4.9, workflow pins anyio<4.7 (#139)
- uv conflicts declare mcp exclusive with workflow and with dev
- pytest stack moved to its own test group, so an environment with
  both pytest and mcp exists
- src/osw/mcp excluded from ty, mcp tests guarded by importorskip
- OSW_MCP_CRED_FILEPATH configures it, OSL_CRED_FILEPATH is a fallback
- an alternative to OSW_USERNAME/OSW_PASSWORD, so the password is not
  duplicated into a second plaintext file
- existence and a matching domain entry are validated at startup
- lookups use CredentialFallback.none, so osw never prompts and never
  blocks the stdio transport
- list_instances and select_instance tools, returning iris only and
  never any credential value
- OSW_DOMAIN becomes optional when a credential file supplies the iris
- auto-selects when OSW_DOMAIN is set or the file holds exactly one iri
- switching rebuilds the connection and the per-domain provenance ledger
- tools resolve the active domain and credentials at call time
@LukasGold

Copy link
Copy Markdown
Contributor Author

All three addressed, one commit each: e219aae, a7c35a7, d393a66.

mcp>=1.2 resolves to 2.0.0 - e219aae

Confirmed against the published wheel: mcp/server/fastmcp/ is gone in 2.0.0 and
MCPServer is exported from mcp.server. Ported rather than pinned to <2. The
extra now requires mcp>=2 and create_server() returns an MCPServer; the
decorator and run() surface were unchanged, so the tool modules needed no edits.

The anyio clash you predicted is real: mcp>=2 wants anyio>=4.9, osw[workflow]
pins anyio<4.7 for prefect 2.20.25's GatherTaskGroup. Resolution keeps the
workflow pin as the default:

  • [tool.uv] conflicts declares mcp exclusive with the workflow extra and with
    the dev group
  • mcp removed from all and from dev
  • the pytest stack moved into a test group, so an environment containing both
    pytest and mcp exists at all
  • src/osw/mcp excluded from ty; mcp tests guarded by importorskip

Cost: the module is neither type-checked nor unit-tested in the default
environment, and CI does not yet run the mcp one. Written up in
#139, which is the reminder to
re-check whether the anyio pin can be dropped.

Credential loading bypasses the CredentialManager file pattern - a7c35a7

OSW_MCP_CRED_FILEPATH (with OSL_CRED_FILEPATH as fallback) now configures a
credential file, built into a CredentialManager and passed to OswExpress via
cred_mngr=. Validated at startup: the file must exist and must hold an entry for
the configured domain, with the error naming the iris the file does contain.

One correction: osw itself does not read OSL_CRED_FILEPATH. CredentialManager
falls back to OSW_USERNAME/OSW_PASSWORD (and the OSL_* aliases) but takes
cred_filepath as a constructor argument only, so the env var is your service
convention rather than a library one. Kept it as the fallback name anyway, since
deployments already set it.

Lookups use CredentialFallback.none throughout, so osw never reaches its
interactive prompt - a prompt on stdio would corrupt the JSON-RPC channel and hang
the server. save_credentials_to_file() is not called anywhere; the server still
never writes credentials.

Multi-instance credentials - d393a66

Agreed on the constraint that elicited values pass through the client and into the
agent's context, so selection is by name only. Two tools:

  • list_instances - the available iris, never a username or password
  • select_instance(iri) - switches, rebuilding the connection and the ledger,
    which stays keyed per domain

OSW_DOMAIN is now optional when a credential file supplies the iris. Auto-selects
when OSW_DOMAIN is set or the file holds exactly one iri, so the single-instance
setup is unchanged. Until one is active, other tools return "No OSL instance
selected. Call select_instance first; available: ...". status reports the active
instance.

Iris may be bare domains or full URLs; OswExpress validates a bare domain, so the
netloc is derived before connecting. The tools that used to bake settings.domain
into their output (create_or_update_entity urls, the SPARQL client) now resolve
the active domain and credentials at call time.

Per-instance registration is documented as the alternative in the README, since it
keeps the instance visible in the tool name at every call site and lets read-only
be set per instance.

Verification

  • default env: 51 passed, 4 skipped; ty and deptry clean
  • mcp env (uv sync --extra mcp --group test --no-dev): 58 passed against
    mcp==2.0.0
  • not run: the live integration test

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants