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 +16 -0
- mcpgawk/__main__.py +6 -0
- mcpgawk/cli.py +121 -0
- mcpgawk/drift.py +84 -0
- mcpgawk/history.py +49 -0
- mcpgawk/label.py +89 -0
- mcpgawk/measure.py +103 -0
- mcpgawk/probe.py +129 -0
- mcpgawk/servercard.py +69 -0
- mcpgawk/signals.py +129 -0
- mcpgawk-0.1.0.dist-info/METADATA +130 -0
- mcpgawk-0.1.0.dist-info/RECORD +16 -0
- mcpgawk-0.1.0.dist-info/WHEEL +4 -0
- mcpgawk-0.1.0.dist-info/entry_points.txt +2 -0
- mcpgawk-0.1.0.dist-info/licenses/LICENSE +201 -0
- mcpgawk-0.1.0.dist-info/licenses/NOTICE +10 -0
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
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
|
+
[](https://pypi.org/project/mcpgawk/)
|
|
41
|
+
[](https://pypi.org/project/mcpgawk/)
|
|
42
|
+
[](LICENSE)
|
|
43
|
+
[](https://github.com/gawk-dev/mcpgawk/actions/workflows/ci.yml)
|
|
44
|
+
[](#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,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.
|