mcp-authlab 0.2.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.
@@ -0,0 +1,5 @@
1
+ """mcp-authlab: black-box conformance scanner for MCP server authorization."""
2
+
3
+ __version__ = "0.2.0"
4
+ SPEC_VERSION = "2025-06-18"
5
+ PROTOCOL_VERSION = "2025-06-18"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
mcp_authlab/catalog.py ADDED
@@ -0,0 +1,436 @@
1
+ """The check catalog: every check with the normative text it enforces.
2
+
3
+ Weights and grade thresholds live in grading.py, so a check here only says
4
+ what level the spec gives it and whether a failure is critical.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from . import SPEC_VERSION
13
+
14
+ AUTHZ = f"https://modelcontextprotocol.io/specification/{SPEC_VERSION}/basic/authorization"
15
+ BEST = f"https://modelcontextprotocol.io/specification/{SPEC_VERSION}/basic/security_best_practices"
16
+ TRANSPORT = f"https://modelcontextprotocol.io/specification/{SPEC_VERSION}/basic/transports"
17
+ DRAFT_AUTHZ = "https://modelcontextprotocol.io/specification/draft/basic/authorization"
18
+ RFC9728 = "https://www.rfc-editor.org/rfc/rfc9728"
19
+ RFC8414 = "https://www.rfc-editor.org/rfc/rfc8414"
20
+ RFC6750 = "https://www.rfc-editor.org/rfc/rfc6750"
21
+ RFC9207 = "https://www.rfc-editor.org/rfc/rfc9207"
22
+ RFC8707 = "https://www.rfc-editor.org/rfc/rfc8707"
23
+ RFC7636 = "https://www.rfc-editor.org/rfc/rfc7636"
24
+ OAUTH21 = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13"
25
+
26
+ PASS, FAIL, WARN, SKIP, ERROR = "pass", "fail", "warn", "skip", "error"
27
+ STATUSES = (PASS, FAIL, WARN, SKIP, ERROR)
28
+
29
+ # How a check gets its evidence. "scan" checks run against the resource server;
30
+ # "login" checks run during `mcp-authlab login` against the authorization server
31
+ # and are stored with the tokens, so a later scan can report them.
32
+ SCAN, LOGIN = "scan", "login"
33
+
34
+ GROUPS: list[tuple[str, str]] = [
35
+ ("TLS", "Transport"),
36
+ ("CHAL", "Challenge"),
37
+ ("PRM", "Protected Resource Metadata"),
38
+ ("ASM", "Authorization Server Metadata"),
39
+ ("ASF", "Authorization server flow"),
40
+ ("TOK", "Token validation"),
41
+ ("SCOPE", "Scope step-up"),
42
+ ("SESS", "Sessions"),
43
+ ("ORIG", "Origin"),
44
+ ("VER", "Protocol version"),
45
+ ]
46
+
47
+
48
+ @dataclass(frozen=True, kw_only=True)
49
+ class Check:
50
+ id: str
51
+ title: str
52
+ level: str # MUST or SHOULD
53
+ critical: bool # a failure that makes the grade F on its own
54
+ cite: str # human-readable citation
55
+ url: str
56
+ needs_token: bool = False
57
+ needs_foreign_token: bool = False
58
+ phase: str = SCAN
59
+ disputed: str | None = None # why operators push back, when the spec and the field disagree
60
+
61
+ @property
62
+ def group(self) -> str:
63
+ return self.id.split("-")[0]
64
+
65
+
66
+ @dataclass
67
+ class Result:
68
+ check: Check
69
+ status: str
70
+ message: str
71
+ evidence: dict[str, Any] = field(default_factory=dict)
72
+ applicable: bool = (
73
+ True # False when the server lacks the feature the check tests (a stateless server has no sessions)
74
+ )
75
+ effective_level: str | None = None # set by a policy downgrade
76
+ counted: bool = True # False when a policy ignores the check
77
+
78
+ @property
79
+ def level(self) -> str:
80
+ return self.effective_level or self.check.level
81
+
82
+ @property
83
+ def critical(self) -> bool:
84
+ return self.check.critical and self.effective_level is None
85
+
86
+ def to_dict(self) -> dict[str, Any]:
87
+ return {
88
+ "id": self.check.id,
89
+ "title": self.check.title,
90
+ "level": self.check.level,
91
+ "critical": self.check.critical,
92
+ "effective_level": self.level,
93
+ "effective_critical": self.critical,
94
+ "counted": self.counted,
95
+ "applicable": self.applicable,
96
+ "phase": self.check.phase,
97
+ "status": self.status,
98
+ "message": self.message,
99
+ "cite": self.check.cite,
100
+ "url": self.check.url,
101
+ "evidence": self.evidence,
102
+ }
103
+
104
+ @classmethod
105
+ def from_dict(cls, d: dict[str, Any]) -> Result:
106
+ return cls(BY_ID[d["id"]], d["status"], d["message"], dict(d.get("evidence") or {}), d.get("applicable", True))
107
+
108
+
109
+ _ORIG_DISPUTE = (
110
+ "The transports spec says servers MUST validate Origin to stop DNS rebinding. Operators point out that "
111
+ "non-browser clients don't send Origin, so a server that only checks Origin when it's present is also "
112
+ "defending the browser case. This check sends a hostile Origin, so a server that validates when present "
113
+ "still passes; the pushback is from operators who skip the header entirely because their clients are CLIs."
114
+ )
115
+ _VER_DISPUTE = (
116
+ "The transports spec says a server MUST respond 400 to an unsupported MCP-Protocol-Version. Operators "
117
+ "prefer to answer with the newest version they support so older clients keep working. The spec text is "
118
+ "clear, so this stays MUST; use a policy downgrade if your organisation accepts version tolerance."
119
+ )
120
+
121
+ CATALOG: list[Check] = [
122
+ Check(
123
+ id="TLS-01",
124
+ title="MCP endpoint is served over HTTPS",
125
+ level="MUST",
126
+ critical=False,
127
+ cite="MCP Authorization, Communication Security; OAuth 2.1 section 1.5",
128
+ url=f"{AUTHZ}#communication-security",
129
+ ),
130
+ Check(
131
+ id="CHAL-01",
132
+ title="Unauthenticated request is refused with 401",
133
+ level="MUST",
134
+ critical=False,
135
+ cite="MCP Authorization, Error Handling; OAuth 2.1 section 5.3",
136
+ url=f"{AUTHZ}#error-handling",
137
+ ),
138
+ Check(
139
+ id="CHAL-02",
140
+ title="401 carries WWW-Authenticate: Bearer with resource_metadata",
141
+ level="MUST",
142
+ critical=False,
143
+ cite="MCP Authorization, Authorization Server Location; RFC 9728 section 5.1",
144
+ url=f"{AUTHZ}#authorization-server-location",
145
+ ),
146
+ Check(
147
+ id="CHAL-03",
148
+ title="401 challenge names the scopes to request",
149
+ level="SHOULD",
150
+ critical=False,
151
+ cite="MCP Authorization (draft), Scope Selection Strategy; RFC 6750 section 3",
152
+ url=f"{DRAFT_AUTHZ}#scope-selection-strategy",
153
+ ),
154
+ Check(
155
+ id="PRM-01",
156
+ title="Protected Resource Metadata is published at the RFC 9728 well-known location",
157
+ level="MUST",
158
+ critical=False,
159
+ cite="MCP Authorization, Overview item 3; RFC 9728 section 3",
160
+ url=f"{AUTHZ}#overview",
161
+ ),
162
+ Check(
163
+ id="PRM-02",
164
+ title="Metadata 'resource' equals the server's canonical URI",
165
+ level="MUST",
166
+ critical=False,
167
+ cite="RFC 9728 section 3.3; MCP Authorization, Canonical Server URI",
168
+ url=f"{AUTHZ}#canonical-server-uri",
169
+ ),
170
+ Check(
171
+ id="PRM-03",
172
+ title="Metadata lists at least one authorization server",
173
+ level="MUST",
174
+ critical=False,
175
+ cite="MCP Authorization, Authorization Server Location",
176
+ url=f"{AUTHZ}#authorization-server-location",
177
+ ),
178
+ Check(
179
+ id="PRM-04",
180
+ title="resource_metadata URL in the challenge resolves to the same document",
181
+ level="SHOULD",
182
+ critical=False,
183
+ cite="RFC 9728 section 5.1",
184
+ url=f"{RFC9728}#section-5.1",
185
+ ),
186
+ Check(
187
+ id="PRM-05",
188
+ title="scopes_supported avoids wildcard, omnibus, and offline_access scopes",
189
+ level="SHOULD",
190
+ critical=False,
191
+ cite="Security Best Practices, Scope Minimization; MCP Authorization (draft), Refresh Tokens",
192
+ url=f"{BEST}#scope-minimization",
193
+ ),
194
+ Check(
195
+ id="PRM-06",
196
+ title="Metadata keeps bearer tokens out of the query string",
197
+ level="SHOULD",
198
+ critical=False,
199
+ cite="MCP Authorization, Token Requirements; RFC 6750 section 2.3",
200
+ url=f"{AUTHZ}#token-requirements",
201
+ ),
202
+ Check(
203
+ id="ASM-01",
204
+ title="Each authorization server publishes discoverable metadata with the required fields",
205
+ level="MUST",
206
+ critical=False,
207
+ cite="MCP Authorization, Overview item 4; RFC 8414 section 2 and 3",
208
+ url=f"{AUTHZ}#server-metadata-discovery",
209
+ ),
210
+ Check(
211
+ id="ASM-02",
212
+ title="Authorization server 'issuer' matches the advertised URL",
213
+ level="MUST",
214
+ critical=False,
215
+ cite="RFC 8414 section 3.3",
216
+ url=f"{RFC8414}#section-3.3",
217
+ ),
218
+ Check(
219
+ id="ASM-03",
220
+ title="Authorization server endpoints use HTTPS",
221
+ level="MUST",
222
+ critical=False,
223
+ cite="MCP Authorization, Communication Security; OAuth 2.1 section 1.5",
224
+ url=f"{AUTHZ}#communication-security",
225
+ ),
226
+ Check(
227
+ id="ASM-04",
228
+ title="Authorization server advertises PKCE S256",
229
+ level="MUST",
230
+ critical=False,
231
+ cite="MCP Authorization, Authorization Code Protection; OAuth 2.1 section 7.5.2",
232
+ url=f"{AUTHZ}#authorization-code-protection",
233
+ ),
234
+ Check(
235
+ id="ASM-05",
236
+ title="A client registration path exists (dynamic registration or Client ID Metadata Documents)",
237
+ level="SHOULD",
238
+ critical=False,
239
+ cite="MCP Authorization, Dynamic Client Registration; draft, Client Registration",
240
+ url=f"{AUTHZ}#dynamic-client-registration",
241
+ ),
242
+ Check(
243
+ id="ASM-06",
244
+ title="Authorization server supports the RFC 9207 'iss' response parameter",
245
+ level="SHOULD",
246
+ critical=False,
247
+ cite="MCP Authorization (draft), Authorization Response Validation",
248
+ url=f"{DRAFT_AUTHZ}#authorization-response-validation",
249
+ ),
250
+ Check(
251
+ id="ASF-01",
252
+ title="Authorization endpoint refuses a request without code_challenge",
253
+ level="MUST",
254
+ critical=False,
255
+ cite="MCP Authorization, Authorization Code Protection; OAuth 2.1 section 7.5.2; RFC 7636 section 4.4.1",
256
+ url=f"{AUTHZ}#authorization-code-protection",
257
+ phase=LOGIN,
258
+ ),
259
+ Check(
260
+ id="ASF-02",
261
+ title="Token endpoint refuses a wrong code_verifier",
262
+ level="MUST",
263
+ critical=False,
264
+ cite="OAuth 2.1 section 4.1.3; RFC 7636 section 4.6",
265
+ url=f"{RFC7636}#section-4.6",
266
+ phase=LOGIN,
267
+ ),
268
+ Check(
269
+ id="ASF-03",
270
+ title="Token endpoint refuses a redirect_uri that differs from the authorization request",
271
+ level="MUST",
272
+ critical=False,
273
+ cite="OAuth 2.1 section 4.1.3; RFC 6749 section 4.1.3",
274
+ url=f"{OAUTH21}#section-4.1.3",
275
+ phase=LOGIN,
276
+ ),
277
+ Check(
278
+ id="ASF-04",
279
+ title="Issued access token is bound to the requested resource",
280
+ level="MUST",
281
+ critical=False,
282
+ cite="MCP Authorization, Resource Parameter Implementation; RFC 8707 section 2",
283
+ url=f"{AUTHZ}#resource-parameter-implementation",
284
+ phase=LOGIN,
285
+ ),
286
+ Check(
287
+ id="ASF-05",
288
+ title="Refresh tokens rotate for a public client",
289
+ level="SHOULD",
290
+ critical=False,
291
+ cite="OAuth 2.1 section 4.3.1: refresh tokens for public clients are sender-constrained or rotate",
292
+ url=f"{OAUTH21}#section-4.3.1",
293
+ phase=LOGIN,
294
+ ),
295
+ Check(
296
+ id="ASF-06",
297
+ title="Authorization server URLs are HTTPS and the callback carried iss when advertised",
298
+ level="MUST",
299
+ critical=False,
300
+ cite="MCP Authorization, Communication Security; RFC 9207 section 2",
301
+ url=f"{RFC9207}#section-2",
302
+ phase=LOGIN,
303
+ ),
304
+ Check(
305
+ id="TOK-01",
306
+ title="Malformed bearer token is refused with 401",
307
+ level="MUST",
308
+ critical=False,
309
+ cite="MCP Authorization, Token Handling; OAuth 2.1 section 5.2",
310
+ url=f"{AUTHZ}#token-handling",
311
+ ),
312
+ Check(
313
+ id="TOK-02",
314
+ title="Unsigned (alg=none) token with the right audience is refused",
315
+ level="MUST",
316
+ critical=True,
317
+ cite="MCP Authorization, Token Handling; Security Best Practices, Token Passthrough",
318
+ url=f"{BEST}#token-passthrough",
319
+ ),
320
+ Check(
321
+ id="TOK-03",
322
+ title="Valid token issued for a different resource is refused (audience binding)",
323
+ level="MUST",
324
+ critical=True,
325
+ cite="MCP Authorization, Access Token Privilege Restriction; RFC 8707 section 2",
326
+ url=f"{AUTHZ}#access-token-privilege-restriction",
327
+ needs_foreign_token=True,
328
+ ),
329
+ Check(
330
+ id="TOK-04",
331
+ title='Invalid-token 401 carries error="invalid_token"',
332
+ level="SHOULD",
333
+ critical=False,
334
+ cite="RFC 6750 section 3.1",
335
+ url=f"{RFC6750}#section-3.1",
336
+ ),
337
+ Check(
338
+ id="TOK-05",
339
+ title="A valid token in the query string is refused",
340
+ level="MUST",
341
+ critical=False,
342
+ cite="MCP Authorization, Token Requirements",
343
+ url=f"{AUTHZ}#token-requirements",
344
+ needs_token=True,
345
+ ),
346
+ Check(
347
+ id="TOK-06",
348
+ title="Valid token succeeds (initialize completes)",
349
+ level="MUST",
350
+ critical=False,
351
+ cite="MCP Authorization, Access Token Usage",
352
+ url=f"{AUTHZ}#access-token-usage",
353
+ needs_token=True,
354
+ ),
355
+ Check(
356
+ id="SCOPE-01",
357
+ title='A tool call the token can\'t make gets 403 with error="insufficient_scope" and a scope hint',
358
+ level="SHOULD",
359
+ critical=False,
360
+ cite="MCP Authorization (draft), Scope Selection Strategy; RFC 6750 section 3.1",
361
+ url=f"{DRAFT_AUTHZ}#scope-selection-strategy",
362
+ needs_token=True,
363
+ ),
364
+ Check(
365
+ id="SESS-01",
366
+ title="A session ID alone doesn't authenticate",
367
+ level="MUST",
368
+ critical=True,
369
+ cite="Security Best Practices, Session Hijacking: servers MUST NOT use sessions for authentication",
370
+ url=f"{BEST}#session-hijacking",
371
+ needs_token=True,
372
+ ),
373
+ Check(
374
+ id="SESS-02",
375
+ title="Session IDs are long, random, and non-sequential",
376
+ level="SHOULD",
377
+ critical=False,
378
+ cite="Transports, Session Management; Security Best Practices, Session Hijacking",
379
+ url=f"{TRANSPORT}#session-management",
380
+ needs_token=True,
381
+ ),
382
+ Check(
383
+ id="ORIG-01",
384
+ title="Request with a hostile Origin header is refused",
385
+ level="MUST",
386
+ critical=False,
387
+ cite="Transports, Security Warning: servers MUST validate the Origin header",
388
+ url=f"{TRANSPORT}#security-warning",
389
+ needs_token=True,
390
+ disputed=_ORIG_DISPUTE,
391
+ ),
392
+ Check(
393
+ id="VER-01",
394
+ title="Unsupported MCP-Protocol-Version is refused with 400",
395
+ level="MUST",
396
+ critical=False,
397
+ cite="Transports, Protocol Version Header",
398
+ url=f"{TRANSPORT}#protocol-version-header",
399
+ needs_token=True,
400
+ disputed=_VER_DISPUTE,
401
+ ),
402
+ ]
403
+ BY_ID: dict[str, Check] = {c.id: c for c in CATALOG}
404
+ CRITICAL_IDS: list[str] = [c.id for c in CATALOG if c.critical]
405
+
406
+
407
+ def needs_label(c: Check) -> str:
408
+ if c.phase == LOGIN:
409
+ return "`login`"
410
+ if c.needs_token:
411
+ return "`--token` or `login`"
412
+ if c.needs_foreign_token:
413
+ return "`--foreign-token` or `login`"
414
+ return ""
415
+
416
+
417
+ def markdown_table() -> str:
418
+ """The table in docs/checks.md. A test fails when that file is stale."""
419
+ lines = ["| ID | Level | Check | Needs | Normative source |", "|---|---|---|---|---|"]
420
+ for c in CATALOG:
421
+ lvl = c.level + ("\\*" if c.critical else "")
422
+ lines.append(f"| {c.id} | {lvl} | {c.title} | {needs_label(c)} | [{c.cite}]({c.url}) |")
423
+ lines.append("")
424
+ lines.append("MUST\\* is a critical check: one failure makes the conformance level Unsafe and the grade F.")
425
+ disputed = [c for c in CATALOG if c.disputed]
426
+ if disputed:
427
+ lines.append("")
428
+ lines.append("## Disputed checks")
429
+ lines.append("")
430
+ lines.append(
431
+ "These stay MUST because the spec says MUST. A [policy file](grading.md#policy-files) can downgrade them."
432
+ )
433
+ for c in disputed:
434
+ lines.append("")
435
+ lines.append(f"**{c.id}** {c.title}. {c.disputed}")
436
+ return "\n".join(lines)
mcp_authlab/checks.py ADDED
@@ -0,0 +1,34 @@
1
+ """Public entry point. `checks.run(url, ...)` scans a server and returns the report dict.
2
+
3
+ The catalog lives in catalog.py, the scanner in scanner.py and the grading in
4
+ grading.py; this module re-exports them so existing imports keep working.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .catalog import BY_ID, CATALOG, ERROR, FAIL, PASS, SKIP, WARN, Check, Result
10
+ from .grading import GRADE_ORDER, GRADING, LEVELS, Grade, conformance_level, coverage, grade
11
+ from .scanner import Scanner, build_report, initialize_payload, run
12
+
13
+ __all__ = [
14
+ "BY_ID",
15
+ "CATALOG",
16
+ "ERROR",
17
+ "FAIL",
18
+ "GRADE_ORDER",
19
+ "GRADING",
20
+ "LEVELS",
21
+ "PASS",
22
+ "SKIP",
23
+ "WARN",
24
+ "Check",
25
+ "Grade",
26
+ "Result",
27
+ "Scanner",
28
+ "build_report",
29
+ "conformance_level",
30
+ "coverage",
31
+ "grade",
32
+ "initialize_payload",
33
+ "run",
34
+ ]