Skip to content

Commit 2b66474

Browse files
committed
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
1 parent 4e70ab9 commit 2b66474

7 files changed

Lines changed: 112 additions & 16 deletions

File tree

‎docs/cli-and-mcp.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,15 @@ and `credentials` when none was:
271271
- `OSW_USERNAME/OSW_PASSWORD (from the env file)`
272272
- `not configured (set OSW_CRED_FILEPATH, or OSW_USERNAME/OSW_PASSWORD)`
273273

274-
The second line is labelled `env file`. The MCP server prints both lines. The
275-
CLI prints the first only, adding the second with `--verbose` or when the
276-
command fails:
274+
The second line is labelled `env file`. Which lines appear depends on the
275+
adapter:
276+
277+
- **CLI**: the first line only. `--verbose`, or a command that fails, adds
278+
the second.
279+
- **MCP server**: neither, since its sources are fixed in the server entry.
280+
`OSW_VERBOSE=true` prints both, and a failed start prints both regardless.
281+
282+
A verbose run prints:
277283

278284
```text
279285
[osw] credential file: /home/me/project/accounts.pwd.yaml (accounts.pwd.yaml found in the working directory)
@@ -287,7 +293,7 @@ process only, and never written back to disk. Set either `OSW_USERNAME` and
287293
`OSW_PASSWORD`, or `OSW_CRED_FILEPATH`.
288294

289295
A credential file uses the YAML format osw's `CredentialManager` reads, keyed
290-
by iri:
296+
by iri (default file name: `accounts.pwd.yaml`):
291297

292298
```yaml
293299
wiki-dev.open-semantic-lab.org:
@@ -317,6 +323,7 @@ set wins:
317323
| `OSW_STATE_DIR` | `OSW_MCP_STATE_DIR` | Where the provenance ledger is kept |
318324
| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) |
319325
| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) |
326+
| `OSW_VERBOSE` | `OSW_MCP_VERBOSE` | `true` prints the configuration source report |
320327

321328
### Windows paths in a `.env` file
322329

‎src/osw/mcp/server.py‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99

1010
import atexit
1111
import inspect
12+
import io
1213
import sys
13-
from typing import Any, Optional
14+
from typing import Any, Optional, TextIO
1415

1516
from mcp.server import MCPServer
1617
from mcp.types import ToolAnnotations
@@ -95,7 +96,7 @@ def tool_kwargs(op: Operation, settings: Settings) -> dict[str, Any]:
9596
}
9697

9798

98-
def _build_server() -> tuple[MCPServer, Context]:
99+
def _build_server(report: Optional[TextIO] = None) -> tuple[MCPServer, Context]:
99100
"""Build the MCPServer and the Context its tools are bound to.
100101
101102
Loads and validates settings first so a missing-credential misconfiguration
@@ -106,11 +107,16 @@ def _build_server() -> tuple[MCPServer, Context]:
106107
Deliberately stricter than :func:`config.get_active_domain`, which the CLI
107108
uses: there the instance is resolved per invocation and reported at
108109
startup, and ``--instance`` can override it per command.
110+
111+
``report`` receives the configuration source lines instead of stderr, so
112+
the caller decides whether to show them. With no ``report`` they are
113+
discarded.
109114
"""
110115
# Before get_settings(), so a misconfiguration that makes loading raise
111-
# still reports which files were read. stderr, so it lands in the MCP
112-
# client's server log without touching the JSON-RPC stream on stdout.
113-
config.log_config_sources()
116+
# still reports which files were read. Into `report` rather than stderr:
117+
# these lines repeat the client's own server entry, so main() shows them
118+
# only when asked or when the start fails.
119+
config.log_config_sources(stream=report if report is not None else io.StringIO())
114120
settings = config.get_settings()
115121
domain = settings.domain
116122
if domain is None:
@@ -145,12 +151,20 @@ def create_server() -> MCPServer:
145151

