mcpgawk 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,16 @@
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
9
+ from .measure import Measurement, measure
10
+ from .label import build_label
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = [
14
+ "ServerSnapshot", "probe_stdio", "probe_http", "probe_sse",
15
+ "Measurement", "measure", "build_label", "__version__",
16
+ ]
mcpgawk/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
mcpgawk/cli.py ADDED
@@ -0,0 +1,121 @@
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 datetime import datetime, timezone
18
+
19
+ from . import drift, history
20
+ from .label import build_label, render_cli, render_summary
21
+ from .measure import measure
22
+ from .probe import ServerSnapshot, probe, probe_http, probe_sse, probe_stdio
23
+ from .signals import as_dicts, detect, detect_card_mismatch, detect_shadowing
24
+
25
+
26
+ def _load_config(path: str) -> dict:
27
+ with open(path, encoding="utf-8") as f:
28
+ data = json.load(f)
29
+ return data.get("mcpServers", data)
30
+
31
+
32
+ async def _scan_config(cfg: dict, only: set[str] | None) -> list[ServerSnapshot]:
33
+ targets = [(n, e) for n, e in cfg.items() if not only or n in only]
34
+ return await asyncio.gather(*(probe(e, n) for n, e in targets))
35
+
36
+
37
+ def _headers(pairs: list[str] | None) -> dict[str, str]:
38
+ out = {}
39
+ for p in pairs or []:
40
+ k, _, v = p.partition(":")
41
+ out[k.strip()] = v.strip()
42
+ return out
43
+
44
+
45
+ async def _run(args) -> list[ServerSnapshot]:
46
+ if args.stdio:
47
+ parts = shlex.split(args.stdio)
48
+ return [await probe_stdio("cli-stdio", parts[0], parts[1:])]
49
+ if args.http:
50
+ return [await probe_http("cli-http", args.http, _headers(args.header))]
51
+ if args.sse:
52
+ return [await probe_sse("cli-sse", args.sse, _headers(args.header))]
53
+ only = set(args.only.split(",")) if args.only else None
54
+ return await _scan_config(_load_config(args.config), only)
55
+
56
+
57
+ def main(argv: list[str] | None = None) -> int:
58
+ p = argparse.ArgumentParser(prog="mcpgawk", description="gawk at an MCP server before you trust it")
59
+ sub = p.add_subparsers(dest="cmd", required=True)
60
+ s = sub.add_parser("scan", help="measure MCP server(s) locally")
61
+ s.add_argument("config", nargs="?", help="path to an mcp.json config")
62
+ s.add_argument("--stdio", help='one stdio server, e.g. "npx -y @modelcontextprotocol/server-filesystem /tmp"')
63
+ s.add_argument("--http", help="one streamable-HTTP server URL")
64
+ s.add_argument("--sse", help="one SSE server URL")
65
+ s.add_argument("--header", action="append", help='HTTP header, e.g. "Authorization: Bearer XYZ" (repeatable)')
66
+ s.add_argument("--only", help="comma-separated server names to scan from the config")
67
+ s.add_argument("--no-signals", action="store_true", help="skip BOUNDED heuristic signals (facts only)")
68
+ s.add_argument("--track", action="store_true",
69
+ help="record this scan locally and report DRIFT vs the last sighting (rug-pull detection)")
70
+ s.add_argument("--json", action="store_true", help="emit JSON labels instead of a table")
71
+ args = p.parse_args(argv)
72
+
73
+ if args.cmd == "scan" and not (args.config or args.stdio or args.http or args.sse):
74
+ p.error("give a config path or one of --stdio/--http/--sse")
75
+
76
+ snaps = asyncio.run(_run(args))
77
+ measurements = [measure(sn) for sn in snaps]
78
+ # Cross-server shadowing needs all snapshots together; merge into each involved server's signals.
79
+ shadow = {} if args.no_signals else detect_shadowing(snaps)
80
+ labels = []
81
+ for sn, m in zip(snaps, measurements):
82
+ sigs = None
83
+ if not args.no_signals:
84
+ sigs = (as_dicts(detect(sn)) + as_dicts(shadow.get(sn.name, []))
85
+ + as_dicts(detect_card_mismatch(sn)))
86
+ labels.append(build_label(sn, m, bounded_signals=(sigs or None)))
87
+
88
+ # --track: record locally and diff against the last sighting (rug-pull detection).
89
+ drift_reports: dict[str, drift.DriftReport] = {}
90
+ if args.track:
91
+ now = datetime.now(timezone.utc).isoformat(timespec="seconds")
92
+ store = history.load()
93
+ for sn, m in zip(snaps, measurements):
94
+ if sn.error:
95
+ continue
96
+ key = history.key_for(sn)
97
+ record = drift.build_record(sn, m, measured_at=now)
98
+ rep = drift.compare(history.last(store, key), record)
99
+ if rep and rep.any:
100
+ drift_reports[sn.name] = rep
101
+ history.append(store, key, record)
102
+ history.save(store)
103
+
104
+ if args.json:
105
+ print(json.dumps(labels, indent=2))
106
+ return 0
107
+
108
+ print(f"\n{'='*70}\nmcpgawk 0.1 — LOCAL scan (no inventory uploaded)\n{'='*70}")
109
+ any_error = False
110
+ for lab in labels:
111
+ print("\n" + render_cli(lab))
112
+ rep = drift_reports.get(lab["name"])
113
+ if rep:
114
+ print(drift.render(lab["name"], rep))
115
+ any_error = any_error or bool(lab["x-mcpgawk"].get("caveats"))
116
+ print("\n" + render_summary(labels) + "\n")
117
+ return 1 if any_error else 0
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main())
mcpgawk/drift.py ADDED
@@ -0,0 +1,84 @@
1
+ """Drift / rug-pull detection — pure diff over stored measurements.
2
+
3
+ The integrity pin (measure.py) already changes when a server silently rewrites its tools. Drift
4
+ turns that into an actionable, per-tool diff: what was ADDED, REMOVED, or CHANGED (same tool name,
5
+ different description = the classic tool-poisoning rug-pull signature) since you last trusted it.
6
+
7
+ Pure functions, no I/O, no clock (the caller stamps time). history.py handles the local store.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from .measure import Measurement
16
+ from .probe import ServerSnapshot
17
+
18
+
19
+ def _tool_hashes(snap: ServerSnapshot) -> dict[str, str]:
20
+ """name -> hash(description). Detects silent description edits per tool."""
21
+ return {t.get("name", "?"): hashlib.sha256((t.get("description") or "").encode()).hexdigest()[:12]
22
+ for t in snap.tools}
23
+
24
+
25
+ def build_record(snap: ServerSnapshot, m: Measurement, measured_at: str | None = None) -> dict[str, Any]:
26
+ """A storable snapshot: enough to diff, small enough to keep forever."""
27
+ return {
28
+ "measured_at": measured_at,
29
+ "pin": m.integrity_pin,
30
+ "tool_count": m.tool_count,
31
+ "cost_index": m.total_tokens,
32
+ "tokenizer": m.tokenizer, # so token_delta isn't compared across tokenizers
33
+ "protocol_version": snap.protocol_version,
34
+ "tools": _tool_hashes(snap),
35
+ }
36
+
37
+
38
+ @dataclass
39
+ class DriftReport:
40
+ pin_changed: bool
41
+ added: list[str]
42
+ removed: list[str]
43
+ changed: list[str] # same name, description hash differs = rug-pull signature
44
+ token_delta: int
45
+ prev_at: str | None
46
+
47
+ @property
48
+ def any(self) -> bool:
49
+ return self.pin_changed or bool(self.added or self.removed or self.changed)
50
+
51
+
52
+ def compare(prev: dict[str, Any] | None, curr: dict[str, Any]) -> DriftReport | None:
53
+ """None if there's no prior record (first sighting — nothing to drift from)."""
54
+ if not prev:
55
+ return None
56
+ pa, ca = prev.get("tools", {}), curr.get("tools", {})
57
+ added = sorted(set(ca) - set(pa))
58
+ removed = sorted(set(pa) - set(ca))
59
+ changed = sorted(n for n in (set(pa) & set(ca)) if pa[n] != ca[n])
60
+ # token_delta is only meaningful when both runs used the same tokenizer; else it lies (an
61
+ # index change from swapping tiktoken, not a real server change). Rug-pull detection (pin +
62
+ # description hashes) is tokenizer-independent, so it stays correct.
63
+ same_tok = prev.get("tokenizer") == curr.get("tokenizer")
64
+ delta = (curr.get("cost_index", 0) - prev.get("cost_index", 0)) if same_tok else 0
65
+ return DriftReport(
66
+ pin_changed=prev.get("pin") != curr.get("pin"),
67
+ added=added, removed=removed, changed=changed,
68
+ token_delta=delta,
69
+ prev_at=prev.get("measured_at"),
70
+ )
71
+
72
+
73
+ def render(name: str, r: DriftReport) -> str:
74
+ since = f" since {r.prev_at}" if r.prev_at else ""
75
+ lines = [f" ⟳ DRIFT on {name}{since} — server changed after you last saw it:"]
76
+ if r.changed:
77
+ lines.append(f" ! description CHANGED (rug-pull signature): {', '.join(r.changed)}")
78
+ if r.added:
79
+ lines.append(f" + tools added: {', '.join(r.added)}")
80
+ if r.removed:
81
+ lines.append(f" - tools removed: {', '.join(r.removed)}")
82
+ if r.token_delta:
83
+ lines.append(f" Δ cost index: {r.token_delta:+d} tok")
84
+ return "\n".join(lines)
mcpgawk/history.py ADDED
@@ -0,0 +1,49 @@
1
+ """Local, human-readable drift history. JSON on disk — never leaves the machine.
2
+
3
+ Default: $MCPGAWK_HISTORY or ~/.mcpgawk/history.json. This is the ONLY state mcpgawk persists,
4
+ and it's the user's own machine. No sync, no cloud.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from typing import Any
11
+
12
+ from .probe import ServerSnapshot
13
+
14
+
15
+ def default_path() -> str:
16
+ return os.environ.get("MCPGAWK_HISTORY") or os.path.expanduser("~/.mcpgawk/history.json")
17
+
18
+
19
+ def load(path: str | None = None) -> dict[str, Any]:
20
+ path = path or default_path()
21
+ try:
22
+ with open(path, encoding="utf-8") as f:
23
+ return json.load(f)
24
+ except (FileNotFoundError, json.JSONDecodeError):
25
+ return {"servers": {}}
26
+
27
+
28
+ def save(store: dict[str, Any], path: str | None = None) -> None:
29
+ path = path or default_path()
30
+ os.makedirs(os.path.dirname(path), exist_ok=True)
31
+ tmp = path + ".tmp"
32
+ with open(tmp, "w", encoding="utf-8") as f:
33
+ json.dump(store, f, indent=2, sort_keys=True)
34
+ os.replace(tmp, path) # atomic
35
+
36
+
37
+ def key_for(snap: ServerSnapshot) -> str:
38
+ return f"{snap.transport}:{snap.name}"
39
+
40
+
41
+ def last(store: dict[str, Any], key: str) -> dict[str, Any] | None:
42
+ hist = store.get("servers", {}).get(key, {}).get("history", [])
43
+ return hist[-1] if hist else None
44
+
45
+
46
+ def append(store: dict[str, Any], key: str, record: dict[str, Any], keep: int = 50) -> None:
47
+ hist = store.setdefault("servers", {}).setdefault(key, {}).setdefault("history", [])
48
+ hist.append(record)
49
+ del hist[:-keep] # bounded — keep the last `keep` sightings
mcpgawk/label.py ADDED
@@ -0,0 +1,89 @@
1
+ """ATTEST — build the MCP Label as a Server-Card extension, and render it.
2
+
3
+ The label complements the emerging LF `.well-known` Server Card standard rather than competing:
4
+ standard-ish card fields at the top, our measurements under the `x-mcpgawk` namespace. Nothing
5
+ here is a verdict — token cost is a named index; capabilities are facts; there are no risk
6
+ 'scores' in v1.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .measure import Measurement
13
+ from .probe import ServerSnapshot
14
+ from .servercard import compare_to_reality
15
+
16
+ LABEL_SCHEMA = "mcpgawk/label@0.1"
17
+
18
+
19
+ def build_label(snap: ServerSnapshot, m: Measurement, measured_at: str | None = None,
20
+ bounded_signals: list[dict[str, Any]] | None = None) -> dict[str, Any]:
21
+ """measured_at is passed in (the library never reads the clock — keeps output reproducible).
22
+ bounded_signals (optional) are BOUNDED-layer heuristic findings — kept in their own key,
23
+ never merged into the EXACT tool facts."""
24
+ return {
25
+ # ---- Server-Card-compatible surface ----
26
+ "name": snap.name,
27
+ "transport": snap.transport,
28
+ "protocolVersion": snap.protocol_version,
29
+ "serverInfo": snap.server_info or None,
30
+ # ---- our independent measurement extension ----
31
+ "x-mcpgawk": {
32
+ "schema": LABEL_SCHEMA,
33
+ "measured_at": measured_at,
34
+ "tokenizer": m.tokenizer,
35
+ "cost_index_tokens": m.total_tokens, # INDEX (tokenizer named above)
36
+ "tool_count": m.tool_count,
37
+ "prompt_count": m.prompt_count,
38
+ "resource_count": m.resource_count,
39
+ "integrity_pin": m.integrity_pin, # EXACT — rug-pull anchor
40
+ # server's self-declaration checked against what we measured (http/sse only)
41
+ "server_card": (compare_to_reality(snap.server_card, [t.name for t in m.tools])
42
+ if snap.server_card else {"present": False}),
43
+ "tools": [
44
+ {"name": t.name, "tokens": t.tokens,
45
+ "write": t.write, "exfil_capable": t.exfil_capable,
46
+ "annotations": t.annotations or None}
47
+ for t in m.tools
48
+ ],
49
+ "caveats": m.caveats or None,
50
+ # BOUNDED layer — heuristic signals, kept apart from the EXACT facts above.
51
+ "bounded_signals": bounded_signals or None,
52
+ "disclaimer": "Local measurement. Token cost is a comparable index, not an absolute "
53
+ "Claude count. Capabilities are structural facts, not risk verdicts. "
54
+ "bounded_signals are heuristic pointers for a human to review, not verdicts.",
55
+ },
56
+ }
57
+
58
+
59
+ def render_cli(label: dict[str, Any]) -> str:
60
+ x = label["x-mcpgawk"]
61
+ lines = [
62
+ f"● {label['name']:<22} [{label['transport']}] proto={label.get('protocolVersion') or '?'}",
63
+ f" {x['tool_count']:>3} tools {x['cost_index_tokens']:>6} tok@connect pin:{x['integrity_pin']}",
64
+ ]
65
+ flagged = [t for t in x["tools"] if t["write"] or t["exfil_capable"]]
66
+ for t in flagged:
67
+ caps = []
68
+ if t["write"]:
69
+ caps.append("write" if (t.get("annotations") or {}) else "write/no-annotation")
70
+ if t["exfil_capable"]:
71
+ caps.append("exfil-capable")
72
+ if (t.get("annotations") or {}).get("destructiveHint") is True:
73
+ caps.append("destructive-declared")
74
+ lines.append(f" · {t['name']:<28} {t['tokens']:>5} tok {', '.join(caps)}")
75
+ for s in (x.get("bounded_signals") or []):
76
+ lines.append(f" ⚠ SIGNAL {s['kind']:<26} [{s['tool']}] — review: {s['evidence']!r}")
77
+ if x.get("caveats"):
78
+ lines.append(f" ! {'; '.join(x['caveats'])}")
79
+ return "\n".join(lines)
80
+
81
+
82
+ def render_summary(labels: list[dict[str, Any]]) -> str:
83
+ tools = sum(l["x-mcpgawk"]["tool_count"] for l in labels)
84
+ toks = sum(l["x-mcpgawk"]["cost_index_tokens"] for l in labels)
85
+ flagged = sum(1 for l in labels for t in l["x-mcpgawk"]["tools"] if t["write"] or t["exfil_capable"])
86
+ tzs = {l["x-mcpgawk"]["tokenizer"] for l in labels}
87
+ return ("-" * 70 + f"\nTOTAL: {tools} tools | {toks} tok loaded at connect | "
88
+ f"{flagged} capability-flagged | tokenizer: {', '.join(tzs)}\n"
89
+ "Nothing was uploaded. Re-run for identical numbers.")
mcpgawk/measure.py ADDED
@@ -0,0 +1,103 @@
1
+ """BOUND — measure a snapshot. Pure, offline, deterministic.
2
+
3
+ The EXACT/INDEX/BOUNDED wall (enforced here):
4
+ * EXACT — structural capability facts + integrity pin. Facts, not estimates.
5
+ * INDEX — token cost via a *named* tokenizer (cl100k). A comparable ranking index, NOT
6
+ an absolute Claude count (tiktoken undercounts Claude ~15-20%). Honestly labelled.
7
+ * BOUNDED— heuristic risk signals. NOT in v1 (security is a 0-FP fast-follow). Kept out of
8
+ this module entirely so an estimate can never contaminate a fact.
9
+
10
+ No network. No LLM. Scanning the inventory is pure computation.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from .probe import ServerSnapshot
21
+
22
+ TOKENIZER_NAME = "cl100k_base (approx index; not Claude-exact)"
23
+
24
+ # Structural capability detectors — deliberately conservative, fact-level only.
25
+ _WRITE = re.compile(
26
+ r"\b(create|delete|remove|write|update|send|post|put|patch|execute|run|modify|drop|"
27
+ r"insert|upload|push|merge|deploy|revoke|grant|edit|rename|move|set|add)\b", re.I)
28
+ _EXFIL_PARAM = re.compile(r"\b(url|uri|endpoint|webhook|href|callback|redirect)\b", re.I)
29
+ _EXFIL_NAME = re.compile(r"\b(fetch|http|request|download|browse|scrape|curl|web)\b", re.I)
30
+
31
+
32
+ @dataclass
33
+ class ToolMeasure:
34
+ name: str
35
+ tokens: int # INDEX
36
+ write: bool # EXACT (structural)
37
+ exfil_capable: bool # EXACT (structural)
38
+ annotations: dict[str, Any] # EXACT (declared)
39
+
40
+
41
+ @dataclass
42
+ class Measurement:
43
+ tokenizer: str
44
+ total_tokens: int # INDEX — sum at connect
45
+ tool_count: int
46
+ tools: list[ToolMeasure]
47
+ integrity_pin: str # EXACT — rug-pull anchor
48
+ prompt_count: int = 0
49
+ resource_count: int = 0
50
+ caveats: list[str] = field(default_factory=list)
51
+
52
+
53
+ def _encoder():
54
+ try:
55
+ import tiktoken
56
+ return tiktoken.get_encoding("cl100k_base"), TOKENIZER_NAME
57
+ except Exception:
58
+ return None, "chars/4 (rough fallback — tiktoken unavailable)"
59
+
60
+
61
+ def _count(enc, text: str) -> int:
62
+ return len(enc.encode(text)) if enc is not None else max(1, len(text) // 4)
63
+
64
+
65
+ def _exfil_capable(tool: dict[str, Any]) -> bool:
66
+ if _EXFIL_NAME.search(tool.get("name", "") + " " + (tool.get("description") or "")):
67
+ return True
68
+ props = ((tool.get("inputSchema") or {}).get("properties") or {})
69
+ return any(_EXFIL_PARAM.search(k) for k in props)
70
+
71
+
72
+ def _is_write(tool: dict[str, Any], ann: dict[str, Any]) -> bool:
73
+ if ann.get("readOnlyHint") is True: # declared read-only wins over the verb heuristic
74
+ return False
75
+ text = tool.get("name", "") + " " + (tool.get("description") or "")
76
+ return bool(_WRITE.search(text))
77
+
78
+
79
+ def measure(snap: ServerSnapshot, enc=None, tokenizer_name: str | None = None) -> Measurement:
80
+ if enc is None and tokenizer_name is None:
81
+ enc, tokenizer_name = _encoder()
82
+ tools: list[ToolMeasure] = []
83
+ total = 0
84
+ for t in snap.tools:
85
+ # Tokenise exactly what a model's context would carry for this tool.
86
+ blob = json.dumps({k: t.get(k) for k in ("name", "description", "inputSchema", "annotations")
87
+ if t.get(k) is not None}, sort_keys=True)
88
+ tk = _count(enc, blob)
89
+ total += tk
90
+ ann = t.get("annotations") or {}
91
+ tools.append(ToolMeasure(
92
+ name=t.get("name", "?"), tokens=tk,
93
+ write=_is_write(t, ann), exfil_capable=_exfil_capable(t), annotations=ann))
94
+ # Integrity pin = stable hash of the (name, description) pairs the server presents.
95
+ pin_src = json.dumps(sorted((t.get("name"), t.get("description")) for t in snap.tools),
96
+ sort_keys=True).encode()
97
+ pin = hashlib.sha256(pin_src).hexdigest()[:16]
98
+ m = Measurement(
99
+ tokenizer=tokenizer_name, total_tokens=total, tool_count=len(tools), tools=tools,
100
+ integrity_pin=pin, prompt_count=len(snap.prompts), resource_count=len(snap.resources))
101
+ if snap.error:
102
+ m.caveats.append(f"probe error: {snap.error}")
103
+ return m
mcpgawk/probe.py ADDED
@@ -0,0 +1,129 @@
1
+ """OBSERVE — connect to an MCP server via the official `mcp` SDK and snapshot it.
2
+
3
+ We use the maintained protocol client (`ClientSession` + the stdio/streamable-http/sse
4
+ transports). The SDK negotiates the protocol version and tracks the spec by definition, so
5
+ mcpgawk rides protocol evolution instead of owning a stale fork. We are a *one-shot client*,
6
+ not a man-in-the-middle proxy — that keeps us off the runtime-enforcement lane.
7
+
8
+ Egress note: the only network here is the SDK talking to the server being scanned. Nothing
9
+ about the captured inventory is sent anywhere else.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from mcp import ClientSession, StdioServerParameters
19
+ from mcp.client.stdio import stdio_client
20
+ from mcp.client.sse import sse_client
21
+ from mcp.client.streamable_http import streamablehttp_client
22
+
23
+ from .servercard import fetch_card
24
+
25
+
26
+ @dataclass
27
+ class ServerSnapshot:
28
+ """Everything we captured from one server. Raw wire-shape tool dicts, so measurement
29
+ sees exactly what a model's context would see."""
30
+ name: str
31
+ transport: str
32
+ protocol_version: str | None
33
+ tools: list[dict[str, Any]] = field(default_factory=list)
34
+ prompts: list[dict[str, Any]] = field(default_factory=list)
35
+ resources: list[dict[str, Any]] = field(default_factory=list)
36
+ server_info: dict[str, Any] = field(default_factory=dict)
37
+ server_card: dict[str, Any] | None = None # self-declared .well-known card (http/sse only)
38
+ error: str | None = None
39
+
40
+
41
+ def _dump(items: list[Any]) -> list[dict[str, Any]]:
42
+ """Pydantic model -> wire-shape dict (by_alias gives `inputSchema`, `readOnlyHint`, ...)."""
43
+ out = []
44
+ for it in items:
45
+ if hasattr(it, "model_dump"):
46
+ out.append(it.model_dump(by_alias=True, exclude_none=True, mode="json"))
47
+ elif isinstance(it, dict):
48
+ out.append(it)
49
+ return out
50
+
51
+
52
+ async def _snapshot(session: ClientSession, name: str, transport: str) -> ServerSnapshot:
53
+ init = await session.initialize()
54
+ snap = ServerSnapshot(
55
+ name=name,
56
+ transport=transport,
57
+ protocol_version=getattr(init, "protocolVersion", None),
58
+ server_info=(init.serverInfo.model_dump(mode="json") if getattr(init, "serverInfo", None) else {}),
59
+ )
60
+ # tools/list is the load-bearing surface; prompts/resources are optional per server.
61
+ snap.tools = _dump((await session.list_tools()).tools)
62
+ for attr, method in (("prompts", "list_prompts"), ("resources", "list_resources")):
63
+ try:
64
+ res = await getattr(session, method)()
65
+ setattr(snap, attr, _dump(getattr(res, attr)))
66
+ except Exception:
67
+ pass # server doesn't advertise that capability — not an error for us
68
+ return snap
69
+
70
+
71
+ # Per-server wall-clock bound. A hung / slow-loris server must degrade to ONE error row, never
72
+ # block the whole scan. Generous enough for a cold npx/uvx first-run install.
73
+ DEFAULT_TIMEOUT = 90.0
74
+
75
+
76
+ async def _bounded(coro_factory, name: str, transport: str, timeout: float) -> ServerSnapshot:
77
+ try:
78
+ return await asyncio.wait_for(coro_factory(), timeout)
79
+ except Exception as e: # noqa: BLE001 — incl. TimeoutError; surface, never crash the scan
80
+ return ServerSnapshot(name=name, transport=transport, protocol_version=None,
81
+ error=f"{type(e).__name__}: {e}")
82
+
83
+
84
+ async def probe_stdio(name: str, command: str, args: list[str] | None = None,
85
+ env: dict[str, str] | None = None, timeout: float = DEFAULT_TIMEOUT) -> ServerSnapshot:
86
+ async def _do():
87
+ params = StdioServerParameters(command=command, args=args or [],
88
+ env={**os.environ, **(env or {})})
89
+ async with stdio_client(params) as (read, write):
90
+ async with ClientSession(read, write) as session:
91
+ return await _snapshot(session, name, "stdio")
92
+ return await _bounded(_do, name, "stdio", timeout)
93
+
94
+
95
+ async def probe_http(name: str, url: str, headers: dict[str, str] | None = None,
96
+ timeout: float = DEFAULT_TIMEOUT) -> ServerSnapshot:
97
+ """Streamable-HTTP transport — for hosted MCPs. `headers` carries a user-supplied bearer/OAuth
98
+ token for the MCP connection ONLY; the public Server Card fetch never sees it (see servercard.py)."""
99
+ async def _do():
100
+ async with streamablehttp_client(url, headers=headers or {}) as (read, write, _sid):
101
+ async with ClientSession(read, write) as session:
102
+ snap = await _snapshot(session, name, "http")
103
+ snap.server_card = await fetch_card(url) # public, unauthenticated; tolerant
104
+ return snap
105
+ return await _bounded(_do, name, "http", timeout)
106
+
107
+
108
+ async def probe_sse(name: str, url: str, headers: dict[str, str] | None = None,
109
+ timeout: float = DEFAULT_TIMEOUT) -> ServerSnapshot:
110
+ async def _do():
111
+ async with sse_client(url, headers=headers or {}) as (read, write):
112
+ async with ClientSession(read, write) as session:
113
+ snap = await _snapshot(session, name, "sse")
114
+ snap.server_card = await fetch_card(url)
115
+ return snap
116
+ return await _bounded(_do, name, "sse", timeout)
117
+
118
+
119
+ async def probe(entry: dict[str, Any], name: str) -> ServerSnapshot:
120
+ """Dispatch a config entry (mcp.json shape) to the right transport."""
121
+ if entry.get("command"):
122
+ return await probe_stdio(name, entry["command"], entry.get("args"), entry.get("env"))
123
+ url = entry.get("url")
124
+ if not url:
125
+ return ServerSnapshot(name=name, transport="?", protocol_version=None,
126
+ error="entry has neither `command` (stdio) nor `url` (http/sse)")
127
+ transport = entry.get("transport", "http")
128
+ headers = entry.get("headers")
129
+ return await (probe_sse if transport == "sse" else probe_http)(name, url, headers)
mcpgawk/servercard.py ADDED
@@ -0,0 +1,69 @@
1
+ """Server Card reader — read a server's self-declaration and check it against reality.
2
+
3
+ SEP-1649 / SEP-2127: an HTTP MCP server MAY publish `/.well-known/mcp/server-card.json` describing
4
+ itself for pre-connection discovery. mcpgawk reads it when present, but does NOT trust it: we still
5
+ live-connect and MEASURE, then compare. A card that *under-declares* (hides tools it actually
6
+ exposes) is a trust signal — exactly the independent-measurement value mcpgawk exists for.
7
+
8
+ Tolerant by design: any fetch/parse failure -> no card -> fall back to live measurement. The card
9
+ fetch is to the server being scanned (the one allowed network touch), same as connecting. Applies to
10
+ HTTP/SSE servers only (stdio has no well-known URL).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+ from urllib.parse import urlsplit, urlunsplit
16
+
17
+ WELL_KNOWN = "/.well-known/mcp/server-card.json"
18
+
19
+
20
+ def card_url_for(server_url: str) -> str:
21
+ p = urlsplit(server_url)
22
+ return urlunsplit((p.scheme, p.netloc, WELL_KNOWN, "", ""))
23
+
24
+
25
+ def _looks_like_card(d: Any) -> bool:
26
+ return isinstance(d, dict) and bool(
27
+ d.get("serverInfo") or d.get("name") or d.get("protocolVersion") or "$schema" in d)
28
+
29
+
30
+ async def fetch_card(server_url: str, timeout: float = 8.0) -> dict[str, Any] | None:
31
+ """GET the well-known card. Returns the parsed card, or None on any failure (tolerant).
32
+
33
+ SECURITY: Server Cards are PUBLIC pre-connection discovery, so we send NO auth headers and
34
+ do NOT follow redirects — otherwise a `.well-known` endpoint could 3xx to an attacker host and
35
+ (if we forwarded the user's bearer) leak their credential cross-origin. Neither is negotiable.
36
+ """
37
+ import httpx
38
+ try:
39
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as c:
40
+ r = await c.get(card_url_for(server_url)) # no headers — public, unauthenticated
41
+ if r.status_code != 200:
42
+ return None
43
+ data = r.json()
44
+ return data if _looks_like_card(data) else None
45
+ except Exception: # noqa: BLE001 — a missing/broken card must never break a scan
46
+ return None
47
+
48
+
49
+ def compare_to_reality(card: dict[str, Any], measured_tool_names: list[str]) -> dict[str, Any]:
50
+ """What the card declares vs what we actually measured."""
51
+ out: dict[str, Any] = {
52
+ "present": True,
53
+ "declared_version": card.get("version"),
54
+ "declared_protocol": card.get("protocolVersion"),
55
+ }
56
+ ct = card.get("tools")
57
+ if isinstance(ct, list):
58
+ declared = sorted({t.get("name") for t in ct if isinstance(t, dict) and t.get("name")})
59
+ real = sorted(set(measured_tool_names))
60
+ undeclared = sorted(set(real) - set(declared)) # present but hidden from the card
61
+ phantom = sorted(set(declared) - set(real)) # claimed but not actually present
62
+ out.update({
63
+ "declared_tool_count": len(declared),
64
+ "measured_tool_count": len(real),
65
+ "undeclared_tools": undeclared, # the trust-relevant case
66
+ "phantom_tools": phantom,
67
+ "matches_reality": not (undeclared or phantom),
68
+ })
69
+ return out
mcpgawk/signals.py ADDED
@@ -0,0 +1,129 @@
1
+ """BOUND / BOUNDED layer — heuristic risk *signals*, kept physically apart from measure.py.
2
+
3
+ The wall: this module has NO token math and NO capability facts; measure.py has NO signals.
4
+ An estimate can therefore never contaminate a fact.
5
+
6
+ 0-FP discipline (authdrift lesson): every detector here is deliberately *precise* — it fires only
7
+ on language aimed at the reader/model, never on legitimate tool capability keywords (a `url` param
8
+ or a `delete` verb is a FACT, handled in measure.py, not a signal here). A detector ships only after
9
+ a measured 0 false positives on a real clean corpus (see tests/test_signals.py + the live FP gate).
10
+
11
+ A signal is a SIGNAL, never a verdict. We never say "server X is insecure".
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from .probe import ServerSnapshot
20
+
21
+ # --- Detector 1: hidden markup in a description (HTML comments / pseudo-system tags). ---
22
+ # Legit tool descriptions are plain prose; an embedded comment or <important>/<system> tag is
23
+ # the classic tool-poisoning carrier (Invariant's own local rule keyed on <IMPORTANT>).
24
+ _HIDDEN_MARKUP = re.compile(r"<!--.*?-->|<\s*/?\s*(important|system|secret|admin|instruction|tool_call)\b",
25
+ re.IGNORECASE | re.DOTALL)
26
+
27
+ # --- Detector 2: reader-directed instructions (the model is the audience, not the caller). ---
28
+ _READER_DIRECTED = re.compile(
29
+ r"ignore\s+(?:all\s+|the\s+|your\s+|any\s+)*(?:previous|prior|above|earlier|preceding)\s+"
30
+ r"(?:instruction|prompt|message|context)"
31
+ r"|disregard\s+(?:the\s+|all\s+|any\s+)*(?:previous|prior|above|earlier)"
32
+ r"|do\s*n?o?t?'?\s*(?:tell|inform|mention|reveal|notify|disclose)\s+(?:the\s+)?(?:user|human)"
33
+ r"|(?:you\s+are|act\s+as|you\s+must\s+act)\s+an?\s+(?:ai|assistant|language\s+model|agent)"
34
+ r"|(?:your|the)\s+system\s+prompt"
35
+ r"|new\s+instructions?\s*:",
36
+ re.IGNORECASE)
37
+
38
+ # --- Detector 3: secret-exfil directives (read a secret AND move it). ---
39
+ # Tightened after a real false positive: Vercel's `get_access_to_vercel_url` says "access ... without
40
+ # requiring login credentials" — benign. "access" and a bare "credential" are NOT suspicious. We fire on
41
+ # (a) reading an unambiguous secret FILE, or (b) a secret WORD paired with an explicit exfil verb.
42
+ # Only unambiguous secret FILES — a legit tool description has no reason to name these. Secret *words*
43
+ # ("api key", "password", "credential") appear in countless benign descriptions, so they are NOT used
44
+ # (that was the FP source: "pass/fail with an API key", "access without credentials", etc.). Other
45
+ # injection styles are still caught by the hidden-markup and reader-directed detectors above.
46
+ _SECRET_FILE = r"(?:\.env\b|~/\.ssh|id_rsa|/etc/passwd|/etc/shadow)"
47
+ _EXFIL_VERB = r"(?:pass|send|include|attach|exfiltrat\w*|leak|upload|post|forward)"
48
+ _EXFIL_DIRECTIVE = re.compile(
49
+ r"(?:read|open|cat|load|retrieve|dump)\b[^.]{0,40}" + _SECRET_FILE # read a secret file
50
+ + r"|" + _SECRET_FILE + r"[^.]{0,40}\b" + _EXFIL_VERB, # secret file → moved out
51
+ re.IGNORECASE | re.DOTALL)
52
+
53
+ _DETECTORS = (
54
+ ("injection:hidden-markup", _HIDDEN_MARKUP),
55
+ ("injection:reader-directed", _READER_DIRECTED),
56
+ ("injection:secret-exfil", _EXFIL_DIRECTIVE),
57
+ )
58
+
59
+
60
+ @dataclass
61
+ class Finding:
62
+ tool: str
63
+ kind: str
64
+ evidence: str # the matched span — so a human can judge, we never auto-verdict
65
+ confidence: str = "signal" # never "confirmed"; this layer only signals
66
+
67
+
68
+ def _scan_text(text: str, tool: str) -> list[Finding]:
69
+ out: list[Finding] = []
70
+ for kind, rx in _DETECTORS:
71
+ m = rx.search(text or "")
72
+ if m:
73
+ span = m.group(0).strip()
74
+ out.append(Finding(tool=tool, kind=kind, evidence=span[:120]))
75
+ return out
76
+
77
+
78
+ def detect(snap: ServerSnapshot) -> list[Finding]:
79
+ """Run the BOUNDED detectors over the injection surface: tool AND prompt descriptions.
80
+ (Prompts are model-facing text too — the tool-poisoning surface isn't only tools/list.)"""
81
+ findings: list[Finding] = []
82
+ for t in snap.tools:
83
+ findings.extend(_scan_text(t.get("description") or "", t.get("name", "?")))
84
+ for pr in snap.prompts:
85
+ findings.extend(_scan_text(pr.get("description") or "", f"prompt:{pr.get('name', '?')}"))
86
+ return findings
87
+
88
+
89
+ def detect_shadowing(snaps: list[ServerSnapshot]) -> dict[str, list[Finding]]:
90
+ """CROSS-SERVER signal: a tool name exposed by more than one server. All connected servers share
91
+ one context, so a malicious server can register the same name as a trusted one and shadow it
92
+ (mcp-secret-exfil-threat-model: 'cross-tool shadowing'). Naturally 0-FP — fires only on a genuine
93
+ collision between distinct servers. Returns {server_name -> [Finding]}.
94
+ """
95
+ owners: dict[str, set[str]] = {}
96
+ for s in snaps:
97
+ for t in s.tools:
98
+ owners.setdefault(t.get("name", "?"), set()).add(s.name)
99
+ out: dict[str, list[Finding]] = {}
100
+ for s in snaps:
101
+ for t in s.tools:
102
+ nm = t.get("name", "?")
103
+ others = owners.get(nm, set()) - {s.name}
104
+ if others:
105
+ out.setdefault(s.name, []).append(Finding(
106
+ tool=nm, kind="shadowing:name-collision",
107
+ evidence=f"also exposed by: {', '.join(sorted(others))}"))
108
+ return out
109
+
110
+
111
+ def detect_card_mismatch(snap: ServerSnapshot) -> list[Finding]:
112
+ """Signal: the server's public .well-known card UNDER-DECLARES — hides tools it actually exposes.
113
+ A fact-based, 0-FP signal (fires only on a real declared-vs-measured gap). Independent
114
+ measurement catching a self-declaration that doesn't match reality."""
115
+ if not snap.server_card:
116
+ return []
117
+ from .servercard import compare_to_reality
118
+ cmp = compare_to_reality(snap.server_card, [t.get("name", "?") for t in snap.tools])
119
+ out: list[Finding] = []
120
+ if cmp.get("undeclared_tools"):
121
+ u = cmp["undeclared_tools"]
122
+ out.append(Finding(tool="<server-card>", kind="servercard:undeclared-tools",
123
+ evidence=f"exposes {len(u)} tool(s) absent from its public card: {', '.join(u[:6])}"))
124
+ return out
125
+
126
+
127
+ def as_dicts(findings: list[Finding]) -> list[dict[str, Any]]:
128
+ return [{"tool": f.tool, "kind": f.kind, "evidence": f.evidence, "confidence": f.confidence}
129
+ for f in findings]
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcpgawk
3
+ Version: 0.1.0
4
+ Summary: Local-first MCP measurement — gawk at an MCP server before you trust it. No inventory ever leaves your machine.
5
+ Project-URL: Homepage, https://mcp.gawk.dev
6
+ Project-URL: Documentation, https://mcp.gawk.dev
7
+ Project-URL: Repository, https://github.com/gawk-dev/mcpgawk
8
+ Project-URL: Issues, https://github.com/gawk-dev/mcpgawk/issues
9
+ Author: Neelagiri
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Keywords: agents,cli,llm,local-first,mcp,model-context-protocol,security,tokenizer,tokens
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: Apache Software License
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: mcp<2,>=1.28
24
+ Requires-Dist: tiktoken>=0.7
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
+ Requires-Dist: pytest>=8; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ <p align="center">
31
+ <picture>
32
+ <source media="(prefers-color-scheme: dark)" srcset="assets/brand/wordmark-dark.png">
33
+ <img alt="mcpgawk by nativerse" src="assets/brand/wordmark-light.png" width="320">
34
+ </picture>
35
+ </p>
36
+ <p align="center"><em>gawk at it before you trust it.</em></p>
37
+
38
+ # mcpgawk
39
+
40
+ [![PyPI](https://img.shields.io/pypi/v/mcpgawk.svg)](https://pypi.org/project/mcpgawk/)
41
+ [![Python](https://img.shields.io/pypi/pyversions/mcpgawk.svg)](https://pypi.org/project/mcpgawk/)
42
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
43
+ [![CI](https://github.com/gawk-dev/mcpgawk/actions/workflows/ci.yml/badge.svg)](https://github.com/gawk-dev/mcpgawk/actions/workflows/ci.yml)
44
+ [![No egress](https://img.shields.io/badge/inventory-never%20uploaded-brightgreen.svg)](#guarantees)
45
+
46
+ **gawk at an MCP server before you trust it.** A single, local-first command that connects to any
47
+ [Model Context Protocol](https://modelcontextprotocol.io) server and measures what it will cost and
48
+ expose — **without the server's inventory ever leaving your machine.**
49
+
50
+ <p align="center">
51
+ <img src="assets/brand/demo.gif" alt="mcpgawk scanning an MCP server — tools, token cost, and capability flags, locally" width="760">
52
+ </p>
53
+ <p align="center"><sub>Real output. Reproducible on your machine — no account, nothing uploaded.</sub></p>
54
+
55
+ ## Why
56
+
57
+ Every MCP server you connect dumps *all* its tool definitions into your model's context at connect
58
+ time — whether you use one tool or none. That's a hidden **token tax** and an unvetted **trust
59
+ surface**. mcpgawk measures both, **locally**, and never phones home about what it saw.
60
+
61
+ ## How it's different
62
+
63
+ - **vs. cloud scanners** (e.g. Snyk/Invariant `mcp-scan`) — they upload your inventory to a server and
64
+ gate the verdict. mcpgawk runs entirely on your machine; nothing is uploaded, ever.
65
+ - **vs. lazy-load gateways** — they cut tokens but tell you nothing about the *risk* surface.
66
+ - **mcpgawk does both** — cost **and** trust — locally, reproducibly, in one command.
67
+
68
+ ## Features
69
+
70
+ - 🔌 **Any transport** — stdio, streamable-HTTP, SSE, and OAuth remotes (via the `mcp-remote` bridge).
71
+ - 💸 **Token cost index** — exactly what each tool adds to your context at connect.
72
+ - 🧾 **Capability facts** — write / exfil-capable / declared annotations, straight from the schema.
73
+ - 📌 **Integrity pin + drift** — catch a server that silently rewrites its tools (`--track`).
74
+ - 🚩 **Bounded signals** — injection-shaped descriptions, cross-server shadowing, under-declaring Server Cards — pointers for a human, never verdicts.
75
+ - 🔒 **Zero egress, by construction** — the measurement layers import no network library. Enforced by a test.
76
+
77
+ ## Install
78
+
79
+ ```bash
80
+ pip install mcpgawk # or: uv tool install mcpgawk
81
+ ```
82
+
83
+ ## Use
84
+
85
+ ```bash
86
+ mcpgawk scan mcp.json # a whole config
87
+ mcpgawk scan --stdio "npx -y @modelcontextprotocol/server-filesystem /tmp"
88
+ mcpgawk scan --http https://host/mcp --header "Authorization: Bearer $TOKEN"
89
+ mcpgawk scan --sse https://host/sse
90
+ mcpgawk scan mcp.json --track # record + detect rug-pulls over time
91
+ mcpgawk scan mcp.json --json # machine-readable labels
92
+ ```
93
+
94
+ ## What it reports
95
+
96
+ - **Cost index** — tokens each tool adds at connect (named tokenizer; a comparable index, not an
97
+ absolute Claude count).
98
+ - **Capability facts** — write/mutating, exfil-capable, declared annotations.
99
+ - **Integrity pin** — a hash that changes if the server silently rewrites its tools; `--track`
100
+ turns it into rug-pull detection over time.
101
+ - **Bounded signals** — precise, low-false-positive pointers *for a human to review*, never verdicts:
102
+ injection-shaped descriptions (tools **and** prompts), cross-server name shadowing, and public
103
+ Server Cards that under-declare what the server actually exposes.
104
+
105
+ ## Guarantees
106
+
107
+ - **No inventory egress.** The only network is the protocol client talking to the server you point
108
+ it at. The measurement layers import no network library — they *cannot* egress by construction
109
+ (enforced by a test). Public Server Card discovery is fetched with no auth and no redirect-following.
110
+ - **Facts ≠ heuristics.** Exact capability facts and the token index never mix with the bounded
111
+ heuristic signals — separate in code, separate in output.
112
+ - **Reproducible.** One command, identical numbers.
113
+ - **Rides protocol evolution.** Built on the official `mcp` SDK, which negotiates the protocol version.
114
+
115
+ ## Develop
116
+
117
+ ```bash
118
+ uv run --extra dev --with mcp --with tiktoken --with httpx python -m pytest -q
119
+ ```
120
+
121
+ ## Contributing
122
+
123
+ Issues and PRs welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) first, and see the design
124
+ boundaries in [THREAT-MODEL.md](THREAT-MODEL.md). Security reports go through [SECURITY.md](SECURITY.md)
125
+ (privately, not a public issue).
126
+
127
+ ## License
128
+
129
+ **Apache-2.0** — see [LICENSE](LICENSE). Part of the **nativerse** · gawk.dev family. The value is in the
130
+ repo, not a cloud.
@@ -0,0 +1,16 @@
1
+ mcpgawk/__init__.py,sha256=Q9bCMOJxfGthhAgB8WOk6svVW_G7j8w7EMfMhEHUiBc,554
2
+ mcpgawk/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
3
+ mcpgawk/cli.py,sha256=XXwCZVECkOL-_pj7YLzCkmE6aOJObyEFNv9A49udky8,5009
4
+ mcpgawk/drift.py,sha256=iDK5KFvCKGDmZpRXUk5g_TkoIz3NF1Nw6RNyPIRE4Ro,3447
5
+ mcpgawk/history.py,sha256=TivyJKqoGmhmO8kmUx7DnhgCIWI4tK6YuUBoZzY7Cr0,1593
6
+ mcpgawk/label.py,sha256=2pWUxX56nzPSMaFiWITQ00QRcFAkBAx6jLlXP8yFu7Q,4390
7
+ mcpgawk/measure.py,sha256=45kVD_GsRZRAB1oWOEBtbDNgXuaE_Y-SxJ4QnwVFb3E,4172
8
+ mcpgawk/probe.py,sha256=14y0-QnYDG31a-ayCjO9iRjvCyjc-byE-6qEhw-WtVI,5947
9
+ mcpgawk/servercard.py,sha256=T1wYIj0y2KPOLGRBJ3Ex8LrC2-xIV8K9IhPs0PAqKhY,3159
10
+ mcpgawk/signals.py,sha256=MJcWik5JQpgDu4zzLYPttBdgQng5iRk_0B7syc4GiH4,6405
11
+ mcpgawk-0.1.0.dist-info/METADATA,sha256=A0u4VJLZnrnNmzZMjOSaJ6ULBW00fSjtSBLMpFclagU,6228
12
+ mcpgawk-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
13
+ mcpgawk-0.1.0.dist-info/entry_points.txt,sha256=EeoYYkkcXlaHh2avJEY4LAc17mesGti3GyjR4SNz4Xo,45
14
+ mcpgawk-0.1.0.dist-info/licenses/LICENSE,sha256=kBVAMbcL7--sAlEG9JMSRTDhPGCLh2zBQYtfZbqUXxQ,11347
15
+ mcpgawk-0.1.0.dist-info/licenses/NOTICE,sha256=q0rLw5ACo07HFaLw8SNjQEZIsujEJni5_sHvDw3wv9k,601
16
+ mcpgawk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcpgawk = mcpgawk.cli:main
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2025 Invariant Labs AG
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,10 @@
1
+ The reference/ directory contains unmodified third-party source cloned for study,
2
+ each under the Apache License 2.0, © Invariant Labs / Snyk:
3
+
4
+ - invariantlabs-ai/invariant (reference/invariant-analyzer)
5
+ - invariantlabs-ai/invariant-gateway (reference/invariant-gateway)
6
+ - invariantlabs-ai/mcp-scan (reference/mcp-scan-hist, reference/recovered-local-engine)
7
+
8
+ These retain their upstream LICENSE files and git history. They are NOT our code and are
9
+ not vendored into this project's git history. Any code we derive from them will carry the
10
+ required Apache-2.0 attribution.