Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/tools/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,12 @@ form.
- `--verbose` / `-v` shows full tracebacks instead of a one-line message, and
adds the env-file line to the source report described under
[Where settings come from](configuration.md#where-settings-come-from).
- `--version` / `-V` prints one line with the osw version, the location of
the osw package and the Python version, then exits, needing no instance or
credentials.

`--help` / `-h` works on `osw` itself and after any subcommand or group, e.g.
`osw entity --help` or `osw entity get -h`, and prints that command's own
help.

Failures exit non-zero with a short message on stderr and no traceback.
7 changes: 7 additions & 0 deletions docs/tools/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ instance for its whole lifetime; there is no tool to switch at runtime.
`.env` file that entry names. Without it the server refuses to start rather than
register tools that would all fail.

`osw-mcp` also answers two flags from a shell, for checking an install rather
than for an MCP client to pass: `-h` / `--help` prints usage and exits, and
`-V` / `--version` prints one line with the osw version, the location of the
osw package and the Python version, then exits. Neither one starts the server
or needs an instance or credentials. Any other argument, including a
shortened form of these two flags, is rejected with a usage error.

## Quick install

For Claude Code, one command registers the server. Replace the domain and the
Expand Down
47 changes: 45 additions & 2 deletions src/osw/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,23 @@
from osw.service.params import json_value
from osw.service.registry import Operation, bind, iter_operations
from osw.service.streams import force_utf8
from osw.service.version import version_line
from osw.wtsite import SLOTS

from .render import render

app = typer.Typer(no_args_is_help=True, add_completion=False)
# help_option_names adds -h as an alias for --help. A child context created
# for a subcommand or a subgroup inherits it from its parent context when the
# child sets none of its own (click.Context.__init__), so this one setting
# covers the whole command tree, not just the root. --help comes first: click
# builds its "Try '... --help' for help." hint from help_option_names[0], and
# every existing usage error already names --help, so putting -h first would
# have changed all of them.
app = typer.Typer(
no_args_is_help=True,
add_completion=False,
context_settings={"help_option_names": ["--help", "-h"]},
)


def _force_utf8_output() -> None:
Expand All @@ -53,14 +65,43 @@ def _force_utf8_output() -> None:
stays exposed is the name the user typed, echoed back in a usage error --
an unknown command name or an unknown root option name. A name typed
after the command is fine, because click resolves the command, runs this
callback, and only then parses the command's own arguments.
callback, and only then parses the command's own arguments. --version /
-V also prints before this callback runs (its own callback is eager, like
--help), but it names no wiki content either, and forces UTF-8 on stdout
itself (see ``_version_callback``) rather than relying on this function.
"""
force_utf8(sys.stdout, sys.stderr)


def _version_callback(value: bool) -> None:
"""Eager callback for --version / -V: print the line and exit.

click processes an eager option's callback before a command's own
callback body runs, so this needs no configuration, no credential file
and no network -- the same reason ``--help`` works with none of those.
It also runs before ``_force_utf8_output``, so it forces stdout to UTF-8
itself: the line names the package's install directory, which a redirected
stdout on Windows would otherwise encode with the locale encoding, raising
``UnicodeEncodeError`` on a path outside it. ``osw-mcp -V`` does the same
(``osw.mcp.server.main``).
"""
if value:
force_utf8(sys.stdout)
typer.echo(version_line("osw"))
raise typer.Exit()


@app.callback()
def _callback(
ctx: typer.Context,
version: bool = typer.Option(
False,
"--version",
"-V",
help="Show the version and exit.",
is_eager=True,
callback=_version_callback,
),
instance: Optional[str] = typer.Option(
None,
"--instance",
Expand Down Expand Up @@ -113,6 +154,8 @@ def _callback(
"--read-only": "--read-only",
"--verbose": "--verbose",
"-v": "-v",
"--version": "--version",
"-V": "-V",
}


Expand Down
82 changes: 70 additions & 12 deletions src/osw/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@

from __future__ import annotations

import argparse
import atexit
import inspect
import io
import sys
from typing import Any, Optional, TextIO
from typing import Any, Optional, Sequence, TextIO

from mcp.server import MCPServer
from mcp.types import ToolAnnotations
Expand All @@ -23,6 +24,7 @@
from osw.service.context import Context, Policy
from osw.service.registry import Operation, bind, iter_operations
from osw.service.streams import force_utf8
from osw.service.version import version_line

INSTRUCTIONS = """\
This server is pinned to exactly one OpenSemanticLab (OSL) instance for its
Expand Down Expand Up @@ -114,9 +116,11 @@ def _build_server(report: Optional[TextIO] = None) -> tuple[MCPServer, Context]:
discarded.
"""
# Set before any shared-code logging runs, so every "[xxx] ..." message
# and wiki edit comment from shared code names this adapter. Also set as
# the first statement of main(), since main() prints on a start failure
# and this function is also callable on its own, e.g. from tests.
# and wiki edit comment from shared code names this adapter. main() sets
# it again before calling this function -- not as its first statement:
# force_utf8, argument parsing and the --version branch run before it
# there -- since main() prints on a start failure and this function is
# also callable on its own, e.g. from tests.
config.set_log_prefix("osw-mcp")
# Before get_settings(), so a misconfiguration that makes loading raise
# still reports which files were read. Into `report` rather than stderr:
Expand Down Expand Up @@ -155,20 +159,74 @@ def create_server() -> MCPServer:
return mcp


def main() -> None:
"""Console-script entry point: build the server and serve over stdio."""
# Before any write below. An MCP client starts this server with stderr on
# a pipe, so Python encodes it with the locale encoding, cp1252 on a
# German Windows system. The report holds the credential file path and the
# env file path, so a directory named "Muller" with an umlaut is enough to
# reach the client's log mangled. Reconfiguring in place also covers osw's
# own log handler, which holds this same stream object.
# Wrapped by hand, below 80 columns, with the URL on a line of its own.
# argparse's default formatter re-wraps a description with textwrap
# (break_on_hyphens=True), so at some terminal widths a line ends with
# ".../osw-" and the next starts with "python/blob/...", splitting the URL.
# RawDescriptionHelpFormatter (below) prints this text verbatim instead.
_DESCRIPTION = (
"Runs an MCP server for one OpenSemanticLab instance over stdio. An MCP\n"
"client starts it, not a person from a shell. Configuration comes from\n"
"environment variables, a .env file, or a credential file; see\n"
"https://github.com/OpenSemanticLab/osw-python/blob/main/docs/tools/mcp.md."
)


def _build_parser() -> argparse.ArgumentParser:
"""Build the argument parser for the ``osw-mcp`` console script.

Parsing happens before :func:`_build_server`, so ``-h``, ``-V`` and an
unrecognized argument never need credentials.
"""
parser = argparse.ArgumentParser(
prog="osw-mcp",
description=_DESCRIPTION,
formatter_class=argparse.RawDescriptionHelpFormatter,
# argparse accepts an unambiguous abbreviation by default (e.g.
# "--vers"); osw's own CLI (click) does not, so this is turned off to
# match: an argument that is not exactly one of the two below is
# rejected.
allow_abbrev=False,
)
parser.add_argument(
"-V",
"--version",
action="store_true",
help="show the version and exit",
)
return parser


def main(argv: Optional[Sequence[str]] = None) -> None:
"""Console-script entry point: build the server and serve over stdio.

``argv`` follows ``sys.argv[1:]``'s convention: ``None`` (the default)
reads the real command line, and a caller that wants to pass its own,
such as a test or ``python -m osw.mcp``, gives an explicit list instead.
"""
# Before any write below, including the usage error argparse prints for
# an unrecognized argument, which echoes it back. An MCP client starts
# this server with stderr on a pipe, so Python encodes it with the locale
# encoding, cp1252 on a German Windows system. The report holds the
# credential file path and the env file path, so a directory named
# "Muller" with an umlaut is enough to reach the client's log mangled.
# Reconfiguring in place also covers osw's own log handler, which holds
# this same stream object.
#
# stdout is deliberately left alone. The SDK's stdio_server re-wraps the
# binary buffer as UTF-8 itself, and claims file descriptor 1 while doing
# it, so the JSON-RPC channel does not depend on this and changing it here
# would only add a way to interfere.
force_utf8(sys.stderr)
args = _build_parser().parse_args(argv)

if args.version:
# No server runs on this path, so the reason above for leaving
# stdout alone does not apply.
force_utf8(sys.stdout)
print(version_line("osw-mcp"))
return

# See _build_server for why this is set here too.
config.set_log_prefix("osw-mcp")
report = io.StringIO()
Expand Down
28 changes: 28 additions & 0 deletions src/osw/service/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Version banner shared by the osw CLI and the osw-mcp server.

Neither adapter may import the other (see :mod:`osw.service.streams`), so the
one line both ``--version`` options print lives here instead of in either of
them. See :func:`version_line`.
"""

from __future__ import annotations

import os
import platform

import osw


def version_line(prog: str) -> str:
"""The one-line version banner ``prog`` prints for --version / -V.

``prog`` names the console script asking (``"osw"`` or ``"osw-mcp"``), so
the same installed package reports under whichever command the caller
actually ran. The remaining fields are the installed osw version, the
directory the osw package was loaded from, and the running Python
version, in that order.
"""
location = os.path.dirname(osw.__file__)
return (
f"{prog} {osw.__version__} from {location} (Python {platform.python_version()})"
)
Loading
Loading