146152
def main() -> None:
147153
"""Console-script entry point: build the server and serve over stdio."""
154+
report = io.StringIO()
148155
try:
149-
mcp, ctx = _build_server()
156+
mcp, ctx = _build_server(report)
150157
except Exception as exc:
151-
print(f"[osw-mcp] failed to start: {exc}", file=sys.stderr)
158+
# A failed start is when the configuration sources matter most, so
159+
# they are printed even with OSW_VERBOSE unset.
160+
sys.stderr.write(report.getvalue())
161+
print(f"[osw-mcp] failed to start: {exc}", file=sys.stderr, flush=True)
152162
raise SystemExit(1) from exc
153163

164+
if config.get_settings().verbose:
165+
sys.stderr.write(report.getvalue())
166+
sys.stderr.flush()
167+
154168
atexit.register(ctx.close)
155169
try:
156170
mcp.run(transport="stdio")

‎src/osw/service/config.py‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
ENV_MAX_RESULTS = ("OSW_MAX_RESULTS", "OSW_MCP_MAX_RESULTS")
4040
ENV_MAX_CHARS = ("OSW_MAX_CHARS", "OSW_MCP_MAX_CHARS")
4141
ENV_FILE = ("OSW_ENV_FILE", "OSW_MCP_ENV_FILE")
42+
ENV_VERBOSE = ("OSW_VERBOSE", "OSW_MCP_VERBOSE")
4243

4344

4445
def _first_env(names: tuple[str, ...]) -> Optional[str]:
@@ -62,6 +63,7 @@ def _first_env(names: tuple[str, ...]) -> Optional[str]:
6263
"state_dir": ENV_STATE_DIR,
6364
"max_results": ENV_MAX_RESULTS,
6465
"max_chars": ENV_MAX_CHARS,
66+
"verbose": ENV_VERBOSE,
6567
}
6668

6769

@@ -100,6 +102,9 @@ class Settings(BaseModel):
100102
state_dir: Optional[str] = None
101103
max_results: int = Field(default=100, gt=0)
102104
max_chars: int = Field(default=100_000, gt=0)
105+
# Only controls the startup configuration report. No tool or command
106+
# reads it, and Settings.redacted() deliberately does not expose it.
107+
verbose: bool = False
103108

