mcpgawk 0.1.7__tar.gz → 0.1.8__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 (154) hide show
  1. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/.github/workflows/publish-pypi.yml +13 -1
  2. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/CHANGELOG.md +42 -0
  3. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/PKG-INFO +1 -1
  4. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/pyproject.toml +1 -1
  5. mcpgawk-0.1.8/src/mcpgawk/baseline.py +155 -0
  6. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/cli.py +124 -1
  7. mcpgawk-0.1.8/tests/test_baseline_spine.py +108 -0
  8. mcpgawk-0.1.8/tests/test_free_cli_platform_hint.py +70 -0
  9. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/.github/workflows/ci.yml +0 -0
  10. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/.github/workflows/mcpgawk-gate-selftest.yml +0 -0
  11. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/.gitignore +0 -0
  12. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/BRAND.md +0 -0
  13. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/CONTRIBUTING.md +0 -0
  14. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/LICENSE +0 -0
  15. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/NOTICE +0 -0
  16. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/README.md +0 -0
  17. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/SECURITY.md +0 -0
  18. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/THREAT-MODEL.md +0 -0
  19. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/action.yml +0 -0
  20. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/demo.gif +0 -0
  21. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/favicon.svg +0 -0
  22. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/identity-board.html +0 -0
  23. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/nativerse-mark.svg +0 -0
  24. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/social-preview.png +0 -0
  25. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/social-preview.svg +0 -0
  26. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/wordmark-dark.png +0 -0
  27. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/wordmark-dark.svg +0 -0
  28. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/wordmark-light.png +0 -0
  29. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/assets/brand/wordmark-light.svg +0 -0
  30. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/.gitignore +0 -0
  31. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/CNAME +0 -0
  32. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/api/lead.js +0 -0
  33. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/brand/favicon.svg +0 -0
  34. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/brand/nativerse-mark.svg +0 -0
  35. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/brand/social-preview.png +0 -0
  36. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/brand/social-preview.svg +0 -0
  37. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/alibaba.svg +0 -0
  38. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/anthropic.svg +0 -0
  39. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/baidu.svg +0 -0
  40. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/cerebras.svg +0 -0
  41. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/cloudflare.svg +0 -0
  42. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/cohere.svg +0 -0
  43. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/copilot.svg +0 -0
  44. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/cursor.svg +0 -0
  45. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/deepseek.svg +0 -0
  46. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/discord.svg +0 -0
  47. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/docker.svg +0 -0
  48. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/figma.svg +0 -0
  49. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/framer.svg +0 -0
  50. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/gemini.svg +0 -0
  51. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/github.svg +0 -0
  52. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/gitlab.svg +0 -0
  53. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/gojek.svg +0 -0
  54. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/google.svg +0 -0
  55. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/grab.svg +0 -0
  56. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/grok.svg +0 -0
  57. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/huawei.svg +0 -0
  58. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/huggingface.svg +0 -0
  59. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/inflection.svg +0 -0
  60. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/infosys.svg +0 -0
  61. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/kakaotalk.svg +0 -0
  62. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/kimi.svg +0 -0
  63. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/klarna.svg +0 -0
  64. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/langchain.svg +0 -0
  65. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/line.svg +0 -0
  66. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/linear.svg +0 -0
  67. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/mercadolibre.svg +0 -0
  68. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/meta.svg +0 -0
  69. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/microsoft.svg +0 -0
  70. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/midjourney.svg +0 -0
  71. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/mistral.svg +0 -0
  72. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/naver.svg +0 -0
  73. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/netlify.svg +0 -0
  74. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/notion.svg +0 -0
  75. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/nubank.svg +0 -0
  76. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/nvidia.svg +0 -0
  77. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/ollama.svg +0 -0
  78. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/openai.svg +0 -0
  79. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/paytm.svg +0 -0
  80. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/perplexity.svg +0 -0
  81. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/postgresql.svg +0 -0
  82. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/python.svg +0 -0
  83. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/qwen.svg +0 -0
  84. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/rakuten.svg +0 -0
  85. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/replicate.svg +0 -0
  86. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/replit.svg +0 -0
  87. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/revolut.svg +0 -0
  88. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/runway.svg +0 -0
  89. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/sap.svg +0 -0
  90. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/sentry.svg +0 -0
  91. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/shopee.svg +0 -0
  92. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/slack.svg +0 -0
  93. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/sony.svg +0 -0
  94. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/spotify.svg +0 -0
  95. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/stability.svg +0 -0
  96. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/stripe.svg +0 -0
  97. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/suno.svg +0 -0
  98. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/supabase.svg +0 -0
  99. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/swiggy.svg +0 -0
  100. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/tiktok.svg +0 -0
  101. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/togetherai.svg +0 -0
  102. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/vercel.svg +0 -0
  103. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/wechat.svg +0 -0
  104. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/weibo.svg +0 -0
  105. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/wise.svg +0 -0
  106. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/xai.svg +0 -0
  107. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/xiaomi.svg +0 -0
  108. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/zoho.svg +0 -0
  109. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/assets/logos/zomato.svg +0 -0
  110. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/index.html +0 -0
  111. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/logofield.js +0 -0
  112. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/report.html +0 -0
  113. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/docs/styles.css +0 -0
  114. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/examples/mcp.json +0 -0
  115. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/scripts/ci_gate.py +0 -0
  116. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/__init__.py +0 -0
  117. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/__main__.py +0 -0
  118. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/ambient.py +0 -0
  119. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/consent.py +0 -0
  120. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/discover.py +0 -0
  121. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/drift.py +0 -0
  122. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/fingerprint.py +0 -0
  123. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/fleet.py +0 -0
  124. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/grade.py +0 -0
  125. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/history.py +0 -0
  126. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/label.py +0 -0
  127. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/mcp_server.py +0 -0
  128. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/measure.py +0 -0
  129. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/oauth_login.py +0 -0
  130. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/oauth_scopes.py +0 -0
  131. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/probe.py +0 -0
  132. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/redact.py +0 -0
  133. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/servercard.py +0 -0
  134. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/signals.py +0 -0
  135. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/supplychain.py +0 -0
  136. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/src/mcpgawk/transport.py +0 -0
  137. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/supabase/leads.sql +0 -0
  138. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/supabase/lockdown.sql +0 -0
  139. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_ambient.py +0 -0
  140. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_cli_integration.py +0 -0
  141. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_drift.py +0 -0
  142. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_drift_invariants.py +0 -0
  143. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_grade.py +0 -0
  144. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_label_axes.py +0 -0
  145. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_layer_invariants.py +0 -0
  146. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_mcp_server.py +0 -0
  147. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_measure.py +0 -0
  148. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_no_egress.py +0 -0
  149. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_oauth_scopes.py +0 -0
  150. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_servercard.py +0 -0
  151. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_signals.py +0 -0
  152. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_supplychain.py +0 -0
  153. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/tests/test_thin_guidance.py +0 -0
  154. {mcpgawk-0.1.7 → mcpgawk-0.1.8}/uv.lock +0 -0
