Skip to content

Commit 41e0162

Browse files
authored
feat(cli,mcp): accept -h and -V/--version (#200)
* feat(cli,mcp): accept -h and -V/--version - osw: -h is an alias of --help on every command - osw and osw-mcp: -V/--version print one line and exit 0 - the line names the version, package location and Python version - neither flag needs configuration, credentials or network - osw-mcp rejects an unknown or abbreviated argument with exit 2 * test(cli): check -h on every command, not three sample paths - the commands are generated from the operation registry - walk the whole click tree and require exit 0 for each
1 parent d7f0116 commit 41e0162

8 files changed

Lines changed: 448 additions & 26 deletions

File tree

‎docs/tools/cli.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,12 @@ form.
7070
- `--verbose` / `-v` shows full tracebacks instead of a one-line message, and
7171
adds the env-file line to the source report described under
7272
[Where settings come from](configuration.md#where-settings-come-from).
73+
- `--version` / `-V` prints one line with the osw version, the location of
74+
the osw package and the Python version, then exits, needing no instance or
75+
credentials.
76+
77+
`--help` / `-h` works on `osw` itself and after any subcommand or group, e.g.
78+
`osw entity --help` or `osw entity get -h`, and prints that command's own
79+
help.
7380

7481
Failures exit non-zero with a short message on stderr and no traceback.

‎docs/tools/mcp.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ instance for its whole lifetime; there is no tool to switch at runtime.
1919
`.env` file that entry names. Without it the server refuses to start rather than
2020
register tools that would all fail.
2121

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

2431
For Claude Code, one command registers the server. Replace the domain and the

‎src/osw/cli/main.py‎

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,23 @@
3131
from osw.service.params import json_value
3232
from osw.service.registry import Operation, bind, iter_operations
3333
from osw.service.streams import force_utf8
34+
from osw.service.version import version_line
3435
from osw.wtsite import SLOTS
3536

3637
from .render import render
3738

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

4052

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

6075

76+
def _version_callback(value: bool) -> None:
77+
"""Eager callback for --version / -V: print the line and exit.
78+
79+
click processes an eager option's callback before a command's own
80+
callback body runs, so this needs no configuration, no credential file
81+
and no network -- the same reason ``--help`` works with none of those.
82+
It also runs before ``_force_utf8_output``, so it forces stdout to UTF-8
83+
itself: the line names the package's install directory, which a redirected
84+
stdout on Windows would otherwise encode with the locale encoding, raising
85+
``UnicodeEncodeError`` on a path outside it. ``osw-mcp -V`` does the same
86+
(``osw.mcp.server.main``).
87+
"""
88+
if value:
89+
force_utf8(sys.stdout)
90+
typer.echo(version_line("osw"))
91+
raise typer.Exit()
92+
93+
6194
@app.callback()
6295
def _callback(
6396
ctx: typer.Context,
97+
version: bool = typer.Option(
98+
False,
99+
"--version",
100+
"-V",
101+
help="Show the version and exit.",
102+
is_eager=True,
103+
callback=_version_callback,
104+
),
64105
instance: Optional[str] = typer.Option(
65106
None,
66107
"--instance",
@@ -113,6 +154,8 @@ def _callback(
113154
"--read-only": "--read-only",
114155
"--verbose": "--verbose",
115156
"-v": "-v",
157+
"--version": "--version",
158+
"-V": "-V",
116159
}
117160

118161

‎src/osw/mcp/server.py‎

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77

88
from __future__ import annotations
99

10+
import argparse
1011
import atexit
1112
import inspect
1213
import io
1314
import sys
14-
from typing import Any, Optional, TextIO
15+
from typing import Any, Optional, Sequence, TextIO
1516

1617
from mcp.server import MCPServer
1718
from mcp.types import ToolAnnotations
@@ -23,6 +24,7 @@
2324
from osw.service.context import Context, Policy
2425
from osw.service.registry import Operation, bind, iter_operations
2526
from osw.service.streams import force_utf8
27+
from osw.service.version import version_line
2628

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

157161

158-
def main() -> None:
159-
"""Console-script entry point: build the server and serve over stdio."""
160-
# Before any write below. An MCP client starts this server with stderr on
161-
# a pipe, so Python encodes it with the locale encoding, cp1252 on a
162-
# German Windows system. The report holds the credential file path and the
163-
# env file path, so a directory named "Muller" with an umlaut is enough to
164-
# reach the client's log mangled. Reconfiguring in place also covers osw's
165-
# own log handler, which holds this same stream object.
162+
# Wrapped by hand, below 80 columns, with the URL on a line of its own.
163+
# argparse's default formatter re-wraps a description with textwrap
164+
# (break_on_hyphens=True), so at some terminal widths a line ends with
165+
# ".../osw-" and the next starts with "python/blob/...", splitting the URL.
166+
# RawDescriptionHelpFormatter (below) prints this text verbatim instead.
167+
_DESCRIPTION = (
168+
"Runs an MCP server for one OpenSemanticLab instance over stdio. An MCP\n"
169+
"client starts it, not a person from a shell. Configuration comes from\n"
170+
"environment variables, a .env file, or a credential file; see\n"
171+
"https://github.com/OpenSemanticLab/osw-python/blob/main/docs/tools/mcp.md."
172+
)
173+
174+
175+
def _build_parser() -> argparse.ArgumentParser:
176+
"""Build the argument parser for the ``osw-mcp`` console script.
177+
178+
Parsing happens before :func:`_build_server`, so ``-h``, ``-V`` and an
179+
unrecognized argument never need credentials.
180+
"""
181+
parser = argparse.ArgumentParser(
182+
prog="osw-mcp",
183+
description=_DESCRIPTION,
184+
formatter_class=argparse.RawDescriptionHelpFormatter,
185+
# argparse accepts an unambiguous abbreviation by default (e.g.
186+
# "--vers"); osw's own CLI (click) does not, so this is turned off to
187+
# match: an argument that is not exactly one of the two below is
188+
# rejected.
189+
allow_abbrev=False,
190+
)
191+
parser.add_argument(
192+
"-V",
193+
"--version",
194+
action="store_true",
195+
help="show the version and exit",
196+
)
197+
return parser
198+
199+
200+
def main(argv: Optional[Sequence[str]] = None) -> None:
201+
"""Console-script entry point: build the server and serve over stdio.
202+
203+
``argv`` follows ``sys.argv[1:]``'s convention: ``None`` (the default)
204+
reads the real command line, and a caller that wants to pass its own,
205+
such as a test or ``python -m osw.mcp``, gives an explicit list instead.
206+
"""
207+
# Before any write below, including the usage error argparse prints for
208+
# an unrecognized argument, which echoes it back. An MCP client starts
209+
# this server with stderr on a pipe, so Python encodes it with the locale
210+
# encoding, cp1252 on a German Windows system. The report holds the
211+
# credential file path and the env file path, so a directory named
212+
# "Muller" with an umlaut is enough to reach the client's log mangled.
213+
# Reconfiguring in place also covers osw's own log handler, which holds
214+
# this same stream object.
166215
#
167216
# stdout is deliberately left alone. The SDK's stdio_server re-wraps the
168217
# binary buffer as UTF-8 itself, and claims file descriptor 1 while doing
169218
# it, so the JSON-RPC channel does not depend on this and changing it here
170219
# would only add a way to interfere.
171220
force_utf8(sys.stderr)
221+
args = _build_parser().parse_args(argv)
222+
223+
if args.version:
224+
# No server runs on this path, so the reason above for leaving
225+
# stdout alone does not apply.
226+
force_utf8(sys.stdout)
227+
print(version_line("osw-mcp"))
228+
return
229+
172230
# See _build_server for why this is set here too.
173231
config.set_log_prefix("osw-mcp")
174232
report = io.StringIO()

‎src/osw/service/version.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Version banner shared by the osw CLI and the osw-mcp server.
2+
3+
Neither adapter may import the other (see :mod:`osw.service.streams`), so the
4+
one line both ``--version`` options print lives here instead of in either of
5+
them. See :func:`version_line`.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import os
11+
import platform
12+
13+
import osw
14+
15+
16+
def version_line(prog: str) -> str:
17+
"""The one-line version banner ``prog`` prints for --version / -V.
18+
19+
``prog`` names the console script asking (``"osw"`` or ``"osw-mcp"``), so
20+
the same installed package reports under whichever command the caller
21+
actually ran. The remaining fields are the installed osw version, the
22+
directory the osw package was loaded from, and the running Python
23+
version, in that order.
24+
"""
25+
location = os.path.dirname(osw.__file__)
26+
return (
27+
f"{prog} {osw.__version__} from {location} (Python {platform.python_version()})"
28+
)

0 commit comments

Comments
 (0)