Skip to content

feat: an osw CLI and an MCP server for live OSL instances - #133

Merged
LukasGold merged 59 commits into
mainfrom
feat/mcp-server
Sep 21, 2026
Merged

LukasGold merged 59 commits into
mainfrom
feat/mcp-server

Conversation

@LukasGold

@LukasGold LukasGold commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

What this adds

Two ways to work against a live OpenSemanticLab instance without writing Python:

  • osw, a command line client, shipped with the base package
  • osw-mcp, a stdio MCP server (osw[mcp] extra) that exposes one instance
    to agent clients such as Claude Code

Both are generated from a single declaration per operation, so a command and its
matching tool cannot drift apart in behaviour, argument names or help text.

Capabilities

Subject CLI MCP tools
Search search ask, search text, search instances, search sparql search_entities, full_text_search, list_instances_of_category, sparql_query
Schemas schema get get_category_schema
Entities entity get, put, export, delete get_entity, create_or_update_entity, export_entity_jsonld, delete_entity
Page slots slot list, get, set list_page_slots, get_slot, set_slot
File pages as text file info, cat, write get_file_info, read_file_text, write_file_text
Files on disk file download, file upload none, by design
Session status, instance list, ledger path status

Behaviour common to both:

  • Read-only mode (--read-only, OSW_READ_ONLY) hides mutating tools rather
    than failing them when called, so an agent never sees a tool it cannot use.
  • Provenance-guarded deletes. Pages the server created or modified delete
    normally; anything else requires confirm_external_delete=true.
  • Result caps (OSW_MAX_RESULTS, OSW_MAX_CHARS) keep a broad query from
    flooding an agent's context.
  • Credentials come from the environment, a .env file, or an iri-keyed osw
    credential file. No tool ever returns one.
  • Machine-readable output. osw --json puts JSON on stdout and osw's own
    progress output on stderr. Failures exit non-zero with one line, no traceback.

Constraints that shaped it

Constraint Consequence
An MCP server can be remote or containerised no filesystem path on the MCP surface
stdio carries the JSON-RPC stream no prompt, no stdout write; osw's own output is redirected to stderr
Wiki content an agent reads is untrusted input no runtime instance switching; deletes are provenance-guarded
Whatever a tool returns enters the agent's context no credential value crosses MCP, ever

Decisions that need agreement

1. No filesystem path reaches the MCP surface.
Rejected: documenting the server as local-use-only. That is a rule with no
enforcement behind it. Instead paths exist only in osw.cli, and the operation
model refuses to register a path-like parameter on an MCP-surfaced operation, so
the server fails at import rather than shipping such a tool.

2. One server process per instance, no switching at runtime.
This reverses d393a66, which was agreed earlier in this PR. In-session
switching cannot keep the instance visible in the tool name, cannot make
read-only per instance, and is a prompt-injection target. Argument in full:
#133 (comment)

3. The server refuses to start unless an instance is named explicitly.
Rejected: failing at the first tool call, and inferring the sole iri of a
credential file. A server that cannot name its target would advertise tools that
all fail. The CLI still infers, because it resolves per invocation, reports what
it resolved, and --instance overrides any single command.

4. typer as a base dependency, not an extra.
Rejected: argparse plus a hand-written signature-to-parser translator (roughly 50
lines to own and keep honest), and click, which is decorator-per-option and would
mean writing every parameter twice. typer reads the same type hints and
docstrings the MCP SDK reads, which is what makes one declaration serve both. As
an extra it would let pip install osw ship a broken osw console script.

5. 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. It is included in osw[all] and in the dev group, so the server and its
tests share one environment with everything else. The earlier isolation, forced by
an anyio conflict with osw[workflow] and declared through [tool.uv] conflicts,
is gone: #139 is closed and
the pin is now anyio>=4.9,<4.14.

6. Canonical OSW_* configuration names, old names kept as aliases.
OSW_CRED_FILEPATH is already read by src/osw/express.py, so the previous
OSW_MCP_CRED_FILEPATH diverged from the library that owns the same setting.
OSW_MCP_* and OSL_* remain accepted, so existing deployments keep working.

Configuration

Required: an instance and credentials, either OSW_DOMAIN plus
OSW_USERNAME/OSW_PASSWORD, or OSW_DOMAIN plus OSW_CRED_FILEPATH.

.env handling differs by adapter on purpose. The CLI searches upward from the
working directory. The server searches nowhere, because its working directory is
chosen by the client, and takes its settings from the env block of its
registration. Both report the env file and credential file they resolved on
stderr before connecting.