@@ -75,4 +75,16 @@ jobs:
75
75
  TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
76
76
  run: |
77
77
  python -m pip install twine
78
- twine upload dist/*
78
+ # --skip-existing + retry: on 2026-07-23 (0.1.7) upload.pypi.org connect-timeouts made
79
+ # twine exit 1 AFTER both artefacts had already landed — a RED run over a SUCCESSFUL
80
+ # publish, and a plain re-run would then fail with "file already exists". --skip-existing
81
+ # makes a retry idempotent (already-uploaded files are skipped, not errored); the loop
82
+ # rides out a transient network blip instead of failing the whole release on it.
83
+ for attempt in 1 2 3; do
84
+ if twine upload --non-interactive --skip-existing dist/*; then
85
+ echo "upload ok (attempt $attempt)"; exit 0
86
+ fi
87
+ echo "::warning::twine upload attempt $attempt failed (likely a transient upload.pypi.org timeout) — retrying"
88
+ sleep 20
89
+ done
90
+ echo "::error::twine upload failed after 3 attempts"; exit 1
@@ -2,6 +2,48 @@
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.8] — 2026-07-26
6
+
7
+ One command. The paid capabilities are now subcommands of `mcpgawk`, not a separate binary.
8
+
9
+ ### Changed
10
+
11
+ - **`mcpgawk --help` now lists the gawk Platform capabilities** (`verify`, `enforce`, `monitor`,
12
+ `build`) so you can see what a subscription adds without installing anything. Running one on a
13
+ free install prints a single line explaining what it is and where to get it, and exits 3 — no
14
+ Python traceback, and never a silent success.
15
+ - **The free scanner is unchanged and stays free.** `mcpgawk scan` needs no licence, no account and
16
+ no network beyond the servers you point it at. Nothing in this release adds a paid dependency:
17
+ the paid engine is not in this package and the import that reaches for it is optional.
18
+
19
+ ### Why one command
20
+
21
+ The paid tier used to install a second executable named `gawk`. That name belongs to **GNU AWK** —
22
+ it owns `/usr/bin/gawk` across the Debian family and supplies `/usr/bin/awk` there through the
23
+ alternatives system, so shipping our own `gawk` could break a machine's `awk`. Debian Policy §10.1
24
+ requires a rename when two packages claim one path, and Homebrew's `gawk` formula *is* GNU awk.
25
+ Projects that hit this before us all retreated: ast-grep deprecated its colliding `sg`, `fd` ships
26
+ as `fdfind`, `bat` as `batcat`.
27
+
28
+ One command plus a licence unlock is also the ordinary shape for this kind of tool — Semgrep, Snyk,
29
+ GitLab and Terraform all do it. "gawk" remains the brand (gawk.dev); `mcpgawk` is the command.
30
+
31
+ ## [0.1.7] — 2026-07-23
32
+
33
+ Engine sync release. Dispatch-aware drift, credential-safe fleet URLs, sturdier baselines.
34
+
35
+ ### Added
36
+
37
+ - `fingerprint.py` — one shared surface-fingerprint used by both drift and the fleet view, so the
38
+ two can no longer disagree about whether a server changed.
39
+
40
+ ### Changed
41
+
42
+ - **Fleet URLs are redacted for display.** A secret-named query parameter (`apiKey=`, `token=`) or
43
+ userinfo in a server URL no longer renders in the fleet listing. The real URL is untouched for
44
+ connecting — only what gets shown is masked.
45
+ - Drift and history handling hardened around dispatch-style servers and baseline recording.
46
+
5
47
  ## [0.1.6] — 2026-07-22
6
48
 
7
49
  The drift release. Everything here is about one question a fresh look at your machine can never
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpgawk
3
- Version: 0.1.7
3
+ Version: 0.1.8
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mcpgawk"
7
- version = "0.1.7"
7
+ version = "0.1.8"
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"
@@ -0,0 +1,155 @@
1
+ """The one answer to "what did this server look like when I trusted it".
2
+
3
+ The product kept three memories of that: `history.py` (scan), `packages/verify/src/pins.ts`
4
+ (verify) and `monitor/store.py` (the daemon). You could approve a server in one and still be told
5
+ it had drifted by another, which is why no flow felt joined up — there wasn't one spine, there were
6
+ three.
7
+
8
+ This is the spine. It is deliberately a NARROW VIEW over the store that already exists
9
+ (`history.json`) rather than a fourth file: a new store would have to be migrated into, kept in
10
+ sync, and would itself become another memory to disagree with. The canonical record was already
11
+ there — `servers[key]["approved"]` — it simply had no name and no reader outside scan.
12
+
13
+ What makes one shared baseline possible at all is that the fingerprint is now identical everywhere:
14
+ `fingerprint.surface_pin` is computed by scan (through `measure`), by monitor, and — since the
15
+ schema-2.0 change — byte-for-byte by verify in TypeScript. Before that, sharing a baseline would
16
+ have meant sharing a number the three pillars each interpreted differently.
17
+
18
+ Shape (stable, and the contract other runtimes read through `mcpgawk baseline --json`):
19
+
20
+ {"schema": "gawk.baseline/1",
21
+ "servers": {"<key>": {"pin": "<16-hex>", "tools": {"<name>": "<hash>"},
22
+ "approved_at": "<iso8601>", "aliases": [...]}}}
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+ from . import history
30
+
31
+ #: Bumped only when the exported shape changes incompatibly. Readers check it rather than guessing.
32
+ SCHEMA = "gawk.baseline/1"
33
+
34
+
35
+ def approved_record(key: str, path: str | None = None) -> dict[str, Any] | None:
36
+ """The full approved record for one server, or None if nothing has been approved yet."""
37
+ store = history.load(path or history.default_path())
38
+ return history.approved(store, key)
39
+
40
+
41
+ def approved_pin(key: str, path: str | None = None) -> str | None:
42
+ """The approved whole-surface pin — the single value every pillar compares against.
43
+
44
+ None means "never approved", which is NOT the same as "approved as empty": a caller that
45
+ conflates them reports drift against a baseline that does not exist, on a server the operator
46
+ has never looked at. Trust-on-first-use is scan's job (see history.record); this only reports.
47
+ """
48
+ rec = approved_record(key, path)
49
+ return (rec or {}).get("pin")
50
+
51
+
52
+ def approved_tools(key: str, path: str | None = None) -> dict[str, str]:
53
+ """`{tool name: hash}` from the approved record. Empty dict when nothing is approved."""
54
+ rec = approved_record(key, path) or {}
55
+ tools = rec.get("tools")
56
+ return dict(tools) if isinstance(tools, dict) else {}
57
+
58
+
59
+ def approved_annotations(key: str, path: str | None = None) -> dict[str, dict[str, Any]]:
60
+ """`{tool name: annotations}` as they were WHEN APPROVED.
61
+
62
+ The distinction that makes this worth having: `enforce` can already derive a policy from the
63
+ annotations a server declares at connect time — but that is the server's CURRENT claim about
64
+ itself. A server that quietly flips `readOnlyHint` to true would have the guard relax to match
65
+ it. Deriving from the approved record instead means the policy reflects the operator's decision,
66
+ and a server changing its story does not change what it is allowed to do.
67
+
68
+ Records store typed keys (`tool.read_file`) because a prompt and a tool may share a name; only
69
+ tools carry scopes, so only tools come back.
70
+ """
71
+ rec = approved_record(key, path) or {}
72
+ raw = rec.get("annotations")
73
+ if not isinstance(raw, dict):
74
+ return {}
75
+ out: dict[str, dict[str, Any]] = {}
76
+ for ident, ann in raw.items():
77
+ if not isinstance(ident, str) or not ident.startswith("tool."):
78
+ continue
79
+ if isinstance(ann, dict):
80
+ out[ident[len("tool."):]] = dict(ann)
81
+ return out
82
+
83
+
84
+ def export(path: str | None = None) -> dict[str, Any]:
85
+ """The whole approved baseline, in the shape other runtimes read.
86
+
87
+ Only APPROVED state crosses this boundary — never the sighting history. What a server looked
88
+ like last Tuesday is scan's business; what the operator has agreed to trust is everyone's.
89
+ """
90
+ store = history.load(path or history.default_path())
91
+ out: dict[str, Any] = {}
92
+ for key, entry in (store.get("servers") or {}).items():
93
+ if not isinstance(entry, dict):
94
+ continue
95
+ rec = entry.get("approved")
96
+ if not isinstance(rec, dict):
97
+ continue # seen but never approved — deliberately absent, not exported as empty
98
+ out[key] = {
99
+ "pin": rec.get("pin"),
100
+ "tools": dict(rec.get("tools") or {}),
101
+ "approved_at": rec.get("measured_at"),
102
+ "aliases": list(entry.get("aliases") or []),
103
+ "annotations": {
104
+ ident[len("tool."):]: dict(ann)
105
+ for ident, ann in (rec.get("annotations") or {}).items()
106
+ if isinstance(ident, str) and ident.startswith("tool.") and isinstance(ann, dict)
107
+ },
108
+ }
109
+ return {"schema": SCHEMA, "servers": out}
110
+
111
+
112
+ def publish(key: str, *, pin: str, tools: dict[str, str], approved_at: str,
113
+ alias: str | None = None, path: str | None = None) -> None:
114
+ """Write an approval INTO the spine from another pillar.
115
+
116
+ The read path (`export`) alone makes the spine a one-way mirror: verify and monitor could see
117
+ what scan approved, but an operator approving a drift in `gawk-monitor approve` left scan still
118
+ reporting it. A shared baseline that only one pillar can move is three memories again, with
119
+ extra steps.
120
+
121
+ Deliberately narrow — pin, per-tool hashes, when. A monitor Snapshot carries more (grade,
122
+ signals, verification state) and that stays in monitor's own store: the spine is the answer to
123
+ "what surface did we agree to trust", not a general-purpose replica.
124
+ """
125
+ p = path or history.default_path()
126
+ with history.locked(p):
127
+ store = history.load(p)
128
+ entry = store.setdefault("servers", {}).setdefault(key, {})
129
+ entry["approved"] = {
130
+ **(entry.get("approved") or {}),
131
+ "measured_at": approved_at,
132
+ "pin": pin,
133
+ "tools": dict(tools),
134
+ }
135
+ if alias:
136
+ entry["aliases"] = sorted(set(entry.get("aliases", [])) | {alias})
137
+ history.save(store, p)
138
+
139
+
140
+ def resolve(name: str, path: str | None = None) -> str | None:
141
+ """Map a name the user typed to the key the baseline is stored under.
142
+
143
+ A server's identity key and the name it is configured under in an AI tool are not the same
144
+ string, and the same server is routinely configured under different names in different tools.
145
+ Without this, `approve slack` in one pillar and a lookup of `slack-mcp` in another miss each
146
+ other and the shared baseline silently does nothing.
147
+ """
148
+ store = history.load(path or history.default_path())
149
+ servers = store.get("servers") or {}
150
+ if name in servers:
151
+ return name
152
+ for key, entry in servers.items():
153
+ if isinstance(entry, dict) and name in (entry.get("aliases") or []):
154
+ return key
155
+ return None
@@ -127,7 +127,16 @@ def build_parser() -> argparse.ArgumentParser:
127
127
 
128
128
  Whether tracking defaults on is a product invariant (ADR-0012 N2), not a detail — asserting it
129
129
  needs a parser you can build without running a scan."""
130
- p = argparse.ArgumentParser(prog="mcpgawk", description="gawk at an MCP server before you trust it")
130
+ p = argparse.ArgumentParser(
131
+ prog="mcpgawk",
132
+ description="gawk at an MCP server before you trust it",
133
+ epilog=(
134
+ "gawk Platform capabilities (£29/month — https://mcp.gawk.dev/pricing.html):\n"
135
+ + "".join(f" {c:<9} {d}\n" for c, d in PLATFORM_CAPABILITIES.items())
136
+ + "Run `mcpgawk <capability>` once subscribed; scan stays free and open-source."
137
+ ),
138
+ formatter_class=argparse.RawDescriptionHelpFormatter,
139
+ )
131
140
  sub = p.add_subparsers(dest="cmd", required=True)
132
141
  s = sub.add_parser("scan", help="measure MCP server(s) locally")
133
142
  s.add_argument("config", nargs="?", help="path to an mcp.json config")
@@ -175,6 +184,18 @@ def build_parser() -> argparse.ArgumentParser:
175
184
  # The other half of a sticky alarm. Drift now re-reports on every scan until acknowledged
176
185
  # (ADR-0012 N1), so there MUST be an obvious way to acknowledge it — an alarm a user cannot
177
186
  # clear is one they will silence with --no-track, which costs them the baseline entirely.
187
+ b = sub.add_parser(
188
+ "baseline",
189
+ help="print the approved baseline — what you have agreed to trust, per server",
190
+ description=(
191
+ "The shared baseline every pillar compares against. `verify` and `monitor` read this "
192
+ "so that approving a server once is approving it everywhere, instead of each keeping "
193
+ "its own memory and contradicting the others."
194
+ ),
195
+ )
196
+ b.add_argument("--json", action="store_true", help="machine-readable (the cross-runtime shape)")
197
+ b.add_argument("--server", metavar="NAME", help="one server (name or alias) instead of all")
198
+
178
199
  a = sub.add_parser("approve",
179
200
  help="accept a server's current tools as the trusted baseline (clears DRIFT)")
180
201
  a.add_argument("server", nargs="?",
@@ -185,6 +206,48 @@ def build_parser() -> argparse.ArgumentParser:
185
206
  return p
186
207
 
187
208
 
209
+ def _baseline(args) -> int:
210
+ """Print what the operator has agreed to trust — the spine other pillars read.
211
+
212
+ Only approved state is exported. A sighting is not a baseline: printing the last thing seen
213
+ would hand verify and monitor the poisoned surface as though it were trusted, which is the
214
+ exact failure ADR-0012 exists to prevent.
215
+ """
216
+ from . import baseline as _baseline_mod
217
+
218
+ data = _baseline_mod.export(getattr(args, "history", None))
219
+ servers = data["servers"]
220
+
221
+ if args.server:
222
+ key = _baseline_mod.resolve(args.server, getattr(args, "history", None))
223
+ if key is None or key not in servers:
224
+ print(f"mcpgawk: nothing approved for '{args.server}'. "
225
+ f"Run `mcpgawk scan`, then `mcpgawk approve {args.server}`.", file=sys.stderr)
226
+ return 2
227
+ servers = {key: servers[key]}
228
+ data = {**data, "servers": servers}
229
+
230
+ if args.json:
231
+ print(json.dumps(data, indent=2, sort_keys=True))
232
+ return 0
233
+
234
+ if not servers:
235
+ print("Nothing approved yet. Run `mcpgawk scan`, then `mcpgawk approve <server>` to set "
236
+ "the baseline that verify and monitor will compare against.")
237
+ return 0
238
+
239
+ print(f"Approved baseline — {len(servers)} server(s). "
240
+ f"verify and monitor compare against exactly this.\n")
241
+ for key in sorted(servers):
242
+ rec = servers[key]
243
+ alias = f" ({', '.join(rec['aliases'])})" if rec.get("aliases") else ""
244
+ print(f" {key}{alias}")
245
+ print(f" pin {rec.get('pin') or '—'}")
246
+ print(f" tools {len(rec.get('tools') or {})}")
247
+ print(f" approved {rec.get('approved_at') or '—'}")
248
+ return 0
249
+
250
+
188
251
  def _approve(args) -> int:
189
252
  """`mcpgawk approve` — move the trusted baseline forward, deliberately."""
190
253
  path = history.default_path()
@@ -222,10 +285,70 @@ def _approve(args) -> int:
222
285
  return 0
223
286
 
224
287
 
288
+ # The paid capabilities, reachable as `mcpgawk <capability>` when gawk Platform is installed.
289
+ #
290
+ # ONE BINARY, on purpose (2026-07-26). Two reasons, both load-bearing:
291
+ # 1. `gawk` cannot be an executable name. It is GNU AWK — it owns /usr/bin/gawk across the
292
+ # Debian family and supplies /usr/bin/awk there through the alternatives system, so our own
293
+ # `gawk` on PATH risks breaking a machine's `awk`. Debian Policy §10.1 requires one of two
294
+ # colliding programs to be renamed; Homebrew's `gawk` formula is GNU awk, so the name is
295
+ # simply unavailable. Everyone who tried this retreated: ast-grep deprecated `sg`, fd ships
296
+ # as `fdfind`, bat as `batcat`.
297
+ # 2. A second binary for the paid tier is against convention anyway. Semgrep, Snyk, GitLab and
298
+ # Terraform all keep ONE command and unlock with a licence/login. A separate binary is only
299
+ # conventional when the paid thing is a different artefact (Docker Desktop). Ours isn't — it
300
+ # is more analysis on the same inputs.
301
+ #
302
+ # They are listed in `--help` even when unavailable: a free user should be able to SEE what the
303
+ # subscription adds without installing anything, and get one honest line if they try it.
304
+ PLATFORM_CAPABILITIES = {
305
+ "verify": "run a server in a no-egress sandbox and reproduce misbehaviour",
306
+ "enforce": "guard a live server, call by call",
307
+ "monitor": "watch a server for drift after you approved it",
308
+ "build": "generate a server from an OpenAPI spec (in development)",
309
+ }
310
+
311
+ _PLATFORM_UNAVAILABLE = (
312
+ "mcpgawk {cap}: {desc}.\n"
313
+ "This is a gawk Platform capability and it isn't installed in this environment.\n"
314
+ " £29/month, 7-day free trial — https://mcp.gawk.dev/pricing.html\n"
315
+ " Already subscribed? Your purchase email has the install instructions.\n"
316
+ "The free scanner (`mcpgawk scan`) stays free and open-source either way."
317
+ )
318
+
319
+
320
+ def _run_platform_capability(capability: str, rest: list[str]) -> int:
321
+ """Delegate to gawk Platform if it is installed, else say so honestly and exit 3.
322
+
323
+ The import is deliberately OPTIONAL and local: the free scanner is published to PyPI on its
324
+ own and must never depend on, or ship, the paid engine. A paid install supersedes the free
325
+ distribution in place (same distribution name), which is the GitLab CE→EE shape.
326
+ """
327
+ try:
328
+ from gawk_platform.cli import run_pillar
329
+ except ImportError:
330
+ print(
331
+ _PLATFORM_UNAVAILABLE.format(capability=capability, cap=capability,
332
+ desc=PLATFORM_CAPABILITIES[capability]),
333
+ file=sys.stderr,
334
+ )
335
+ return 3 # the same "not licensed / not available" code every paid entry point returns
336
+ return run_pillar(capability, rest)
337
+
338
+
225
339
  def main(argv: list[str] | None = None) -> int:
340
+ # Intercept the paid capabilities BEFORE argparse: their arguments are the pillar's own, and
341
+ # the free parser must not try to validate them.
342
+ raw = list(sys.argv[1:] if argv is None else argv)
343
+ if raw and raw[0] in PLATFORM_CAPABILITIES:
344
+ return _run_platform_capability(raw[0], raw[1:])
345
+
226
346
  p = build_parser()
227
347
  args = p.parse_args(argv)
228
348
 
349
+ if args.cmd == "baseline":
350
+ return _baseline(args)
351
+
229
352
  if args.cmd == "approve":
230
353
  return _approve(args)
231
354
 
@@ -0,0 +1,108 @@
1
+ """One baseline, read by every pillar.
2
+
3
+ The product kept three memories of "what did this server look like when I trusted it":
4
+ history.py (scan), packages/verify/src/pins.ts (verify) and monitor/store.py (the daemon). You
5
+ could approve a server in one and still be told it had drifted by another — which is why no flow
6
+ felt joined up. This is the shared view they all read.
7
+
8
+ Deliberately a NARROW VIEW over history.json rather than a fourth file: a new store would need
9
+ migrating into, keeping in sync, and would itself become another memory to disagree with.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ from mcpgawk import baseline, history
21
+
22
+ REC = {
23
+ "measured_at": "2026-07-27T10:00:00Z",
24
+ "pin": "abc123def4567890",
25
+ "tools": {"read_file": "9c98141c4c35", "write_file": "aac5a0de16f1"},
26
+ }
27
+
28
+
29
+ @pytest.fixture
30
+ def store(tmp_path: Path) -> str:
31
+ return str(tmp_path / "history.json")
32
+
33
+
34
+ def test_nothing_is_approved_until_someone_approves_it(store):
35
+ history.record("srv", REC, path=store)
36
+ # First sighting is trust-on-first-use in scan, so this IS approved; a second, different
37
+ # sighting must NOT move it.
38
+ history.record("srv", {**REC, "pin": "ffffffffffffffff"}, path=store)
39
+ assert baseline.approved_pin("srv", store) == REC["pin"], "a sighting is not an approval"
40
+
41
+
42
+ def test_approve_moves_the_shared_baseline(store):
43
+ history.record("srv", REC, path=store)
44
+ moved = {**REC, "pin": "ffffffffffffffff", "tools": {"read_file": "111111111111"}}
45
+ history.record("srv", moved, path=store)
46
+ history.approve("srv", path=store)
47
+ assert baseline.approved_pin("srv", store) == "ffffffffffffffff"
48
+ assert baseline.approved_tools("srv", store) == {"read_file": "111111111111"}
49
+
50
+
51
+ def test_a_name_the_user_typed_resolves_to_the_key_it_is_stored_under(store):
52
+ """Identity key and configured name are different strings, and the same server is routinely
53
+ configured under different names in different tools. Without this the shared baseline silently
54
+ does nothing for half the fleet."""
55
+ history.record("mcp:abc123", REC, path=store, alias="slack")
56
+ assert baseline.resolve("slack", store) == "mcp:abc123"
57
+ assert baseline.resolve("mcp:abc123", store) == "mcp:abc123"
58
+ assert baseline.resolve("never-heard-of-it", store) is None
59
+
60
+
61
+ def test_export_carries_only_approved_state(store):
62
+ """A sighting must never cross the boundary: handing verify the last thing SEEN would give it
63
+ the poisoned surface as though it were trusted."""
64
+ history.record("approved-one", REC, path=store)
65
+ history.approve("approved-one", path=store)
66
+
67
+ raw = json.loads(Path(store).read_text())
68
+ raw["servers"]["seen-only"] = {"seen": [REC]} # sighted, never approved
69
+ Path(store).write_text(json.dumps(raw))
70
+
71
+ data = baseline.export(store)
72
+ assert data["schema"] == baseline.SCHEMA
73
+ assert "approved-one" in data["servers"]
74
+ assert "seen-only" not in data["servers"]
75
+
76
+
77
+ def test_never_approved_is_none_not_empty(store):
78
+ """'Never approved' and 'approved as empty' must not collapse: the second reports drift on a
79
+ server the operator has never looked at."""
80
+ assert baseline.approved_pin("unknown", store) is None
81
+ assert baseline.approved_tools("unknown", store) == {}
82
+
83
+
84
+ def test_the_cli_emits_the_cross_runtime_shape(store, tmp_path):
85
+ """`mcpgawk baseline --json` is the contract verify reads. If this shape moves, verify breaks."""
86
+ history.record("srv", REC, path=store)
87
+ history.approve("srv", path=store)
88
+
89
+ r = subprocess.run(
90
+ [sys.executable, "-m", "mcpgawk.cli", "baseline", "--json"],
91
+ capture_output=True, text=True, timeout=60,
92
+ env={"PATH": "/usr/bin:/bin", "MCPGAWK_HISTORY": store, "HOME": str(tmp_path)},
93
+ )
94
+ assert r.returncode == 0, r.stdout + r.stderr
95
+ data = json.loads(r.stdout)
96
+ assert data["schema"] == "gawk.baseline/1"
97
+ assert data["servers"]["srv"]["tools"] == REC["tools"]
98
+ assert data["servers"]["srv"]["pin"] == REC["pin"]
99
+
100
+
101
+ def test_asking_for_an_unapproved_server_fails_loudly(store, tmp_path):
102
+ r = subprocess.run(
103
+ [sys.executable, "-m", "mcpgawk.cli", "baseline", "--server", "nope"],
104
+ capture_output=True, text=True, timeout=60,
105
+ env={"PATH": "/usr/bin:/bin", "MCPGAWK_HISTORY": store, "HOME": str(tmp_path)},
106
+ )
107
+ assert r.returncode == 2
108
+ assert "nothing approved" in r.stderr.lower()
@@ -0,0 +1,70 @@
1
+ """What a FREE install does when someone types a paid capability.
2
+
3
+ Public-safe by construction: this file must never import gawk_platform, because it is synced into
4
+ the public mcpgawk repo where the paid engine does not exist. It covers the path every free user
5
+ can hit — `mcpgawk verify ...` with nothing but the free scanner installed.
6
+
7
+ The shape it locks: ONE binary. `gawk` cannot be an executable name (it is GNU AWK, which owns
8
+ /usr/bin/gawk across the Debian family and supplies /usr/bin/awk there via the alternatives
9
+ system), and one-command-plus-licence-unlock is the convention for this product type — Semgrep,
10
+ Snyk, GitLab, Terraform all do it. So the paid capabilities live under `mcpgawk`, and a free user
11
+ must get ONE honest line, never an ImportError traceback and never a silent success.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import builtins
16
+
17
+ import pytest
18
+
19
+ from mcpgawk import cli as free_cli
20
+
21
+
22
+ @pytest.fixture
23
+ def no_platform(monkeypatch):
24
+ """Simulate a free-only install even when the paid engine happens to be importable."""
25
+ real_import = builtins.__import__
26
+
27
+ def _import(name, *a, **kw):
28
+ if name.startswith("gawk_platform"):
29
+ raise ImportError("No module named 'gawk_platform'")
30
+ return real_import(name, *a, **kw)
31
+
32
+ monkeypatch.setattr(builtins, "__import__", _import)
33
+
34
+
35
+ def test_paid_capabilities_are_advertised_in_free_help():
36
+ """A free user should SEE what the subscription adds without installing anything."""
37
+ help_text = free_cli.build_parser().format_help()
38
+ for capability in free_cli.PLATFORM_CAPABILITIES:
39
+ assert capability in help_text
40
+ assert "gawk Platform" in help_text
41
+ assert "pricing" in help_text
42
+
43
+
44
+ @pytest.mark.parametrize("capability", ["verify", "enforce", "monitor", "build"])
45
+ def test_paid_capability_on_a_free_install_exits_3_with_one_actionable_line(
46
+ capability, no_platform, capsys
47
+ ):
48
+ rc = free_cli.main([capability, "whatever.json"])
49
+
50
+ assert rc == 3, "the same 'not available/not licensed' code every paid entry point returns"
51
+ err = capsys.readouterr().err
52
+ assert "gawk Platform" in err
53
+ assert "pricing.html" in err
54
+ assert "free" in err.lower(), "must reassure that the free scanner is unaffected"
55
+ assert "Traceback" not in err
56
+
57
+
58
+ def test_the_hint_never_leaks_a_python_import_error(no_platform, capsys):
59
+ """An ImportError reaching the user is the failure mode this replaced."""
60
+ free_cli.main(["verify"])
61
+ err = capsys.readouterr().err
62
+ assert "ImportError" not in err
63
+ assert "gawk_platform" not in err, "internal module names are not a customer-facing detail"
64
+
65
+
66
+ def test_free_scan_is_unaffected_by_the_paid_dispatch(no_platform):
67
+ """The whole point of the free tier: scan needs no licence and no platform."""
68
+ with pytest.raises(SystemExit) as exc:
69
+ free_cli.main(["scan", "--help"])
70
+ assert exc.value.code == 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes