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.
Files changed (165) hide show
  1. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/CHANGELOG.md +21 -0
  2. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/PKG-INFO +1 -1
  3. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/CHANGELOG.md +6 -0
  4. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/package.json +1 -1
  5. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/pyproject.toml +2 -1
  6. mcpgawk-0.1.4/src/mcpgawk/__init__.py +26 -0
  7. mcpgawk-0.1.4/src/mcpgawk/ambient.py +120 -0
  8. mcpgawk-0.1.4/src/mcpgawk/cli.py +317 -0
  9. mcpgawk-0.1.4/src/mcpgawk/consent.py +75 -0
  10. mcpgawk-0.1.4/src/mcpgawk/discover.py +352 -0
  11. mcpgawk-0.1.4/src/mcpgawk/drift.py +145 -0
  12. mcpgawk-0.1.4/src/mcpgawk/fleet.py +293 -0
  13. mcpgawk-0.1.4/src/mcpgawk/grade.py +132 -0
  14. mcpgawk-0.1.4/src/mcpgawk/history.py +118 -0
  15. mcpgawk-0.1.4/src/mcpgawk/label.py +419 -0
  16. mcpgawk-0.1.4/src/mcpgawk/mcp_server.py +179 -0
  17. mcpgawk-0.1.4/src/mcpgawk/measure.py +173 -0
  18. mcpgawk-0.1.4/src/mcpgawk/oauth_login.py +131 -0
  19. mcpgawk-0.1.4/src/mcpgawk/probe.py +361 -0
  20. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/src/mcpgawk/signals.py +69 -0
  21. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/src/mcpgawk/supplychain.py +5 -5
  22. mcpgawk-0.1.4/src/mcpgawk/transport.py +112 -0
  23. mcpgawk-0.1.4/tests/test_ambient.py +147 -0
  24. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_cli_integration.py +4 -2
  25. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_drift.py +3 -2
  26. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_grade.py +2 -2
  27. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_label_axes.py +12 -1
  28. mcpgawk-0.1.4/tests/test_layer_invariants.py +85 -0
  29. mcpgawk-0.1.4/tests/test_mcp_server.py +205 -0
  30. mcpgawk-0.1.4/tests/test_measure.py +122 -0
  31. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_signals.py +50 -1
  32. mcpgawk-0.1.4/tests/test_thin_guidance.py +162 -0
  33. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/uv.lock +1 -1
  34. mcpgawk-0.1.3/src/mcpgawk/__init__.py +0 -16
  35. mcpgawk-0.1.3/src/mcpgawk/cli.py +0 -145
  36. mcpgawk-0.1.3/src/mcpgawk/drift.py +0 -84
  37. mcpgawk-0.1.3/src/mcpgawk/grade.py +0 -75
  38. mcpgawk-0.1.3/src/mcpgawk/history.py +0 -49
  39. mcpgawk-0.1.3/src/mcpgawk/label.py +0 -158
  40. mcpgawk-0.1.3/src/mcpgawk/measure.py +0 -105
  41. mcpgawk-0.1.3/src/mcpgawk/probe.py +0 -129
  42. mcpgawk-0.1.3/tests/test_measure.py +0 -63
  43. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/.github/workflows/ci.yml +0 -0
  44. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/.github/workflows/mcpgawk-gate-selftest.yml +0 -0
  45. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/.github/workflows/publish-openvsx.yml +0 -0
  46. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/.github/workflows/publish-pypi.yml +0 -0
  47. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/.gitignore +0 -0
  48. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/BRAND.md +0 -0
  49. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/CONTRIBUTING.md +0 -0
  50. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/LICENSE +0 -0
  51. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/NOTICE +0 -0
  52. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/README.md +0 -0
  53. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/SECURITY.md +0 -0
  54. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/THREAT-MODEL.md +0 -0
  55. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/action.yml +0 -0
  56. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/demo.gif +0 -0
  57. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/favicon.svg +0 -0
  58. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/identity-board.html +0 -0
  59. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/nativerse-mark.svg +0 -0
  60. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/social-preview.png +0 -0
  61. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/social-preview.svg +0 -0
  62. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/wordmark-dark.png +0 -0
  63. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/wordmark-dark.svg +0 -0
  64. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/wordmark-light.png +0 -0
  65. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/assets/brand/wordmark-light.svg +0 -0
  66. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/.gitignore +0 -0
  67. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/CNAME +0 -0
  68. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/api/lead.js +0 -0
  69. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/brand/favicon.svg +0 -0
  70. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/brand/nativerse-mark.svg +0 -0
  71. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/brand/social-preview.png +0 -0
  72. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/brand/social-preview.svg +0 -0
  73. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/alibaba.svg +0 -0
  74. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/anthropic.svg +0 -0
  75. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/baidu.svg +0 -0
  76. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/cerebras.svg +0 -0
  77. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/cloudflare.svg +0 -0
  78. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/cohere.svg +0 -0
  79. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/copilot.svg +0 -0
  80. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/cursor.svg +0 -0
  81. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/deepseek.svg +0 -0
  82. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/discord.svg +0 -0
  83. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/docker.svg +0 -0
  84. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/figma.svg +0 -0
  85. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/framer.svg +0 -0
  86. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/gemini.svg +0 -0
  87. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/github.svg +0 -0
  88. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/gitlab.svg +0 -0
  89. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/gojek.svg +0 -0
  90. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/google.svg +0 -0
  91. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/grab.svg +0 -0
  92. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/grok.svg +0 -0
  93. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/huawei.svg +0 -0
  94. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/huggingface.svg +0 -0
  95. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/inflection.svg +0 -0
  96. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/infosys.svg +0 -0
  97. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/kakaotalk.svg +0 -0
  98. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/kimi.svg +0 -0
  99. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/klarna.svg +0 -0
  100. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/langchain.svg +0 -0
  101. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/line.svg +0 -0
  102. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/linear.svg +0 -0
  103. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/mercadolibre.svg +0 -0
  104. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/meta.svg +0 -0
  105. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/microsoft.svg +0 -0
  106. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/midjourney.svg +0 -0
  107. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/mistral.svg +0 -0
  108. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/naver.svg +0 -0
  109. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/netlify.svg +0 -0
  110. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/notion.svg +0 -0
  111. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/nubank.svg +0 -0
  112. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/nvidia.svg +0 -0
  113. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/ollama.svg +0 -0
  114. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/openai.svg +0 -0
  115. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/paytm.svg +0 -0
  116. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/perplexity.svg +0 -0
  117. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/postgresql.svg +0 -0
  118. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/python.svg +0 -0
  119. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/qwen.svg +0 -0
  120. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/rakuten.svg +0 -0
  121. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/replicate.svg +0 -0
  122. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/replit.svg +0 -0
  123. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/revolut.svg +0 -0
  124. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/runway.svg +0 -0
  125. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/sap.svg +0 -0
  126. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/sentry.svg +0 -0
  127. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/shopee.svg +0 -0
  128. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/slack.svg +0 -0
  129. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/sony.svg +0 -0
  130. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/spotify.svg +0 -0
  131. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/stability.svg +0 -0
  132. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/stripe.svg +0 -0
  133. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/suno.svg +0 -0
  134. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/supabase.svg +0 -0
  135. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/swiggy.svg +0 -0
  136. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/tiktok.svg +0 -0
  137. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/togetherai.svg +0 -0
  138. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/vercel.svg +0 -0
  139. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/wechat.svg +0 -0
  140. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/weibo.svg +0 -0
  141. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/wise.svg +0 -0
  142. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/xai.svg +0 -0
  143. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/xiaomi.svg +0 -0
  144. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/zoho.svg +0 -0
  145. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/assets/logos/zomato.svg +0 -0
  146. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/index.html +0 -0
  147. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/logofield.js +0 -0
  148. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/report.html +0 -0
  149. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/docs/styles.css +0 -0
  150. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/.vscodeignore +0 -0
  151. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/LICENSE +0 -0
  152. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/README.md +0 -0
  153. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/extension.js +0 -0
  154. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/editors/vscode/media/icon.png +0 -0
  155. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/examples/mcp.json +0 -0
  156. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/scripts/ci_gate.py +0 -0
  157. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/src/mcpgawk/__main__.py +0 -0
  158. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/src/mcpgawk/oauth_scopes.py +0 -0
  159. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/src/mcpgawk/servercard.py +0 -0
  160. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/supabase/leads.sql +0 -0
  161. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/supabase/lockdown.sql +0 -0
  162. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_no_egress.py +0 -0
  163. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_oauth_scopes.py +0 -0
  164. {mcpgawk-0.1.3 → mcpgawk-0.1.4}/tests/test_servercard.py +0 -0
  165. {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
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.3",
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.3"
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