Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Clarify that `scan --config ... --server ...` is static by default, does not execute launch arguments, and needs `--live --i-understand-live-risk` for per-server runtime analysis.

## [0.1.4] - 2026-06-12

### Security
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,26 @@ uv run mcts scan ./server.py --theme cyber # default
uv run mcts scan ./server.py --theme minimal --no-progress
```

### Config-based scan: static versus live

Selecting a server from an MCP client config is static by default. MCTS analyzes
the repository files but does not execute the selected entry's command or launch
arguments, so multiple config entries that point at the same source may receive
the same score:

```bash
mcts scan . --config ~/.cursor/mcp.json --server ifd-prod
```

Add live mode when the launch arguments or runtime-exposed schemas differ by
server. Live mode starts the selected process and therefore requires explicit
consent:

```bash
mcts scan . --config ~/.cursor/mcp.json --server ifd-prod \
--live --i-understand-live-risk
```

## Architecture

```
Expand Down
13 changes: 10 additions & 3 deletions docs/platform/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,13 @@ Valid **legacy** category keys: `permissions`, `injection`, `execution`, `data_l
| Flag | Default | Description |
|------|---------|-------------|
| `--languages` | `python,typescript` | Comma-separated static discovery backends |
| `--live` | false | Connect to live stdio MCP server |
| `--live` | false | Execute and probe a live stdio MCP server; with `--config`, use its command and args |
| `--url` | — | Remote MCP URL (streamable HTTP or SSE); implies live |
| `--transport` | `streamable-http` | Remote transport: `streamable-http` or `sse` |
| `--command` | — | Custom launch binary for live mode |
| `--args` | — | Comma-separated args for `--command` |
| `--config` | — | MCP client config JSON path (JSON5/comments supported) |
| `--server` | — | Server name inside `mcpServers` (requires `--config`) |
| `--config` | — | MCP client config JSON path (JSON5/comments supported); static mode does not execute launch args |
| `--server` | — | Server name inside `mcpServers` (requires `--config`); add `--live` for runtime analysis |
| `--expand-vars` | `auto` | Expand `$VAR` / `%VAR%` in config commands: `auto`, `linux`, `mac`, `windows`, `off` |
| `--snapshot` | — | Static JSON snapshot (`tools/list` export); no live connection |
| `--surfaces` | all four | Comma-separated: `tool`, `prompt`, `resource`, `instruction` |
Expand All @@ -130,6 +130,13 @@ Valid **legacy** category keys: `permissions`, `injection`, `execution`, `data_l
| `--i-understand-live-risk` | false | Consent for live/remote probe (or `MCTS_LIVE_OK=1`) |
| `--stderr-file` | — | Capture live server stderr to file |

With `--config` and `--server` but without `--live`, MCTS uses the config as
metadata and scans the target files. It does not start the configured command or
interpret argument-dependent behavior. Config entries that point at the same
source can therefore produce identical static scores. Add `--live` and
`--i-understand-live-risk` to execute the selected entry and inspect its runtime
MCP surfaces.

### Remote auth flags

| Flag | Description |
Expand Down
18 changes: 15 additions & 3 deletions src/mcts/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,13 @@ def scan(
] = "json",
live: Annotated[
bool,
typer.Option("--live", help="Connect to a live stdio MCP server (requires consent)"),
typer.Option(
"--live",
help=(
"Execute and probe a live stdio MCP server; with --config, uses its "
"command and args (requires consent)"
),
),
] = False,
command: Annotated[
str | None,
Expand All @@ -286,11 +292,17 @@ def scan(
] = None,
config: Annotated[
Path | None,
typer.Option("--config", help="MCP client config JSON (Cursor, Claude, VS Code)"),
typer.Option(
"--config",
help=("MCP client config JSON; static mode reads metadata only and does not execute launch args"),
),
] = None,
server: Annotated[
str | None,
typer.Option("--server", help="Server name inside --config mcpServers"),
typer.Option(
"--server",
help="Server name inside --config; add --live for per-server runtime analysis",
),
] = None,
understand_live_risk: Annotated[
bool,
Expand Down
65 changes: 65 additions & 0 deletions tests/test_cli_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

from __future__ import annotations

import json
from pathlib import Path

from typer.core import TyperGroup, TyperOption
from typer.main import get_command
from typer.testing import CliRunner

from mcts.cli.main import app
Expand Down Expand Up @@ -74,6 +77,68 @@ def test_scan_scoring_both_prints_v2_summary(example_server_path: Path, tmp_path
assert "absolute_risk" in result.stdout.lower() or "Absolute Risk" in result.stdout


def test_scan_help_explains_config_static_vs_live() -> None:
root_command = get_command(app)
assert isinstance(root_command, TyperGroup)
scan_command = root_command.commands["scan"]
help_by_name = {param.name: param.help for param in scan_command.params if isinstance(param, TyperOption)}

assert help_by_name["config"] == (
"MCP client config JSON; static mode reads metadata only and does not execute launch args"
)
assert help_by_name["server"] == (
"Server name inside --config; add --live for per-server runtime analysis"
)
assert help_by_name["live"] == (
"Execute and probe a live stdio MCP server; with --config, "
"uses its command and args (requires consent)"
)


def test_config_static_scan_warns_in_console_and_json(tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
config = tmp_path / ".mcp.json"
config.write_text(
json.dumps(
{
"mcpServers": {
"prod": {
"command": "definitely-not-a-real-command",
"args": ["--sso-env", "prod"],
}
}
}
)
)
(tmp_path / "app.py").write_text("x = 1\n")
output_path = tmp_path / "scan-report.json"

result = runner.invoke(
app,
[
"scan",
str(tmp_path),
"--config",
str(config),
"--server",
"prod",
"--no-progress",
"--output",
str(output_path),
],
)
console_output = " ".join(result.stdout.split())

assert result.exit_code == 0, result.stdout
assert "did not execute the server command or args" in console_output
assert "All config servers may share the same score until --live is used" in console_output

payload = json.loads(output_path.read_text())
scan_notes = " ".join(payload["scan_notes"])
assert "did not execute the server command or args" in scan_notes
assert "server=prod" in scan_notes


def test_report_valid_json(tmp_path: Path) -> None:
report_path = tmp_path / "report.json"
report_path.write_text(_minimal_report().model_dump_json())
Expand Down
Loading