Repository navigation
Commit 0455673
authored
feat: an osw CLI and an MCP server for live OSL instances (#133)
* feat(mcp): add osw-mcp server exposing a live OSL instance
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.
* feat(mcp): port server to mcp 2.x and isolate the extra
- 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
* feat(mcp): authenticate from an osw credential file
- 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
* feat(mcp): select between multiple OSL instances at runtime
- 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
* refactor: extract SDK-free osw.service core from osw.mcp
- 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
* refactor(service): lift search tools into osw.service.ops
- 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
* refactor(service): lift remaining tool bodies into osw.service.ops
- 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
* feat(cli): add typer CLI assembled from the operation registry
- 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
* feat: remove filesystem paths from the MCP surface
- 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
* feat(mcp): pin one instance per server, wire annotations and meta
- 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
* refactor(mcp): drop tool closures, fold registration into server
- 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
* build: type-check src/osw/mcp instead of excluding it
- drop src/osw/mcp from [tool.ty.src] exclude
- silence only the two unresolvable SDK imports inline (issue #139)
* docs: document the osw CLI and unify the config reference
- 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
* fix(config): find .env from the CWD, report config sources
- CLI now searches upward from the working directory, not from the
installed package's directory (dotenv's default walks the call stack)
- MCP server searches nowhere: its CWD is chosen by the client
- both print the resolved .env and credential file to stderr at startup
- a missing credential file whose path holds a control character now
explains .env double-quote escape decoding
* docs: move CLI and MCP sections out of the README
- new docs page "CLI and MCP tools", added to the zensical nav
- README keeps a short pointer section, otherwise back to its old shape
- get-started extras table gains the osw[mcp] row
* docs: default MCP examples to a credential file in env
- server entries now set OSW_CRED_FILEPATH plus OSW_DOMAIN, no .env needed
- multi-instance example shares one credential file, one domain per server
- register via claude mcp add-json, which takes the entry verbatim
* feat(mcp): require an explicitly configured OSW_DOMAIN
- server no longer auto-selects a single-iri credential file; the CLI still does
- docs present the env block and the .env file as two supported styles
- multi-instance example shows one server of each style
- drop the "OSW_DOMAIN may be omitted" note
* docs: restructure the CLI and MCP guide
- add a Setup section with the uv/pip installs up front
- move Configuration below the MCP section, both adapters share it
- state where the .env is looked for first, drop the always-loaded claim
- note that --instance is optional and when it is required
- collect the rationale in a Design notes section at the end
* docs: simplify the CLI and MCP setup section
- lead with uv tool install, the mcp extra includes the base package
- fold the pip, uv add and uvx variants into a details element
- move the editable-install caveat to a Notes for developers section
- document running the server from a local checkout via uvx --from
* added local folders to .gitignore
* docs: correct why the CLI may infer an instance
- --instance is optional, so it is not what makes inference acceptable
- the CLI resolves per invocation and reports the instance it resolved
- state the --instance condition as OSW_DOMAIN unset, not .env-specific
* refactor: de-isolate the mcp extra from the dev environment
- drop the ty: ignore on the mcp SDK imports in server.py
- run the MCP tests unconditionally instead of importorskip-ing them
- update the config.py env-file hint (no more `test` group)
- docs: mcp is part of osw[all]; replace the anyio design note
* docs: drop remaining references to the separate MCP environment
- test docstrings no longer contrast against a plain dev env
- deptry comment no longer claims extras are absent from dev
- README lists osw[mcp] among the extras
* refactor(service): validate Settings with pydantic
- convert Settings from a frozen dataclass to a frozen pydantic model
- add validators for domain, sparql_endpoint, state_dir, cred_filepath
- constrain max_results/max_chars to positive integers via Field(gt=0)
- drop _int_env in favour of one ValidationError -> RuntimeError site
that still names the exact alias that was set
Closes #143
* test: stop test_init_from_env_vars leaking OSW_CRED_FILEPATH
- use monkeypatch.setenv so OSW_CRED_FILEPATH and OSW_DOMAIN are restored
- the test unlinks its credential file, so the leaked path pointed every
later test at a missing file
- surfaced by the mcp de-isolation: tests/integration/test_mcp_server.py
no longer skips for a missing SDK, so it hit the polluted environment
* test: do not assume the first ask-query hit carries jsondata
- SMW ask results have no defined order and Category:Item can hold pages without a jsondata slot
- scan all returned titles, require at least one with the slot
* fix(service): validate read_only via pydantic instead of truthy set
- route OSW_READ_ONLY through Settings so an unparseable value raises
- a typo like "ture" previously yielded False, silently enabling writes
- "y"/"t" now parse as true; all documented spellings keep working
- blank/whitespace-only still falls back to the default, as for the ints
- drop the now-unused _TRUTHY set
Follow-up to #143.
* fix(deps): make python-dotenv a base dependency
- the osw CLI is a base console script, but .env loading required osw[mcp]
- without dotenv the implicit .env search returned silently, so the CLI
reported missing credentials instead of the missing package
- drop the now redundant entry from the mcp extra
- update the docstrings and error message that named osw[mcp]
* feat(cli): find accounts.pwd.yaml in the working directory
- look for accounts.pwd.yaml in the CWD when no credential file is set
- skip the lookup when OSW_USERNAME or OSW_PASSWORD is configured
- ignore a discovered file with no entry for the configured domain
- report the credential file and where its path came from
- no parent-directory walk, and the MCP server never searches
* feat(cli): print one configuration line by default
- reduce the CLI banner to the credential source unless --verbose
- print the env-file line too when a command fails
- omit the MCP stdio hint from CLI error messages
- flush the banner so it survives a buffered stderr
* docs(mcp): add a quick install section for claude mcp add
- one claude mcp add command with the env block inline
- note on quoting osw[mcp] and on Windows paths
- scope flags, and how to list, inspect and remove the entry
* docs(cli): make the search help explain OSW-ID titles
- rename full_text_search to search_titles: it searches titles only
- drop "full-text" from the group help, module docstring and docs
- state that OSW pages are titled by OSW-ID, so names are not in titles
- add a wildcard label example to `search ask`, with the caveat that the
property name depends on the instance schema (not run against a wiki)
- document the --limit default and the {titles, count, truncated} shape
* feat(search): add a page-content search
- wiki_tools.content_search wraps the MediaWiki list=search API
- WtSite.content_search delegates to it, like prefix_search
- new operation search_content, CLI `osw search content`, MCP tool
- returns page titles in the same shape as the sibling searches
* docs(cli): rename `search text` to `search titles`
- `text` next to the new `content` implied it searched page text
- ground the name-property advice in Entity.json's @context mapping
- name the jsondata slot in the content-search caveat
- list `titles` and `content` in the command table
* docs(mcp): describe the credential lookup without the rationale
- replace the env-file prose with a CLI/server table
- state the credential-file order as steps, drop the reasons
- merge the duplicated credential setup into one section
- fix quick install: osw-mcp is the console script, not the server name
- correct the default `claude mcp add` scope to local
* feat(mcp): report config sources only when OSW_VERBOSE is set
- add Settings.verbose, read from OSW_VERBOSE or OSW_MCP_VERBOSE
- MCP server buffers the credential and env-file lines instead of
writing them to stderr at every start
- a failed start still prints both lines, verbose or not
- CLI output is unchanged
* test(mcp): cover main() on a successful start, quiet and verbose
* fix(service): flag truncation at the limit that reached the wiki
- 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
* docs(service): say what search_entities actually counts
* docs: reorganize CLI and MCP pages into a Tools section
- 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
* feat(cli): add instances status, fix the username status reports
- 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
* fix(service): print the running adapter's name in shared messages
- 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
* refactor(cli): rename osw search instances to osw search entities
- "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]
* test(logging): reset the osw logger in its fixture
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.
* refactor(service): log shared diagnostics instead of printing them
- 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
* build: declare click as a direct dependency
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.
* docs(service): correct the reason the config report flushes
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.
* test(cli): normalize typer's rich output before asserting
- 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
* fix(cli): write stdout and stderr as UTF-8, not the locale encoding
- 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
* feat(cli): suppress osw's import notice on the console scripts
- 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
* test(cli): cover the osw-mcp script and the shim's level choice
- 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): validate domain and state_dir (#195)
* 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
* test: decode subprocess output as UTF-8, not the locale codepage
- 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 protection1 parent e927526 commit 0455673
59 files changed
Lines changed: 9064 additions & 19 deletions
File tree
- docs
- tools
- src
- osw
- cli
- mcp
- service
- ops
- tests
- integration
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
69 | 69 | | |
70 | 70 | | |
71 | 71 | | |
72 | | - | |
73 | | - | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
25 | | - | |
| 25 | + | |
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
42 | 55 | | |
43 | 56 | | |
44 | 57 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| 33 | + | |
33 | 34 | | |
34 | 35 | | |
35 | 36 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
0 commit comments