mcpgawk 0.1.3__tar.gz → 0.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/CHANGELOG.md +21 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/PKG-INFO +1 -1
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/CHANGELOG.md +6 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/package.json +1 -1
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/pyproject.toml +2 -1
- mcpgawk-0.1.4/src/mcpgawk/__init__.py +26 -0
- mcpgawk-0.1.4/src/mcpgawk/ambient.py +120 -0
- mcpgawk-0.1.4/src/mcpgawk/cli.py +317 -0
- mcpgawk-0.1.4/src/mcpgawk/consent.py +75 -0
- mcpgawk-0.1.4/src/mcpgawk/discover.py +352 -0
- mcpgawk-0.1.4/src/mcpgawk/drift.py +145 -0
- mcpgawk-0.1.4/src/mcpgawk/fleet.py +293 -0
- mcpgawk-0.1.4/src/mcpgawk/grade.py +132 -0
- mcpgawk-0.1.4/src/mcpgawk/history.py +118 -0
- mcpgawk-0.1.4/src/mcpgawk/label.py +419 -0
- mcpgawk-0.1.4/src/mcpgawk/mcp_server.py +179 -0
- mcpgawk-0.1.4/src/mcpgawk/measure.py +173 -0
- mcpgawk-0.1.4/src/mcpgawk/oauth_login.py +131 -0
- mcpgawk-0.1.4/src/mcpgawk/probe.py +361 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/src/mcpgawk/signals.py +69 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/src/mcpgawk/supplychain.py +5 -5
- mcpgawk-0.1.4/src/mcpgawk/transport.py +112 -0
- mcpgawk-0.1.4/tests/test_ambient.py +147 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_cli_integration.py +4 -2
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_drift.py +3 -2
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_grade.py +2 -2
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_label_axes.py +12 -1
- mcpgawk-0.1.4/tests/test_layer_invariants.py +85 -0
- mcpgawk-0.1.4/tests/test_mcp_server.py +205 -0
- mcpgawk-0.1.4/tests/test_measure.py +122 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_signals.py +50 -1
- mcpgawk-0.1.4/tests/test_thin_guidance.py +162 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/uv.lock +1 -1
- mcpgawk-0.1.3/src/mcpgawk/__init__.py +0 -16
- mcpgawk-0.1.3/src/mcpgawk/cli.py +0 -145
- mcpgawk-0.1.3/src/mcpgawk/drift.py +0 -84
- mcpgawk-0.1.3/src/mcpgawk/grade.py +0 -75
- mcpgawk-0.1.3/src/mcpgawk/history.py +0 -49
- mcpgawk-0.1.3/src/mcpgawk/label.py +0 -158
- mcpgawk-0.1.3/src/mcpgawk/measure.py +0 -105
- mcpgawk-0.1.3/src/mcpgawk/probe.py +0 -129
- mcpgawk-0.1.3/tests/test_measure.py +0 -63
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/.github/workflows/ci.yml +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/.github/workflows/mcpgawk-gate-selftest.yml +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/.github/workflows/publish-openvsx.yml +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/.github/workflows/publish-pypi.yml +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/.gitignore +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/BRAND.md +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/CONTRIBUTING.md +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/LICENSE +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/NOTICE +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/README.md +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/SECURITY.md +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/THREAT-MODEL.md +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/action.yml +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/demo.gif +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/favicon.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/identity-board.html +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/nativerse-mark.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/social-preview.png +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/social-preview.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/wordmark-dark.png +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/wordmark-dark.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/wordmark-light.png +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/wordmark-light.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/.gitignore +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/CNAME +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/api/lead.js +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/brand/favicon.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/brand/nativerse-mark.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/brand/social-preview.png +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/brand/social-preview.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/alibaba.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/anthropic.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/baidu.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/cerebras.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/cloudflare.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/cohere.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/copilot.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/cursor.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/deepseek.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/discord.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/docker.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/figma.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/framer.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/gemini.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/github.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/gitlab.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/gojek.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/google.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/grab.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/grok.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/huawei.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/huggingface.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/inflection.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/infosys.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/kakaotalk.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/kimi.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/klarna.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/langchain.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/line.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/linear.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/mercadolibre.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/meta.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/microsoft.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/midjourney.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/mistral.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/naver.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/netlify.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/notion.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/nubank.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/nvidia.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/ollama.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/openai.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/paytm.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/perplexity.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/postgresql.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/python.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/qwen.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/rakuten.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/replicate.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/replit.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/revolut.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/runway.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/sap.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/sentry.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/shopee.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/slack.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/sony.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/spotify.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/stability.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/stripe.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/suno.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/supabase.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/swiggy.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/tiktok.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/togetherai.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/vercel.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/wechat.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/weibo.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/wise.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/xai.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/xiaomi.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/zoho.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/zomato.svg +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/index.html +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/logofield.js +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/report.html +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/styles.css +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/.vscodeignore +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/LICENSE +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/README.md +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/extension.js +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/media/icon.png +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/examples/mcp.json +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/scripts/ci_gate.py +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/src/mcpgawk/__main__.py +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/src/mcpgawk/oauth_scopes.py +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/src/mcpgawk/servercard.py +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/supabase/leads.sql +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/supabase/lockdown.sql +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_no_egress.py +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_oauth_scopes.py +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_servercard.py +0 -0
- {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_supplychain.py +0 -0
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to mcpgawk. Format: [Keep a Changelog](https://keepachangelog.com/); versioning: [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [0.1.4] — 2026-07-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `--login`: scan a remote MCP server that requires OAuth. Opens the browser, signs in once via the
|
|
9
|
+
server's own OAuth flow, and scans it — the token is stored locally (`~/.gawk/oauth`) and never
|
|
10
|
+
leaves your machine.
|
|
11
|
+
- Dynamic tool-dispatch detection: flags servers that hide a larger real tool catalog behind a
|
|
12
|
+
meta-tool (the Sentry / Docker mcp-gateway shape). A passive scan structurally can't see the hidden
|
|
13
|
+
tools, so this says "this scan is incomplete" rather than letting a clean-looking result be mistaken
|
|
14
|
+
for a clean server.
|
|
15
|
+
|
|
16
|
+
### Changed / Fixed
|
|
17
|
+
- A probe that errors (unreachable host, wrong URL, an HTML docs page that isn't an MCP endpoint,
|
|
18
|
+
a timeout) can no longer render as CLEAN — failures are now typed, not inferred from message text.
|
|
19
|
+
- Remote (`--http`/`--sse`) scans fail fast (~20s) instead of hanging up to 90s on a non-MCP URL;
|
|
20
|
+
local stdio servers keep the generous cold-start budget.
|
|
21
|
+
- Heuristic signals are labelled by what they are — dynamic-dispatch, tool-name shadowing and
|
|
22
|
+
server-card mismatch are no longer all reported as "possible prompt-injection".
|
|
23
|
+
- Version is single-sourced from the installed package metadata (no more hand-maintained literal that
|
|
24
|
+
could go stale).
|
|
25
|
+
|
|
5
26
|
## [0.1.3] — 2026-07-12
|
|
6
27
|
|
|
7
28
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcpgawk
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A local-first CLI that measures what an MCP server costs and what it can do. Nothing leaves your machine.
|
|
5
5
|
Project-URL: Homepage, https://mcp.gawk.dev
|
|
6
6
|
Project-URL: Documentation, https://mcp.gawk.dev
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## 0.1.4
|
|
2
|
+
- Version sync with CLI 0.1.4 (`--login` OAuth scanning, dynamic tool-dispatch detection, typed
|
|
3
|
+
error handling so a failed probe never reads CLEAN, faster remote-scan timeout, corrected signal
|
|
4
|
+
labels). No extension code change — the panel reads `tool_count`, `cost_index_tokens`,
|
|
5
|
+
`tools[].write/exfil_capable`, and `bounded_signals`, all unchanged additive fields in the label schema.
|
|
6
|
+
|
|
1
7
|
## 0.1.3
|
|
2
8
|
- Version sync with CLI 0.1.3 (5-axis report, `--verbose`, opt-in `--supply-chain`/`--oauth-scopes`). No
|
|
3
9
|
extension code change — the panel reads `tool_count`, `cost_index_tokens`, `tools[].write/exfil_capable`,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "mcpgawk",
|
|
3
3
|
"displayName": "mcpgawk — MCP cost & hygiene",
|
|
4
4
|
"description": "See what your MCP servers cost and expose, right in your editor. Local — nothing leaves your machine.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.4",
|
|
6
6
|
"publisher": "gawk-dev",
|
|
7
7
|
"license": "Apache-2.0",
|
|
8
8
|
"icon": "media/icon.png",
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mcpgawk"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "A local-first CLI that measures what an MCP server costs and what it can do. Nothing leaves your machine."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -37,6 +37,7 @@ Issues = "https://github.com/gawk-dev/mcpgawk/issues"
|
|
|
37
37
|
|
|
38
38
|
[project.scripts]
|
|
39
39
|
mcpgawk = "mcpgawk.cli:main"
|
|
40
|
+
mcpgawk-mcp = "mcpgawk.mcp_server:main"
|
|
40
41
|
|
|
41
42
|
[tool.hatch.build.targets.wheel]
|
|
42
43
|
packages = ["src/mcpgawk"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""mcpgawk — local-first MCP measurement.
|
|
2
|
+
|
|
3
|
+
gawk at an MCP server before you trust it: measure what it costs and exposes,
|
|
4
|
+
without the server's inventory ever leaving your machine.
|
|
5
|
+
|
|
6
|
+
Pipeline: Observe (probe) -> Bound (measure) -> Attest (label).
|
|
7
|
+
"""
|
|
8
|
+
from .probe import ServerSnapshot, probe_stdio, probe_http, probe_sse, probe_url
|
|
9
|
+
from .measure import Measurement, measure
|
|
10
|
+
from .label import build_label
|
|
11
|
+
|
|
12
|
+
# Single source of truth: the version is whatever the installed package metadata says (which comes
|
|
13
|
+
# from `[project] version` in pyproject.toml at build time). No hand-maintained literal to go stale
|
|
14
|
+
# — the prior `__version__ = "0.1.0"` disagreed with the published 0.1.3 and with pyproject. In a
|
|
15
|
+
# raw source tree with no install at all, metadata is absent; report an honest non-version sentinel
|
|
16
|
+
# rather than assert a number that could be wrong.
|
|
17
|
+
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
|
18
|
+
try:
|
|
19
|
+
__version__ = _pkg_version("mcpgawk")
|
|
20
|
+
except PackageNotFoundError: # running from source with no (editable) install
|
|
21
|
+
__version__ = "0+unknown"
|
|
22
|
+
del _pkg_version, PackageNotFoundError
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ServerSnapshot", "probe_stdio", "probe_http", "probe_sse", "probe_url",
|
|
25
|
+
"Measurement", "measure", "build_label", "__version__",
|
|
26
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""AMBIENT CREDENTIALS — what a launched server inherits that nobody declared.
|
|
2
|
+
|
|
3
|
+
Zero egress, zero execution: this only asks whether files EXIST and what environment variables are
|
|
4
|
+
NAMED. It never opens a credential file, never reads a value, and never reports one. That is not
|
|
5
|
+
politeness — a scanner that reads your secrets to tell you they exist has become the risk it
|
|
6
|
+
describes.
|
|
7
|
+
|
|
8
|
+
WHY THIS EXISTS. A config entry declares what a server needs: `env: { SOME_API_KEY: ... }`, and
|
|
9
|
+
the report shows exactly that, values hidden. It reads like the server's credential surface. It is
|
|
10
|
+
not. When the server is actually launched it inherits the whole environment plus every credential
|
|
11
|
+
file the user can read. On a typical developer machine that includes package-registry tokens with
|
|
12
|
+
publish rights — so an MCP server becomes a supply-chain path into whatever that developer ships.
|
|
13
|
+
|
|
14
|
+
Two mechanisms, both real:
|
|
15
|
+
* the launcher hands the child its environment (this scanner does exactly that in probe.py, and so
|
|
16
|
+
does every MCP client that spawns a stdio server);
|
|
17
|
+
* launchers read their own credential files — `npx -y whatever` consults ~/.npmrc regardless of
|
|
18
|
+
what any MCP config says.
|
|
19
|
+
|
|
20
|
+
So the DECLARED blast radius and the ACTUAL one are different numbers, and only the smaller one was
|
|
21
|
+
ever reported. This module reports the gap.
|
|
22
|
+
|
|
23
|
+
It is a FACT layer: presence and name only, no judgement, no score. Whether it matters depends on
|
|
24
|
+
what the servers can do — which measure.py already knows — so the two are combined at the report
|
|
25
|
+
level, never here.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
#: Credential files a launched process can read, and what holding one lets someone do. The wording
|
|
35
|
+
#: is the CONSEQUENCE, not the file's name — "a token" means nothing to a reader deciding whether to
|
|
36
|
+
#: care, whereas "publish packages as you" does.
|
|
37
|
+
CREDENTIAL_FILES: list[tuple[str, str]] = [
|
|
38
|
+
(".npmrc", "publish npm packages as you"),
|
|
39
|
+
(".pypirc", "publish Python packages as you"),
|
|
40
|
+
(".netrc", "authenticate to hosts as you"),
|
|
41
|
+
(".git-credentials", "push to your git remotes"),
|
|
42
|
+
(".config/gh/hosts.yml", "act on GitHub as you"),
|
|
43
|
+
(".docker/config.json", "push images to your registries"),
|
|
44
|
+
(".aws/credentials", "use your AWS account"),
|
|
45
|
+
(".kube/config", "control your Kubernetes clusters"),
|
|
46
|
+
(".ssh/id_rsa", "log in wherever that key is trusted"),
|
|
47
|
+
(".ssh/id_ed25519", "log in wherever that key is trusted"),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
#: Environment variable NAMES that look like credentials. Names only — the value is never touched.
|
|
51
|
+
_CREDENTIAL_NAME = re.compile(
|
|
52
|
+
r"(^|_)(token|secret|password|passwd|apikey|api_key|access_key|private_key|credential)s?($|_)",
|
|
53
|
+
re.IGNORECASE)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class AmbientExposure:
|
|
58
|
+
"""What a stdio server launched from here would inherit."""
|
|
59
|
+
files: list[tuple[str, str]] = field(default_factory=list) # (path shown as ~/…, consequence)
|
|
60
|
+
env_names: list[str] = field(default_factory=list) # NAMES only, never values
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def count(self) -> int:
|
|
64
|
+
return len(self.files) + len(self.env_names)
|
|
65
|
+
|
|
66
|
+
def as_dict(self) -> dict:
|
|
67
|
+
return {
|
|
68
|
+
"files": [{"path": p, "grants": g} for p, g in self.files],
|
|
69
|
+
"env_names": list(self.env_names),
|
|
70
|
+
"count": self.count,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def detect_ambient(home: Path | None = None, environ: dict[str, str] | None = None) -> AmbientExposure:
|
|
75
|
+
"""Enumerate inheritable credential sources. Pure and injectable, so it is testable without
|
|
76
|
+
touching the real home directory of whoever runs the tests."""
|
|
77
|
+
home = home or Path.home()
|
|
78
|
+
environ = os.environ if environ is None else environ
|
|
79
|
+
|
|
80
|
+
files: list[tuple[str, str]] = []
|
|
81
|
+
for rel, grants in CREDENTIAL_FILES:
|
|
82
|
+
path = home / rel
|
|
83
|
+
try:
|
|
84
|
+
if path.is_file():
|
|
85
|
+
files.append((f"~/{rel}", grants))
|
|
86
|
+
except OSError: # unreadable parent, permissions — treat as absent
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Sorted so two runs of the same machine produce the same report; an inventory that reshuffles
|
|
90
|
+
# cannot be diffed, and diffing is the point.
|
|
91
|
+
env_names = sorted(k for k in environ if _CREDENTIAL_NAME.search(k))
|
|
92
|
+
return AmbientExposure(files=files, env_names=env_names)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def summarize(exposure: AmbientExposure, launched: int, exfil_capable: int) -> list[str]:
|
|
96
|
+
"""Report lines, or [] when there is nothing worth saying.
|
|
97
|
+
|
|
98
|
+
Deliberately conditional on there being something to inherit AND something to inherit it: this
|
|
99
|
+
is a property of the PAIRING. A machine full of credentials with no local servers is not a
|
|
100
|
+
finding, and a local server on a machine with no credentials is not one either.
|
|
101
|
+
|
|
102
|
+
Returns LINES, wrapped to fit a terminal. The fleet view holds an invariant that no row runs off
|
|
103
|
+
an 80-column screen — a warning that wraps into unreadable soup is a warning people skip, and
|
|
104
|
+
this one has to survive being read at a glance.
|
|
105
|
+
"""
|
|
106
|
+
if not exposure.count or not launched:
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
head = (f"{launched} local server{'s' if launched != 1 else ''} run as you, inheriting "
|
|
110
|
+
f"credentials no MCP config declares:")
|
|
111
|
+
lines = [head]
|
|
112
|
+
|
|
113
|
+
for path, grants in exposure.files:
|
|
114
|
+
lines.append(f" {path} — {grants}")
|
|
115
|
+
if exposure.env_names:
|
|
116
|
+
n = len(exposure.env_names)
|
|
117
|
+
lines.append(f" {n} credential-shaped environment variable{'s' if n != 1 else ''}")
|
|
118
|
+
if exfil_capable:
|
|
119
|
+
lines.append(f" {exfil_capable} of their tools can send data outward.")
|
|
120
|
+
return lines
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""mcpgawk CLI — one command, zero config.
|
|
2
|
+
|
|
3
|
+
mcpgawk scan <mcp.json> [--only a,b] [--json]
|
|
4
|
+
mcpgawk scan --stdio "npx -y @modelcontextprotocol/server-filesystem /tmp"
|
|
5
|
+
mcpgawk scan --http https://host/mcp [--header "Authorization: Bearer ..."]
|
|
6
|
+
mcpgawk scan --sse https://host/sse
|
|
7
|
+
|
|
8
|
+
Local-first: the only network is the SDK talking to the server you point it at.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import shlex
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import asdict
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
from . import drift, fleet, history
|
|
21
|
+
from .fleet import FleetRow
|
|
22
|
+
from .consent import gate_stdio_consent
|
|
23
|
+
from .discover import detect_unscannable, discover_servers
|
|
24
|
+
from .label import build_label, render_cli, render_summary
|
|
25
|
+
from .measure import measure
|
|
26
|
+
from .oauth_scopes import inspect as inspect_oauth_scopes
|
|
27
|
+
from .probe import ServerSnapshot, probe, probe_stdio, probe_url
|
|
28
|
+
from .signals import as_dicts, detect, detect_card_mismatch, detect_dynamic_dispatch, detect_shadowing
|
|
29
|
+
from .supplychain import check as check_supply_chain
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_config(path: str) -> dict:
|
|
33
|
+
with open(path, encoding="utf-8") as f:
|
|
34
|
+
data = json.load(f)
|
|
35
|
+
return data.get("mcpServers", data)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _headers(pairs: list[str] | None) -> dict[str, str]:
|
|
39
|
+
out = {}
|
|
40
|
+
for p in pairs or []:
|
|
41
|
+
k, _, v = p.partition(":")
|
|
42
|
+
out[k.strip()] = v.strip()
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _run(args) -> tuple[list[ServerSnapshot], dict[str, dict], list[tuple[str, dict]]]:
|
|
47
|
+
"""Returns snapshots, the raw entry (command/args/headers) each came from, and the targets we
|
|
48
|
+
deliberately did NOT scan (consent withheld). The entries feed the opt-in supply-chain/
|
|
49
|
+
oauth-scopes checks and the fleet view's auth step; the skipped list keeps unscanned servers
|
|
50
|
+
VISIBLE, so the summary can never imply coverage it doesn't have."""
|
|
51
|
+
if args.stdio:
|
|
52
|
+
parts = shlex.split(args.stdio)
|
|
53
|
+
entry = {"command": parts[0], "args": parts[1:]}
|
|
54
|
+
return [await probe_stdio("cli-stdio", parts[0], parts[1:])], {"cli-stdio": entry}, []
|
|
55
|
+
if args.http or args.sse:
|
|
56
|
+
url = args.http or args.sse
|
|
57
|
+
transport = "http" if args.http else "sse"
|
|
58
|
+
entry = {"url": url, "headers": _headers(args.header)}
|
|
59
|
+
auth = server = None
|
|
60
|
+
# A remote endpoint that isn't MCP (a pasted docs/repo URL) must fail fast, not hang — so
|
|
61
|
+
# the default here is the short HTTP budget, not the 90s stdio one. --login is the one case
|
|
62
|
+
# that legitimately waits: the user has 5 min to approve the OAuth flow in the browser.
|
|
63
|
+
from .probe import HTTP_TIMEOUT
|
|
64
|
+
timeout = HTTP_TIMEOUT
|
|
65
|
+
if getattr(args, "login", False):
|
|
66
|
+
from .oauth_login import build_login_provider
|
|
67
|
+
auth, server = build_login_provider(url)
|
|
68
|
+
timeout = 330.0
|
|
69
|
+
# `--http`/`--sse` orders the attempts; it does not decide what we believe. The one case we
|
|
70
|
+
# do NOT permute is --login: an OAuth provider would re-run its browser flow per candidate
|
|
71
|
+
# and offer the token to URLs the user never named (see probe_url).
|
|
72
|
+
try:
|
|
73
|
+
snap = await probe_url(f"cli-{transport}", url, entry["headers"], timeout, auth,
|
|
74
|
+
declared=transport, permute=auth is None)
|
|
75
|
+
finally:
|
|
76
|
+
if server is not None:
|
|
77
|
+
server.shutdown()
|
|
78
|
+
return [snap], {f"cli-{transport}": entry}, []
|
|
79
|
+
only = set(args.only.split(",")) if args.only else None
|
|
80
|
+
# Zero-config: with no path given, DISCOVER every MCP server configured across the machine's IDE
|
|
81
|
+
# clients (Claude Desktop/Code, Cursor, VS Code, Windsurf, …), deduped. `mcpgawk scan` just works.
|
|
82
|
+
is_discovery = not args.config
|
|
83
|
+
cfg = _load_config(args.config) if args.config else discover_servers()
|
|
84
|
+
targets = [(n, e) for n, e in cfg.items() if not only or n in only]
|
|
85
|
+
if is_discovery and not targets:
|
|
86
|
+
print("mcpgawk: no MCP servers found in your IDE configs "
|
|
87
|
+
"(Claude Desktop/Code, Cursor, VS Code, Windsurf, …).\n"
|
|
88
|
+
" Point it at a config: mcpgawk scan path/to/mcp.json\n"
|
|
89
|
+
" Or scan one server: mcpgawk scan --stdio \"npx -y <server>\" | --http <url>",
|
|
90
|
+
file=sys.stderr)
|
|
91
|
+
return [], {}, []
|
|
92
|
+
# Default-deny consent before LAUNCHING any discovered/configured stdio server (spawning runs its
|
|
93
|
+
# code). Explicit --stdio never reaches here; remote servers aren't spawned so they always pass.
|
|
94
|
+
approved = gate_stdio_consent(targets, assume_yes=getattr(args, "yes", False))
|
|
95
|
+
# A server we chose NOT to launch must stay VISIBLE in the fleet view. Dropping it silently
|
|
96
|
+
# would let the summary imply coverage we don't have.
|
|
97
|
+
ok_names = {n for n, _ in approved}
|
|
98
|
+
skipped = [(n, e) for n, e in targets if n not in ok_names]
|
|
99
|
+
snaps = await asyncio.gather(*(probe(e, n) for n, e in approved))
|
|
100
|
+
return list(snaps), {n: e for n, e in approved}, skipped
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _label_for(sn: ServerSnapshot, m, entry: dict, args, shadow: dict | None = None) -> dict:
|
|
104
|
+
"""Build one server's label. Extracted so the post-sign-in re-scan produces an IDENTICAL label
|
|
105
|
+
to the original pass — a second, drifting definition of "what a label is" is exactly how the
|
|
106
|
+
refreshed row would start disagreeing with the row it replaced."""
|
|
107
|
+
sigs = None
|
|
108
|
+
if not args.no_signals:
|
|
109
|
+
sigs = (as_dicts(detect(sn)) + as_dicts((shadow or {}).get(sn.name, []))
|
|
110
|
+
+ as_dicts(detect_card_mismatch(sn)) + as_dicts(detect_dynamic_dispatch(sn)))
|
|
111
|
+
label = build_label(sn, m, bounded_signals=(sigs or None))
|
|
112
|
+
# Both opt-in: supply-chain hits a public registry (egress), oauth-scopes reads a credential the
|
|
113
|
+
# user already supplied (no egress, but still consent-gated).
|
|
114
|
+
if args.supply_chain and entry.get("command"):
|
|
115
|
+
finding = check_supply_chain(entry["command"], entry.get("args") or [])
|
|
116
|
+
label["x-mcpgawk"]["supply_chain"] = (
|
|
117
|
+
asdict(finding) if finding else {"checked": False,
|
|
118
|
+
"reason": "package not recognised from the launch command"})
|
|
119
|
+
if args.oauth_scopes:
|
|
120
|
+
label["x-mcpgawk"]["oauth_scopes"] = inspect_oauth_scopes(entry.get("headers"))
|
|
121
|
+
return label
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main(argv: list[str] | None = None) -> int:
|
|
125
|
+
p = argparse.ArgumentParser(prog="mcpgawk", description="gawk at an MCP server before you trust it")
|
|
126
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
127
|
+
s = sub.add_parser("scan", help="measure MCP server(s) locally")
|
|
128
|
+
s.add_argument("config", nargs="?", help="path to an mcp.json config")
|
|
129
|
+
s.add_argument("--stdio", help='one stdio server, e.g. "npx -y @modelcontextprotocol/server-filesystem /tmp"')
|
|
130
|
+
s.add_argument("--http", help="one streamable-HTTP server URL")
|
|
131
|
+
s.add_argument("--sse", help="one SSE server URL")
|
|
132
|
+
s.add_argument("--header", action="append", help='HTTP header, e.g. "Authorization: Bearer XYZ" (repeatable)')
|
|
133
|
+
s.add_argument("--login", action="store_true",
|
|
134
|
+
help="for a remote --http/--sse server that needs OAuth: open the browser, sign "
|
|
135
|
+
"in once, and scan (token stored locally in ~/.gawk/oauth)")
|
|
136
|
+
s.add_argument("--only", help="comma-separated server names to scan from the config")
|
|
137
|
+
s.add_argument("--yes", "-y", action="store_true",
|
|
138
|
+
help="launch discovered/configured local (stdio) servers WITHOUT the consent "
|
|
139
|
+
"prompt (scanning a stdio server runs its code) — for CI / non-interactive use")
|
|
140
|
+
s.add_argument("--no-signals", action="store_true", help="skip BOUNDED heuristic signals (facts only)")
|
|
141
|
+
s.add_argument("--track", action="store_true",
|
|
142
|
+
help="record this scan locally and report DRIFT vs the last sighting (rug-pull detection)")
|
|
143
|
+
s.add_argument("--json", action="store_true", help="emit JSON labels instead of a table")
|
|
144
|
+
s.add_argument("--fleet-json", action="store_true",
|
|
145
|
+
help="emit the FLEET STATUS as JSON (one row per server, grouped by the tool it "
|
|
146
|
+
"lives in) — what the IDE extension renders, so state is computed once here")
|
|
147
|
+
s.add_argument("--verbose", action="store_true", help="show the full per-tool table, not just flagged tools")
|
|
148
|
+
s.add_argument("--detail", action="store_true",
|
|
149
|
+
help="print the full narrative report for EVERY server instead of the fleet "
|
|
150
|
+
"status list (the list is the default when more than one server is scanned)")
|
|
151
|
+
s.add_argument("--supply-chain", action="store_true",
|
|
152
|
+
help="opt-in: query the public npm/PyPI registry for the launched package's "
|
|
153
|
+
"deprecation/yank status (network egress — package name+version only)")
|
|
154
|
+
s.add_argument("--oauth-scopes", action="store_true",
|
|
155
|
+
help="opt-in: locally decode a supplied Bearer JWT's scope claim (no network; "
|
|
156
|
+
"reads a credential you already provided)")
|
|
157
|
+
args = p.parse_args(argv)
|
|
158
|
+
|
|
159
|
+
# No args at all is VALID: it means "discover and scan everything on this machine". _run handles
|
|
160
|
+
# the nothing-found message and default-deny consent before launching any discovered stdio server.
|
|
161
|
+
snaps, entries, skipped = asyncio.run(_run(args))
|
|
162
|
+
measurements = [measure(sn) for sn in snaps]
|
|
163
|
+
# Cross-server shadowing needs all snapshots together; merge into each involved server's signals.
|
|
164
|
+
shadow = {} if args.no_signals else detect_shadowing(snaps)
|
|
165
|
+
labels = [_label_for(sn, m, entries.get(sn.name) or {}, args, shadow)
|
|
166
|
+
for sn, m in zip(snaps, measurements)]
|
|
167
|
+
|
|
168
|
+
# --track: record locally and diff against the last sighting (rug-pull detection).
|
|
169
|
+
drift_reports: dict[str, drift.DriftReport] = {}
|
|
170
|
+
if args.track:
|
|
171
|
+
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
172
|
+
for sn, m in zip(snaps, measurements):
|
|
173
|
+
if sn.error:
|
|
174
|
+
continue
|
|
175
|
+
# Read-the-previous and write-the-current under ONE lock (history.record). Split across
|
|
176
|
+
# a load()/save() pair, two concurrent scans each diff against a baseline the other has
|
|
177
|
+
# already replaced, and one server's drift history is silently lost.
|
|
178
|
+
current = drift.build_record(sn, m, measured_at=now)
|
|
179
|
+
previous = history.record(history.key_for(sn), current)
|
|
180
|
+
rep = drift.compare(previous, current)
|
|
181
|
+
if rep and rep.any:
|
|
182
|
+
drift_reports[sn.name] = rep
|
|
183
|
+
|
|
184
|
+
# Drift must reach the MACHINE-READABLE output and the exit code, not only the pretty print.
|
|
185
|
+
# A rug-pull that a CI job can't see is a rug-pull that ships: `--json` consumers and pipeline
|
|
186
|
+
# gates were previously blind to it.
|
|
187
|
+
for lab in labels:
|
|
188
|
+
rep = drift_reports.get(lab["name"])
|
|
189
|
+
if rep:
|
|
190
|
+
d = asdict(rep)
|
|
191
|
+
d["rug_pull"] = bool(rep.changed) # same item, rewritten description — the signature
|
|
192
|
+
lab["x-mcpgawk"]["drift"] = d
|
|
193
|
+
|
|
194
|
+
# ONE exit code for both output modes. `--json` used to `return 0` unconditionally — so a failed
|
|
195
|
+
# probe or a detected rug-pull reported success to CI, the same class of lie as a false CLEAN.
|
|
196
|
+
failed = any(lab["x-mcpgawk"].get("caveats") for lab in labels) or bool(drift_reports)
|
|
197
|
+
|
|
198
|
+
if args.json:
|
|
199
|
+
print(json.dumps(labels, indent=2))
|
|
200
|
+
return 1 if failed else 0
|
|
201
|
+
|
|
202
|
+
if getattr(args, "fleet_json", False):
|
|
203
|
+
# Front-ends get the SAME rows the terminal view renders — never raw labels to re-interpret.
|
|
204
|
+
unscannable = detect_unscannable() if not (args.stdio or args.http or args.sse) else []
|
|
205
|
+
payload = fleet.to_json(fleet.build_rows(labels, entries, skipped, unscannable))
|
|
206
|
+
print(json.dumps(payload, indent=2))
|
|
207
|
+
return 1 if failed else 0
|
|
208
|
+
|
|
209
|
+
# Show the REAL installed version, not a hardcoded string. `__version__` is now single-sourced
|
|
210
|
+
# from the installed package metadata in __init__ (see there), so this banner can no longer go
|
|
211
|
+
# stale or disagree with pyproject/PyPI. A version banner that lies erodes trust in a measurement
|
|
212
|
+
# tool.
|
|
213
|
+
from . import __version__ as _ver
|
|
214
|
+
print(f"\n{'='*70}\nmcpgawk {_ver} — LOCAL scan (no inventory uploaded)\n{'='*70}")
|
|
215
|
+
|
|
216
|
+
# THE FLEET VIEW. A machine has a fleet of MCP servers, not one — handing the reader seven
|
|
217
|
+
# full narrative reports in a row means the third onwards goes unread, which is the same as not
|
|
218
|
+
# scanning. So multiple servers get one status line each, needs-you-first, and the per-server
|
|
219
|
+
# narrative stays a deliberate `--detail` (or --only <name>) away. One server always renders in
|
|
220
|
+
# full: there is nothing to summarise.
|
|
221
|
+
# Capabilities that exist but no local scan can reach (account-hosted connectors, browser
|
|
222
|
+
# hosts) are LISTED, never silently omitted — see discover.detect_unscannable.
|
|
223
|
+
unscannable = detect_unscannable() if not (args.stdio or args.http or args.sse) else []
|
|
224
|
+
rows = fleet.build_rows(labels, entries, skipped, unscannable)
|
|
225
|
+
if len(rows) > 1 and not args.detail:
|
|
226
|
+
print()
|
|
227
|
+
print(fleet.render_fleet(rows))
|
|
228
|
+
for lab in labels: # drift is never summarised away — it's the alarm
|
|
229
|
+
rep = drift_reports.get(lab["name"])
|
|
230
|
+
if rep:
|
|
231
|
+
print("\n" + drift.render(lab["name"], rep))
|
|
232
|
+
print()
|
|
233
|
+
refreshed = _offer_batched_auth(rows, args, entries)
|
|
234
|
+
any_error = any(lab["x-mcpgawk"].get("caveats") for lab in labels)
|
|
235
|
+
if refreshed:
|
|
236
|
+
# Redraw with the signed-in servers now MEASURED, rather than sending the user back to
|
|
237
|
+
# the shell to run the same command again. The whole point of the batched step is that
|
|
238
|
+
# you finish where you started.
|
|
239
|
+
rows = [refreshed.get(r.name, r) for r in rows]
|
|
240
|
+
print("\n Updated:\n")
|
|
241
|
+
print(fleet.render_fleet(fleet.sort_rows(rows)))
|
|
242
|
+
print()
|
|
243
|
+
# A server that only became measurable after sign-in can carry findings — those must
|
|
244
|
+
# count towards the exit code exactly as if the first pass had seen them.
|
|
245
|
+
any_error = any_error or any(r.state in ("REVIEW", "INCOMPLETE", "UNREACHABLE")
|
|
246
|
+
for r in refreshed.values())
|
|
247
|
+
return 1 if (any_error or failed) else 0
|
|
248
|
+
|
|
249
|
+
any_error = False
|
|
250
|
+
for lab in labels:
|
|
251
|
+
print("\n" + render_cli(lab, verbose=args.verbose))
|
|
252
|
+
rep = drift_reports.get(lab["name"])
|
|
253
|
+
if rep:
|
|
254
|
+
print(drift.render(lab["name"], rep))
|
|
255
|
+
any_error = any_error or bool(lab["x-mcpgawk"].get("caveats"))
|
|
256
|
+
# Local (stdio) servers — launched this run or merely configured. Both inherit the same
|
|
257
|
+
# ambient credentials the moment anything starts them, so both count towards that warning.
|
|
258
|
+
local_servers = (sum(1 for e in entries.values() if e.get("command"))
|
|
259
|
+
+ sum(1 for _, e in skipped if e.get("command")))
|
|
260
|
+
print("\n" + render_summary(labels, local_servers=local_servers) + "\n")
|
|
261
|
+
return 1 if (any_error or failed) else 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _offer_batched_auth(rows: list, args, entries: dict) -> dict:
|
|
265
|
+
"""ONE prompt for every server that needs credentials — never one prompt per server, which the
|
|
266
|
+
founder rejected outright as the painpoint this view exists to remove.
|
|
267
|
+
|
|
268
|
+
Returns {name: refreshed FleetRow} for servers that signed in successfully, so the caller can
|
|
269
|
+
redraw the list in place instead of telling the user to run the command again. Default-deny in
|
|
270
|
+
spirit: a blank or unparseable answer authenticates nothing, and a non-interactive run never
|
|
271
|
+
opens a browser at all."""
|
|
272
|
+
pending = [r for r in rows if r.needs_auth]
|
|
273
|
+
if not pending:
|
|
274
|
+
return {}
|
|
275
|
+
if not sys.stdin.isatty():
|
|
276
|
+
print(f" {len(pending)} server(s) need credentials. Re-run in a terminal, or: "
|
|
277
|
+
f"mcpgawk scan --http <url> --login\n", file=sys.stderr)
|
|
278
|
+
return {}
|
|
279
|
+
|
|
280
|
+
print(" These need credentials:")
|
|
281
|
+
for i, r in enumerate(pending, 1):
|
|
282
|
+
print(f" {i}. {r.name} {r.url}")
|
|
283
|
+
sys.stderr.write(" Sign in to which? [all / 1,2 / N] ")
|
|
284
|
+
sys.stderr.flush()
|
|
285
|
+
picked = fleet.parse_auth_selection(input(), len(pending))
|
|
286
|
+
if not picked:
|
|
287
|
+
print(" → skipped. Nothing was authenticated.\n", file=sys.stderr)
|
|
288
|
+
return {}
|
|
289
|
+
|
|
290
|
+
from .oauth_login import build_login_provider
|
|
291
|
+
refreshed: dict[str, FleetRow] = {}
|
|
292
|
+
for i in picked:
|
|
293
|
+
row = pending[i]
|
|
294
|
+
print(f"\n Signing in to {row.name} — approve in the browser…", file=sys.stderr)
|
|
295
|
+
auth, server = build_login_provider(row.url)
|
|
296
|
+
try:
|
|
297
|
+
snap = asyncio.run(probe_url(row.name, row.url, None, 330.0, auth,
|
|
298
|
+
declared="http", permute=False))
|
|
299
|
+
finally:
|
|
300
|
+
server.shutdown() # always release the local callback port
|
|
301
|
+
if snap.is_failure:
|
|
302
|
+
print(f" {row.name}: sign-in did not complete — {(snap.error or '')[:90]}", file=sys.stderr)
|
|
303
|
+
continue
|
|
304
|
+
# The row is replaced by a REAL measurement of the now-authenticated server, built through
|
|
305
|
+
# the same label path as the original pass — so the refreshed row cannot disagree with the
|
|
306
|
+
# one it replaces, and a server that turns out to be risky says so immediately.
|
|
307
|
+
entry = entries.get(row.name) or {}
|
|
308
|
+
label = _label_for(snap, measure(snap), entry, args)
|
|
309
|
+
state, detail = fleet.state_of(label)
|
|
310
|
+
refreshed[row.name] = FleetRow(name=row.name, state=state, detail=detail, url=row.url,
|
|
311
|
+
clients=row.clients)
|
|
312
|
+
print(f" {row.name}: signed in — {detail}", file=sys.stderr)
|
|
313
|
+
return refreshed
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
sys.exit(main())
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""CONSENT — default-deny before LAUNCHING a local (stdio) MCP server.
|
|
2
|
+
|
|
3
|
+
Spawning a stdio server RUNS its code on your machine. Zero-config `mcpgawk scan` discovers servers
|
|
4
|
+
across your IDE configs and would otherwise launch every one of them silently — so when servers come
|
|
5
|
+
from discovery or a config file (NOT a command you just typed), gawk enumerates the launch plan,
|
|
6
|
+
redacts env values, and asks before launching, defaulting to NO.
|
|
7
|
+
|
|
8
|
+
Explicit `mcpgawk scan --stdio "<cmd>"` is your own typed command — implicit consent, never prompted
|
|
9
|
+
(that path never reaches this gate). Remote (http/sse) servers are connected to, not spawned, so they
|
|
10
|
+
run no local code and are never gated here.
|
|
11
|
+
|
|
12
|
+
The plan and prompt go to STDERR so `--json` stdout stays clean; the reply is read from stdin.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
Target = tuple[str, dict[str, Any]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _format(name: str, entry: dict[str, Any]) -> str:
|
|
23
|
+
cmd = str(entry.get("command", ""))
|
|
24
|
+
args = entry.get("args") or []
|
|
25
|
+
line = f" • {name}: {cmd} {' '.join(map(str, args))}".rstrip()
|
|
26
|
+
env = entry.get("env") or {}
|
|
27
|
+
if isinstance(env, dict) and env:
|
|
28
|
+
# Show which env vars are passed, NEVER their values (they carry the secrets).
|
|
29
|
+
line += f"\n env: {', '.join(sorted(env))} (values hidden)"
|
|
30
|
+
return line
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def gate_stdio_consent(
|
|
34
|
+
targets: list[Target],
|
|
35
|
+
*,
|
|
36
|
+
assume_yes: bool = False,
|
|
37
|
+
stdin_isatty: bool | None = None,
|
|
38
|
+
ask: Callable[[], str] = input,
|
|
39
|
+
err=None,
|
|
40
|
+
) -> list[Target]:
|
|
41
|
+
"""Return the subset of `targets` approved to scan. Remote servers always pass (no code runs);
|
|
42
|
+
local (stdio) servers are launched only with consent — `--yes` (assume_yes), an interactive 'y',
|
|
43
|
+
and never by default. Non-interactive without `--yes` fails closed: remote-only.
|
|
44
|
+
|
|
45
|
+
Injectable (`ask`/`err`/`stdin_isatty`) so it's testable without a real TTY."""
|
|
46
|
+
stdio = [(n, e) for n, e in targets if e.get("command")]
|
|
47
|
+
remote = [(n, e) for n, e in targets if not e.get("command")]
|
|
48
|
+
if not stdio:
|
|
49
|
+
return list(targets) # nothing to spawn — no consent needed
|
|
50
|
+
|
|
51
|
+
err = sys.stderr if err is None else err # resolved at CALL time (so capsys/redirection works)
|
|
52
|
+
isatty = sys.stdin.isatty() if stdin_isatty is None else stdin_isatty
|
|
53
|
+
n = len(stdio)
|
|
54
|
+
print(f"\n⚠ {n} local server{'s' if n != 1 else ''} would be LAUNCHED to scan "
|
|
55
|
+
f"— this RUNS their code on your machine:", file=err)
|
|
56
|
+
for name, entry in stdio:
|
|
57
|
+
print(_format(name, entry), file=err)
|
|
58
|
+
|
|
59
|
+
if assume_yes:
|
|
60
|
+
print("→ launching (--yes given).", file=err)
|
|
61
|
+
return list(targets)
|
|
62
|
+
|
|
63
|
+
if not isatty:
|
|
64
|
+
print(f"→ NOT launched: default-deny in a non-interactive run. Re-run with --yes to launch "
|
|
65
|
+
f"them, or in a terminal to approve. Scanning {len(remote)} remote server(s) only.",
|
|
66
|
+
file=err)
|
|
67
|
+
return remote
|
|
68
|
+
|
|
69
|
+
err.write(f"Launch {'these' if n != 1 else 'this'} {n} local server{'s' if n != 1 else ''}? [y/N] ")
|
|
70
|
+
err.flush()
|
|
71
|
+
reply = (ask() or "").strip().lower()
|
|
72
|
+
if reply in ("y", "yes"):
|
|
73
|
+
return list(targets)
|
|
74
|
+
print(f"→ skipped. Scanning {len(remote)} remote server(s) only.", file=err)
|
|
75
|
+
return remote
|