Full reference, including registering one server per instance:
https://github.com/OpenSemanticLab/osw-python/blob/feat/mcp-server/docs/cli-and-mcp.md

Verification

uv sync
uv run python -m pytest tests/ -q     # 282 passed, 1 skipped
uv run ty check                       # All checks passed!
uv run deptry .                       # no dependency issues found

One environment covers everything. That includes the 17 MCP tests in
tests/test_mcp_registration.py, tests/test_mcp_server.py and
tests/test_no_paths_on_mcp_surface.py, which CI now runs alongside the rest.
They previously self-skipped in CI, because a plain uv sync --frozen never
installed the mcp extra, so the MCP code was effectively untested there.
tests/integration/test_mcp_server.py runs against a live instance.

Follow-ups

Filed while planning this work.

Resolved:

Open, deliberately not blocking this PR:

The store_entity parallel-upload fix that once rode along on this branch is now
tracked 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.6.0 (current: v2.5.2).

Changelog preview (truncated)
## v2.6.0 (2026-09-21)

### Testing

- Decode subprocess output as UTF-8, not the locale codepage
  ([`55aab85`](https://github.com/OpenSemanticLab/osw-python/commit/55aab85fcc5c7c913222f62254e9ff24bfa5e49c))

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

LukasGold commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

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

mcp>=1.2 resolves to 2.0.0

Confirmed against the published wheel: mcp/server/fastmcp/ is gone in 2.0.0 and
MCPServer is exported from mcp.server. Ported to 2.x rather than pinned to
<2; the decorator and run() surface are unchanged.

The anyio clash you predicted is real, and it is a constraint the PR now has to
carry rather than solve: mcp>=2 wants anyio>=4.9, osw[workflow] pins
anyio<4.7 for prefect 2.20.25. The workflow pin stays the default resolution,
mcp is declared exclusive with workflow and dev under [tool.uv] conflicts,
and the pytest stack moved into its own test group so an environment holding
both pytest and mcp can exist at all.

Cost: the module is neither type-checked nor unit-tested in the default
environment, and CI does not run the mcp one.
#139 is the reminder to
re-check the pin.

Credential loading bypasses the CredentialManager file pattern

An iri-keyed credential file now configures the server, built into a
CredentialManager and passed to OswExpress. Validated at startup: the file
must exist and hold an entry for the configured domain, with the error naming the
iris it does contain.

Two things worth confirming:

  • osw itself does not read OSL_CRED_FILEPATH. CredentialManager takes
    cred_filepath as a constructor argument only, so that variable is your service
    convention, not a library one. Kept as a fallback name, since deployments set it.
  • Lookups use CredentialFallback.none, so osw never reaches its interactive
    prompt. On stdio a prompt would corrupt the JSON-RPC stream and hang the server.
    Nothing calls save_credentials_to_file(); the server still never writes
    credentials to disk.

Multi-instance credentials

Agreed on the constraint: elicited values pass through the client and into the
agent's context, so instance selection is by name only and no secret transits MCP.

Implemented as list_instances (iris only) and select_instance(iri), which
rebuilds the connection and the per-domain ledger. OSW_DOMAIN becomes optional
when a credential file supplies the iris, auto-selecting when it is set or when the
file holds exactly one, so single-instance setups are unchanged.

Note this piece was reversed later in the PR, see
#133 (comment).

- move config/ledger/serialization from osw.mcp to osw.service
- add errors.py (OpError), context.py (Context+Policy), registry.py
- Operation validator rejects path-like params on the mcp surface
- canonical OSW_* env names, OSW_MCP_*/OSL_* kept as aliases
- config/ledger/serialization tests now run without the mcp extra
- add osw/service/ops/ with the search group as @operation functions
- mcp/tools/search.py becomes a registry loop over bind()
- add transitional legacy_context() so existing tests keep passing
- bind() resolves annotations against the op module, not registry.py
- schema, status, entities and slots lifted as @operation functions
- mcp/tools/*.py collapse to registry loops over bind()
- error dicts become raised errors.*; ledger calls become records= hooks
- normalize cli_name so every group reads as `osw <group> <verb>`
- 172 passed in the dev env, 29 in the mcp extra env
- typer as a base dependency; osw = "osw.cli.main:app" console script
- commands built from iter_operations(surface="cli"); Context built lazily
- OpError.exit_code becomes the process exit status, no traceback
- json_value parser lives in osw.service.params, so core never imports cli
- set_slot coerces content per the sibling slot's content model
- add path-free get_file_info/read_file_text/write_file_text built on
  WikiFileController.get()/.put(), never touching the local filesystem
- move file download/upload and ledger path to osw.cli.ops, the only
  module allowed to name a path (surfaces={"cli"}); drop status's
  ledger_path
- guard tests: no MCP-surfaced op names a path, and osw.mcp.server
  never imports osw.cli
- read_file_text decodes incrementally so a byte cap splitting a
  multi-byte character is not misreported as binary content
- delete list_instances/select_instance; each server process is pinned to
  one OSL instance and refuses to start if none resolves
- map Operation's hints onto ToolAnnotations and _meta by explicit
  keyword, since the SDK silently absorbs a misspelled field name
- set server instructions and version; make mcp.run(transport="stdio")
  explicit
- add CLI --instance and osw instance list, returning iris only
- retarget the "no instance selected" message at OSW_DOMAIN and
  --instance instead of the removed tool
@LukasGold

LukasGold commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Reversing in-session instance switching (d393a66)

list_instances and select_instance are removed in cb7d807. Each server
process is now pinned to one instance for its lifetime and refuses to start if
none is configured. Multi-instance support becomes one registration per instance.

This is a reversal of what we agreed, so the reasoning in full.

It cannot deliver the two advantages you named for
multiple registrations.

  • Instance visible in the tool name. With in-session selection every call is
    mcp__osw__get_entity whichever wiki it targets, so the permission prompt
    cannot show the destination. That visibility is a property of separate servers;
    switching cannot carry it.
  • Read-only per instance. Write tools are filtered at registration, so a model
    never sees a tool it cannot use. Per-iri read-only would mean the registered
    tool set has to change after the list has been advertised. The alternatives are
    registering write tools always and failing at call time, or a
    tools/list_changed round trip.

A threat model that was not in the original discussion. Wiki content an agent
reads is untrusted input, so a tool that moves the session from dev to prod is a
prompt-injection target: injected text on a page can ask for
select_instance("prod") and then write there. Under static pinning, prod is a
separate process with separate credentials, its own ledger and its own permission
rules.

Your point 5, on startup validation, taken deliberately. The server fails at
startup rather than returning "no instance selected" at call time, so a
misconfigured server never advertises tools that would all fail.

Unchanged: the iri-keyed CredentialManager file, status reporting the
active iri, and no secret transiting MCP. Elicitation is no longer needed here,
which drops that dependency.

Where switching went: the CLI added in c1cede3. osw --instance <iri> and
osw instance list are stateless per invocation, and the target is visible in the
command line and in shell history.

- delete osw/mcp/tools/ and connection.py; bodies live in osw.service.ops
- fold registration.py into server.py, its sole consumer
- replace test_mcp_tools.py with test_service_ops.py + test_mcp_server.py
- rebuild the integration fixture on bind()/iter_operations
- test_mcp_instances.py -> test_service_instances.py, no longer SDK-gated
- drop src/osw/mcp from [tool.ty.src] exclude
- silence only the two unresolvable SDK imports inline (issue #139)
- add a Command line section: command tree, global options, exit behaviour
- move credentials into a shared Configuration section with an alias table
- state that no MCP tool takes a path, and where the path-based commands live
- add stdio type, multi-instance and per-instance permission examples
- give each CLI command group a one-line help string
- conflict in src/osw/wiki_tools.py: both sides appended after
  prefix_search, kept both additions
- branch side: content_search
- main side: _ask_results_as_dict, CONDITION_PATTERN,
  LIMIT_PARAM_PATTERN, get_query_limit
@raederan
raederan self-requested a review September 7, 2026 09:32
- cap_list only reports a cut it made itself, but all four search
  operations pass the same limit to the wiki, which never returns more,
  so `truncated` was always False
- flag when the result set is as large as the effective limit
- search_entities: a `limit=` inside the ask query wins over the limit
  argument, so compare against that one
- sparql_query unchanged; its limit applies to the response, not the query
@raederan

Copy link
Copy Markdown
Contributor

Docs

image

we should name it "Tools" in general in the nav bar including sidebar navigation to

Maybe like this (also adding CLI, MCP as their own sidebar elements

image

user-sync could also be integrated as CLI tool in future development iterations

CLI Improvements

Configuration Behavior

when following in .env is given

OSW_CRED_FILEPATH='./accounts.pwd.yaml'
OSW_DOMAIN=wiki-dev.open-semantic-lab.org
image

./accounts.pwd.yaml carries the userinfo but is missing when .env only references OSW_DOMAIN

Additionals

when using CLI using ./accounts.pwd.yaml including multiple endpoints, we already can use command

osw instance wiki-dev.open-semantic-lab.org

but status would be great when we use ./accounts.pwd.yaml with instance parameter like

osw instance wiki-dev.open-semantic-lab.org status

or maybe better make instance a flag parameter in order to get status of multi instances

osw status --instance wiki-dev.open-semantic-lab.org

- replace the "CLI and MCP tools" tab with a "Tools" tab
- split the single page into Overview, CLI, MCP and Configuration
- leave room for a User Sync page from PR #174
- repoint the links in README.md and get-started.md
- status reports the username from a credential file, not only from
  OSW_USERNAME, and uses the precedence the login path uses
- rename the "instance" group to "instances", unconfusable with --instance
- add "osw instances status" to check every configured instance at once
- name the correct form when a root option is typed after the command
- extract config.get_credentials_for and Context.osw_for
- shared code hardcoded [osw] or [osw-mcp], so each adapter printed the
  other one's name about half the time
- config.set_log_prefix/log_prefix; CLI sets osw, MCP server sets osw-mcp
- covers the default delete and set_slot wiki edit comments, which are
  written permanently into page histories
- add flush=True to the status connection-failure print, which CliRunner
  otherwise never captures because it flushes only stdout
- "instances" read as OSL instances, which is what osw instances lists
- MCP tool name is the function name, so list_instances_of_category
  becomes search_entities and the ask query becomes search_ask
- fix the help text, which documented [[HasType::Category:<category>]]
  while the real query for Category:Item is [[HasType::Category:Item]]
- set the MCP prefix in the integration fixture that stands in for the
  server, so a future write test cannot tag a live wiki edit as [osw]
@LukasGold

Copy link
Copy Markdown
Contributor Author

Addressed on feat/mcp-server-pr-review, branched from 3a9bb89.

Docs

The top-level tab is now "Tools", with the CLI and the MCP server as separate sidebar entries plus a page for the configuration they share:

  • Overview: what the two adapters are, installation, which one to use
  • CLI
  • MCP
  • Configuration: resolution order, accounts.pwd.yaml format, the full OSW_* variable table

docs/cli-and-mcp.md is removed and its content split across those four pages.

User Sync is not included, because #174 is not in this branch. The section takes it as a fifth page with no restructuring once that PR merges.

Username missing when it comes from accounts.pwd.yaml

Fixed. status read settings.username, which only reflects OSW_USERNAME / OSL_USERNAME. It was None whenever the username came from a credential file.

It now calls a new config.get_active_credentials(), which applies the same precedence as the login path: credential file first, environment second. So osw status reports the username the connection actually uses. Reproduced and verified with exactly the .env from your comment.

It never prints the password.

--instance placement

Decision: --instance stays an option of osw itself, so it must come before the command.

  • osw --instance <iri> status works
  • osw status --instance <iri> does not

This follows git and docker. Accepting it in both positions means either declaring the option again on every subcommand, or removing it from the argument list before click parses it. Both give two spellings for one thing, and both make osw --instance a status --instance b a case somebody has to define.

What changed instead: typing it after the command no longer gives a bare "No such option". It now names the correct form.

No such option: --instance. It is an option of 'osw', not of 'osw status',
so it has to come before the command: osw --instance <iri> status

The same applies to --json, --read-only and --verbose. Where click already has a better suggestion, click wins: osw entity put --json ... still answers "Did you mean --jsondata?", because that is a misspelling of that command's own option.

osw instance renamed to osw instances

One correction first: osw instance wiki-dev.open-semantic-lab.org does not work on the current branch. The instance group only had list, so an iri in that position is rejected as an unknown command. Nothing was lost by changing the group.

The group is now plural, with two commands:

  • osw instances list lists the iris this process can connect to: the env-configured domain plus every entry of a configured credential file.
  • osw instances status reports each of them in more detail: iri, whether it is the active one, the username that would be used, and whether a connection succeeded.

osw instances status is the answer to the multi-endpoint case in your "Additionals" section. It checks every iri in accounts.pwd.yaml in one command, without taking an instance argument at all. One failing instance does not stop the others. It never prints passwords.

One limit worth stating: the instances are contacted one after another, and a single attempt has no timeout, because the reachability probe in osw.express has none. An unreachable instance therefore delays the command until its connection attempt gives up.

The plural also separates the two ideas by number: osw instances is the set of servers, --instance <iri> picks one of them.

osw search instances renamed to osw search entities

osw search instances read as if it listed OSL instances, which is what osw instances does. It actually lists wiki pages that declare a category as their type. It is now osw search entities, and its help text still describes instances of a category.

The MCP tool name is the Python function name, so two tools are renamed:

  • list_instances_of_category becomes search_entities
  • search_entities becomes search_ask, the arbitrary ask query behind osw search ask

The second rename follows from the first, since the new command needed the name search_entities. The MCP server is unreleased, so no published tool name breaks. MCP passes arguments by name, so a client calling search_entities(ask_query=...) now fails schema validation rather than returning wrong results.

Message prefix

Shared code hardcoded its prefix, so a CLI user could see [osw-mcp] and an MCP user could see [osw]. The prefix now names the adapter that is running, set once at startup by each one.

This includes the two default MediaWiki edit comments, for page deletion and for set_slot. Those are written permanently into page histories, so an edit made from the CLI no longer claims to come from the MCP server.

- README: keep both the Tools and the Logging sections
- pyproject: take main's version 2.4.0, keep the mcp extra and osw-mcp script
- uv.lock: regenerated
The tests assumed osw was imported after something had configured logging,
which decides whether osw attaches its own handler on import. tests/conftest.py
now imports osw, so that assumption no longer held and the hand-over test read
the leftover import-time handler.
- six stderr prints in the shared code move to the osw logger
- removes messages lost to an unflushed stderr write
- the configuration source report stays a print, gated by --verbose
- the MCP startup failure stays a print, so OSW_LOG_LEVEL=OFF cannot hide it
osw.cli.main subclasses click's Command and Group to report a misplaced root
option, so click is imported directly. deptry flagged it as DEP003 and failed
the quality job.
sys.stderr is line-buffered on every supported Python (>=3.10). The real
reason is the caller-chosen stream: the MCP server passes an io.StringIO and
click's CliRunner never flushes its stderr replacement.
- typer forces colour on when GITHUB_ACTIONS is set
- rich styles an option's leading dash separately, so "--jsondata"
  is no longer a plain substring
- rich wraps at the real terminal width, 80 in CI, 79 on Windows
- strip the escape sequences and panel borders, then join the lines
- a redirected stream used cp1252 on Windows, so a German label reached
  the consumer as bytes no JSON parser could read, with exit code 0
- a character cp1252 cannot represent raised UnicodeEncodeError instead
- the app callback reconfigures both streams before any output
- errors= is passed too, or reconfigure would reset stderr to strict
- --help and an unknown command name still bypass it; neither carries
  wiki content

Closes #187
- osw and osw-mcp now start through the new src/osw_entry.py shim
- the shim sets OSW_LOG_LEVEL before osw is imported, which is the
  only place that can, since importing any submodule imports the
  package first
- setdefault leaves a value the caller already chose alone
- a plain "import osw" still writes the notice, library behaviour
  is deliberately unchanged
- the wheel ships osw_entry.py via only-include plus sources
- the osw-mcp console script had no test of its own, only osw did
- it now runs as a subprocess and must leave stdout empty, which is
  the assertion that matters for a JSON-RPC transport
- the level the shim sets moved into _DEFAULT_LEVEL, and a test ties
  it to osw.DEFAULT_LOG_LEVEL so the two cannot drift apart
* fix(config): reject a hostless domain and a relative state_dir

- domain accepts a bare host or a full URL, so the check is on what
  _derive_domain yields rather than on the form of the value
- a hostless domain used to fail later in OswExpress.validate_domain,
  quoting a regex instead of naming OSW_DOMAIN
- state_dir "~/osw" created a directory literally named "~", because
  Ledger builds its path with Path() and never expands it
- a relative state_dir resolves against a working directory the MCP
  client chooses, so the ledger landed in an unpredictable place
- last open item from #143

* test(config): cover drive-relative state_dir and an unknown home

- reject "\osw-state" and "C:osw-state", non-absolute on both platforms
- assert the expanduser RuntimeError becomes a named config error
- state in the docs that a domain without a host is rejected
- text=True alone decodes with locale.getencoding(), which ignores
  Python's UTF-8 mode, so a child writing UTF-8 broke the reader
  thread with UnicodeDecodeError and returned the stream as None
- reproduced on Windows: byte 0x90 at position 1028 of osw --help
- the osw_entry tests now share a _DECODE mapping
- test_no_paths_on_mcp_surface gets the same kwargs as protection
@LukasGold
LukasGold merged commit 0455673 into main Sep 21, 2026
12 checks passed
@LukasGold
LukasGold deleted the feat/mcp-server branch September 21, 2026 15:21
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

3 participants