104109
@field_validator("domain")
105110
@classmethod
@@ -440,14 +445,16 @@ def log_config_sources(stream=None, verbose: bool = True) -> None:
440445
the CLI's failure path, which prints the env-file line (via
441446
:func:`log_env_file_source`) after this one has already run.
442447
443-
``verbose`` defaults to ``True``, which keeps both lines and matches the
444-
MCP server's behaviour (it never passes a value here). The CLI passes
448+
``verbose`` defaults to ``True``, which keeps both lines. The CLI passes
445449
``verbose=False`` for a successful, non-verbose command, and prints the
446450
env-file line separately (via :func:`log_env_file_source`) on failure or
447-
when the user passed ``--verbose``.
451+
when the user passed ``--verbose``. The MCP server writes both lines to a
452+
buffer instead of stderr and shows them only when ``OSW_VERBOSE`` is set
453+
or the server fails to start.
448454
449-
Always writes to ``stderr``: under MCP ``stdout`` carries the JSON-RPC
450-
stream, and under ``osw --json`` it carries the result payload.
455+
Writes to ``stderr``, or to ``stream`` when one is given, and never to
456+
``stdout``: under MCP ``stdout`` carries the JSON-RPC stream, and under
457+
``osw --json`` it carries the result payload.
451458
"""
452459
_load_env_file()
453460
# Every print below flushes: stderr is block-buffered whenever it is not a
@@ -638,6 +645,9 @@ def load(strict: bool = True) -> Settings:
638645
max_chars_raw = _first_env(ENV_MAX_CHARS)
639646
if max_chars_raw is not None and max_chars_raw.strip():
640647
kwargs["max_chars"] = max_chars_raw
648+
verbose_raw = _first_env(ENV_VERBOSE)
649+
if verbose_raw is not None and verbose_raw.strip():
650+
kwargs["verbose"] = verbose_raw
641651

642652
try:
643653
return Settings(**kwargs)

‎tests/test_cli.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
"OSW_MCP_MAX_CHARS",
4646
"OSW_ENV_FILE",
4747
"OSW_MCP_ENV_FILE",
48+
"OSW_VERBOSE",
49+
"OSW_MCP_VERBOSE",
4850
]
4951

5052

‎tests/test_mcp_server.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from __future__ import annotations
1111

1212
import asyncio
13+
import io
1314

1415
import pytest
1516
import yaml
@@ -31,6 +32,8 @@
3132
"OSW_READ_ONLY",
3233
"OSW_MCP_READ_ONLY",
3334
"OSW_MCP_ENV_FILE",
35+
"OSW_VERBOSE",
36+
"OSW_MCP_VERBOSE",
3437
]
3538

3639

@@ -156,3 +159,39 @@ def test_create_server_does_not_auto_select_a_single_iri(monkeypatch, tmp_path):
156159

157160
with pytest.raises(RuntimeError, match="No OSL instance configured"):
158161
server.create_server()
162+
163+
164+
def test_build_server_is_quiet_by_default(monkeypatch, capsys):
165+
# The configuration source lines repeat what the MCP client's server entry
166+
# already says, so a successful start says nothing without OSW_VERBOSE.
167+
_configure(monkeypatch)
168+
169+
server.create_server()
170+
171+
assert "[osw]" not in capsys.readouterr().err
172+
173+
174+
def test_build_server_writes_the_report_into_the_given_buffer(monkeypatch, capsys):
175+
_configure(monkeypatch)
176+
buf = io.StringIO()
177+
178+
_mcp, ctx = server._build_server(buf)
179+
ctx.close()
180+
181+
assert "[osw] credentials" in buf.getvalue()
182+
assert capsys.readouterr().err == ""
183+
184+
185+
def test_main_prints_the_report_when_startup_fails(monkeypatch, tmp_path, capsys):
186+
# No OSW_DOMAIN: _build_server raises, and that is exactly when the
187+
# configuration sources have to be visible, OSW_VERBOSE or not.
188+
cred_file = _write_cred_file(tmp_path, ["wiki-a.example.org", "wiki-b.example.org"])
189+
monkeypatch.setenv("OSW_MCP_CRED_FILEPATH", str(cred_file))
190+
config.reset()
191+
192+
with pytest.raises(SystemExit):
193+
server.main()
194+
195+
err = capsys.readouterr().err
196+
assert "[osw] " in err
197+
assert "failed to start" in err

‎tests/test_service_config.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
"OSW_MCP_MAX_CHARS",
3232
"OSW_ENV_FILE",
3333
"OSW_MCP_ENV_FILE",
34+
"OSW_VERBOSE",
35+
"OSW_MCP_VERBOSE",
3436
]
3537

3638

@@ -1012,6 +1014,26 @@ def test_blank_read_only_falls_back_to_default(monkeypatch):
10121014
assert config.load().read_only is False
10131015

10141016

1017+
def test_verbose_defaults_to_false_and_is_set_by_the_env_variable(monkeypatch):
1018+
monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
1019+
monkeypatch.setenv("OSW_USERNAME", "alice")
1020+
monkeypatch.setenv("OSW_PASSWORD", "secret")
1021+
assert config.load().verbose is False
1022+
1023+
monkeypatch.setenv("OSW_VERBOSE", "true")
1024+
assert config.load().verbose is True
1025+
1026+
1027+
def test_misspelled_verbose_raises(monkeypatch):
1028+
monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
1029+
monkeypatch.setenv("OSW_USERNAME", "alice")
1030+
monkeypatch.setenv("OSW_PASSWORD", "secret")
1031+
monkeypatch.setenv("OSW_VERBOSE", "ture")
1032+
with pytest.raises(RuntimeError) as exc:
1033+
config.load()
1034+
assert "OSW_VERBOSE" in str(exc.value)
1035+
1036+
10151037
def test_read_only_error_names_the_alias_that_was_set(monkeypatch):
10161038
monkeypatch.setenv("OSW_DOMAIN", "wiki.example.org")
10171039
monkeypatch.setenv("OSW_USERNAME", "alice")

‎tests/test_service_instances.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
"OSW_MCP_MAX_CHARS",
2929
"OSW_ENV_FILE",
3030
"OSW_MCP_ENV_FILE",
31+
"OSW_VERBOSE",
32+
"OSW_MCP_VERBOSE",
3133
]
3234

3335

0 commit comments

Comments
 (0)