mcp-posture 1.0.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,3 @@
1
+ """mcp-posture: security posture scanner for remote MCP servers."""
2
+
3
+ __version__ = "1.0.0" # x-release-please-version
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m mcp_posture``."""
2
+
3
+ from mcp_posture.cli import main
4
+
5
+ main()
@@ -0,0 +1,5 @@
1
+ """Check modules. Importing this package registers every check in the catalogue."""
2
+
3
+ from mcp_posture.checks import asm, authn, cimd, pin, prm, scp, tool, trn
4
+
5
+ __all__ = ["asm", "authn", "cimd", "pin", "prm", "scp", "tool", "trn"]
@@ -0,0 +1,85 @@
1
+ """Reference links shared by checks (title, url)."""
2
+
3
+ _MCP = "https://modelcontextprotocol.io/specification"
4
+
5
+ MCP_AUTH_2025_03 = ("MCP 2025-03-26 Authorization", f"{_MCP}/2025-03-26/basic/authorization")
6
+ MCP_AUTH_2025_06 = ("MCP 2025-06-18 Authorization", f"{_MCP}/2025-06-18/basic/authorization")
7
+ MCP_AUTH_2025_11 = ("MCP 2025-11-25 Authorization", f"{_MCP}/2025-11-25/basic/authorization")
8
+ MCP_AUTH_2026 = (
9
+ "MCP 2026-07-28 Authorization",
10
+ f"{_MCP}/2026-07-28/basic/authorization/index",
11
+ )
12
+ MCP_AUTH_DISCOVERY_2026 = (
13
+ "MCP 2026-07-28 Authorization Server Discovery",
14
+ f"{_MCP}/2026-07-28/basic/authorization/authorization-server-discovery",
15
+ )
16
+ MCP_CLIENT_REG_2026 = (
17
+ "MCP 2026-07-28 Client Registration",
18
+ f"{_MCP}/2026-07-28/basic/authorization/client-registration",
19
+ )
20
+ MCP_AUTH_SECURITY_2026 = (
21
+ "MCP 2026-07-28 Authorization Security Considerations",
22
+ f"{_MCP}/2026-07-28/basic/authorization/security-considerations",
23
+ )
24
+ MCP_TRANSPORT_2025_11 = ("MCP 2025-11-25 Transports", f"{_MCP}/2025-11-25/basic/transports")
25
+ MCP_TRANSPORT_2026 = (
26
+ "MCP 2026-07-28 Streamable HTTP",
27
+ f"{_MCP}/2026-07-28/basic/transports/streamable-http",
28
+ )
29
+ MCP_SECURITY_BP = (
30
+ "MCP Security Best Practices",
31
+ "https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices",
32
+ )
33
+
34
+ RFC6750 = ("RFC 6750 Bearer Token Usage", "https://www.rfc-editor.org/rfc/rfc6750")
35
+ RFC6750_3_1 = ("RFC 6750 §3.1 Error Codes", "https://www.rfc-editor.org/rfc/rfc6750#section-3.1")
36
+ RFC6797 = ("RFC 6797 HTTP Strict Transport Security", "https://www.rfc-editor.org/rfc/rfc6797")
37
+ RFC7591 = ("RFC 7591 Dynamic Client Registration", "https://www.rfc-editor.org/rfc/rfc7591")
38
+ RFC7636 = ("RFC 7636 PKCE", "https://www.rfc-editor.org/rfc/rfc7636")
39
+ RFC8414 = ("RFC 8414 Authorization Server Metadata", "https://www.rfc-editor.org/rfc/rfc8414")
40
+ RFC8414_3_3 = (
41
+ "RFC 8414 §3.3 Metadata Validation",
42
+ "https://www.rfc-editor.org/rfc/rfc8414#section-3.3",
43
+ )
44
+ RFC8707 = ("RFC 8707 Resource Indicators", "https://www.rfc-editor.org/rfc/rfc8707")
45
+ RFC8996 = ("RFC 8996 Deprecating TLS 1.0 and 1.1", "https://www.rfc-editor.org/rfc/rfc8996")
46
+ RFC9207 = ("RFC 9207 Issuer Identification", "https://www.rfc-editor.org/rfc/rfc9207")
47
+ RFC9325 = ("RFC 9325 (BCP 195) TLS Recommendations", "https://www.rfc-editor.org/rfc/rfc9325")
48
+ RFC9700 = ("RFC 9700 OAuth 2.0 Security BCP", "https://www.rfc-editor.org/rfc/rfc9700")
49
+ RFC9728 = ("RFC 9728 Protected Resource Metadata", "https://www.rfc-editor.org/rfc/rfc9728")
50
+ RFC9728_3_3 = (
51
+ "RFC 9728 §3.3 Metadata Validation",
52
+ "https://www.rfc-editor.org/rfc/rfc9728#section-3.3",
53
+ )
54
+ RFC9728_5_1 = (
55
+ "RFC 9728 §5.1 WWW-Authenticate resource_metadata",
56
+ "https://www.rfc-editor.org/rfc/rfc9728#section-5.1",
57
+ )
58
+ OIDC_DISCOVERY = (
59
+ "OpenID Connect Discovery 1.0",
60
+ "https://openid.net/specs/openid-connect-discovery-1_0.html",
61
+ )
62
+ OAUTH21 = (
63
+ "OAuth 2.1 (draft-ietf-oauth-v2-1-16)",
64
+ "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-16",
65
+ )
66
+ CIMD_DRAFT = (
67
+ "OAuth Client ID Metadata Document (draft-ietf-oauth-client-id-metadata-document-02)",
68
+ "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-02",
69
+ )
70
+ OWASP_ERROR_HANDLING = (
71
+ "OWASP Error Handling Cheat Sheet",
72
+ "https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html",
73
+ )
74
+ OWASP_MCP_TOP10 = ("OWASP MCP Top 10", "https://owasp.org/www-project-mcp-top-10/")
75
+ UNICODE_TR36 = ("Unicode TR #36 Security Considerations", "https://www.unicode.org/reports/tr36/")
76
+ UNICODE_TR39 = ("Unicode TR #39 Security Mechanisms", "https://www.unicode.org/reports/tr39/")
77
+ CVE_TROJAN_SOURCE = ("Trojan Source (CVE-2021-42574)", "https://trojansource.codes/")
78
+ MCP_TOOLS_ANNOTATIONS = (
79
+ "MCP 2025-11-25 Tools (annotations)",
80
+ f"{_MCP}/2025-11-25/server/tools",
81
+ )
82
+ RFC6749_3_3 = (
83
+ "RFC 6749 §3.3 Access Token Scope",
84
+ "https://www.rfc-editor.org/rfc/rfc6749#section-3.3",
85
+ )
@@ -0,0 +1,60 @@
1
+ """Small helpers shared by check modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterator
7
+ from typing import Any
8
+ from urllib.parse import urlsplit
9
+
10
+ from mcp_posture.context import AuthServerInfo, FrozenJson, ScanContext, thaw
11
+ from mcp_posture.models import Evidence
12
+ from mcp_posture.net import is_loopback_host
13
+
14
+
15
+ def str_list(value: Any) -> list[str]:
16
+ """A JSON array of strings (frozen as a tuple), or [] for anything else."""
17
+ if isinstance(value, tuple | list):
18
+ return [v for v in value if isinstance(v, str)]
19
+ return []
20
+
21
+
22
+ def is_https(url: str) -> bool:
23
+ return urlsplit(url).scheme == "https"
24
+
25
+
26
+ def is_loopback_url(url: str) -> bool:
27
+ return is_loopback_host(urlsplit(url).hostname or "")
28
+
29
+
30
+ def auth_servers(ctx: ScanContext) -> Iterator[tuple[AuthServerInfo, FrozenJson]]:
31
+ """Every authorization server whose metadata was found, including the 2025-03-26 one."""
32
+ servers = list(ctx.auth.authorization_servers)
33
+ if ctx.auth.legacy_as is not None:
34
+ servers.append(ctx.auth.legacy_as)
35
+ for s in servers:
36
+ doc = s.document
37
+ if doc is not None:
38
+ yield s, doc
39
+
40
+
41
+ def same_origin(a: str, b: str) -> bool:
42
+ pa, pb = urlsplit(a), urlsplit(b)
43
+ return (pa.scheme, pa.hostname, pa.port) == (pb.scheme, pb.hostname, pb.port)
44
+
45
+
46
+ def field_evidence(server: AuthServerInfo, field: str) -> list[Evidence]:
47
+ """The metadata field as served, so findings can be verified without re-fetching."""
48
+ fetch = server.metadata
49
+ if fetch is None or fetch.document is None:
50
+ return []
51
+ value = thaw(fetch.document.get(field, "<absent>"))
52
+ excerpt = json.dumps(value, ensure_ascii=False) if value != "<absent>" else "absent"
53
+ return [
54
+ Evidence(
55
+ summary=field,
56
+ request=fetch.exchange.describe(),
57
+ status=fetch.exchange.status,
58
+ excerpt=excerpt[:300],
59
+ )
60
+ ]
@@ -0,0 +1,391 @@
1
+ """ASM: OAuth 2.0 Authorization Server Metadata (RFC 8414) / OpenID Connect Discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from urllib.parse import urlsplit
7
+
8
+ from mcp_posture.checks import _refs as R
9
+ from mcp_posture.checks._util import (
10
+ auth_servers,
11
+ field_evidence,
12
+ is_https,
13
+ is_loopback_url,
14
+ str_list,
15
+ )
16
+ from mcp_posture.context import ScanContext, exchange_evidence
17
+ from mcp_posture.models import (
18
+ Confidence,
19
+ Evidence,
20
+ Finding,
21
+ Severity,
22
+ SpecRevision,
23
+ revisions_from,
24
+ )
25
+ from mcp_posture.registry import check
26
+
27
+ ENDPOINT_FIELDS = (
28
+ "issuer",
29
+ "authorization_endpoint",
30
+ "token_endpoint",
31
+ "registration_endpoint",
32
+ "jwks_uri",
33
+ "revocation_endpoint",
34
+ "introspection_endpoint",
35
+ "userinfo_endpoint",
36
+ "pushed_authorization_request_endpoint",
37
+ "device_authorization_endpoint",
38
+ )
39
+ NO_NONE_ALG_FIELDS = (
40
+ "token_endpoint_auth_signing_alg_values_supported",
41
+ "revocation_endpoint_auth_signing_alg_values_supported",
42
+ "introspection_endpoint_auth_signing_alg_values_supported",
43
+ )
44
+
45
+
46
+ @check(
47
+ id="MCPP-ASM01",
48
+ title="Authorization server metadata not found",
49
+ severity=Severity.HIGH,
50
+ revisions=revisions_from(SpecRevision.R2025_06_18),
51
+ references=[R.RFC8414, R.OIDC_DISCOVERY, R.MCP_AUTH_2025_11],
52
+ rationale="""MCP authorization servers MUST publish RFC 8414 metadata (or, since
53
+ 2025-11-25, OpenID Connect Discovery). Clients cannot learn the endpoints or verify
54
+ PKCE support otherwise.""",
55
+ remediation="""Publish `/.well-known/oauth-authorization-server` (path-inserted for issuers
56
+ with a path) or `/.well-known/openid-configuration`.""",
57
+ )
58
+ def asm01(ctx: ScanContext) -> Iterator[Finding]:
59
+ for s in ctx.auth.authorization_servers:
60
+ if s.document is None:
61
+ tried = ", ".join(
62
+ f"{f.url} → {f.exchange.status or f.exchange.error}" for f in s.fetches
63
+ )
64
+ yield ctx.finding(
65
+ "MCPP-ASM01",
66
+ f"No metadata found for issuer {s.issuer}.",
67
+ location=s.issuer,
68
+ evidence=[Evidence(summary="tried", excerpt=tried)],
69
+ )
70
+
71
+
72
+ @check(
73
+ id="MCPP-ASM02",
74
+ title="Issuer mismatch",
75
+ severity=Severity.HIGH,
76
+ references=[R.RFC8414_3_3, R.OIDC_DISCOVERY, R.MCP_AUTH_DISCOVERY_2026],
77
+ rationale="""The `issuer` in the metadata MUST be identical to the issuer used to build the
78
+ well-known URL; otherwise the metadata MUST NOT be used. A mismatch is the classic
79
+ authorization-server impersonation / mix-up setup.""",
80
+ remediation="""Make `issuer` exactly equal to the value listed in `authorization_servers`
81
+ (same scheme, host, port, path, trailing slash), https, without query or fragment.""",
82
+ )
83
+ def asm02(ctx: ScanContext) -> Iterator[Finding]:
84
+ for s, doc in auth_servers(ctx):
85
+ issuer = doc.get("issuer")
86
+ loc = s.metadata.url if s.metadata else s.issuer
87
+ if not isinstance(issuer, str):
88
+ yield ctx.finding("MCPP-ASM02", f"{loc} has no issuer.", location=loc)
89
+ continue
90
+ p = urlsplit(issuer)
91
+ if p.query or p.fragment:
92
+ yield ctx.finding(
93
+ "MCPP-ASM02",
94
+ f"issuer {issuer!r} has a query or fragment.",
95
+ location=loc,
96
+ key="shape",
97
+ )
98
+ if issuer != s.issuer and not (
99
+ s.metadata and s.metadata.variant == "legacy-origin" and issuer.rstrip("/") == s.issuer
100
+ ):
101
+ yield ctx.finding(
102
+ "MCPP-ASM02",
103
+ f"Metadata at {loc} declares issuer {issuer!r}, expected {s.issuer!r}.",
104
+ location=loc,
105
+ evidence=field_evidence(s, "issuer"),
106
+ )
107
+
108
+
109
+ @check(
110
+ id="MCPP-ASM03",
111
+ title="Authorization server endpoint not HTTPS",
112
+ severity=Severity.HIGH,
113
+ references=[R.RFC8414, R.MCP_AUTH_2025_11],
114
+ rationale="""All authorization server endpoints MUST be served over HTTPS (MCP
115
+ authorization, communication security). Codes, tokens and client credentials would
116
+ otherwise travel in clear text.""",
117
+ remediation="""Publish only `https://` endpoint URLs.""",
118
+ )
119
+ def asm03(ctx: ScanContext) -> Iterator[Finding]:
120
+ for s, doc in auth_servers(ctx):
121
+ for name in ENDPOINT_FIELDS:
122
+ url = doc.get(name)
123
+ if isinstance(url, str) and not is_https(url):
124
+ yield ctx.finding(
125
+ "MCPP-ASM03",
126
+ f"{name} is not HTTPS: {url}.",
127
+ location=s.issuer,
128
+ key=name,
129
+ severity=Severity.MEDIUM if is_loopback_url(url) else Severity.HIGH,
130
+ )
131
+
132
+
133
+ @check(
134
+ id="MCPP-ASM04",
135
+ title="PKCE S256 not advertised",
136
+ severity=Severity.CRITICAL,
137
+ references=[R.RFC7636, R.OAUTH21, R.RFC9700, R.MCP_AUTH_2025_11],
138
+ rationale="""PKCE is mandatory for MCP clients, and since 2025-11-25 clients MUST refuse to
139
+ proceed when `code_challenge_methods_supported` is absent. Without S256, authorization
140
+ codes can be intercepted and redeemed by an attacker.""",
141
+ remediation="""Enforce PKCE and advertise `"code_challenge_methods_supported": ["S256"]`.""",
142
+ )
143
+ def asm04(ctx: ScanContext) -> Iterator[Finding]:
144
+ for s, doc in auth_servers(ctx):
145
+ methods = str_list(doc.get("code_challenge_methods_supported"))
146
+ if "code_challenge_methods_supported" not in doc:
147
+ msg = "code_challenge_methods_supported is absent (PKCE support not advertised)."
148
+ elif "S256" not in methods:
149
+ msg = f"code_challenge_methods_supported {methods} does not include S256."
150
+ else:
151
+ continue
152
+ yield ctx.finding(
153
+ "MCPP-ASM04",
154
+ msg,
155
+ location=s.issuer,
156
+ evidence=field_evidence(s, "code_challenge_methods_supported"),
157
+ )
158
+
159
+
160
+ @check(
161
+ id="MCPP-ASM05",
162
+ title="PKCE plain method allowed",
163
+ severity=Severity.HIGH,
164
+ references=[R.RFC7636, R.OAUTH21, R.RFC9700],
165
+ rationale="""The `plain` transformation sends the verifier in the authorization request,
166
+ which defeats PKCE against an attacker who can read it, and allows downgrade.
167
+ OAuth 2.1 prohibits it.""",
168
+ remediation="""Advertise and accept only `S256`.""",
169
+ )
170
+ def asm05(ctx: ScanContext) -> Iterator[Finding]:
171
+ for s, doc in auth_servers(ctx):
172
+ if "plain" in str_list(doc.get("code_challenge_methods_supported")):
173
+ yield ctx.finding(
174
+ "MCPP-ASM05",
175
+ "code_challenge_methods_supported includes plain.",
176
+ location=s.issuer,
177
+ evidence=field_evidence(s, "code_challenge_methods_supported"),
178
+ )
179
+
180
+
181
+ @check(
182
+ id="MCPP-ASM06",
183
+ title="Deprecated grant types enabled",
184
+ severity=Severity.HIGH,
185
+ references=[R.OAUTH21, R.RFC9700, R.RFC8414],
186
+ rationale="""OAuth 2.1 removes the implicit and resource owner password grants; RFC 9700
187
+ says the password grant MUST NOT be used. When `grant_types_supported` is omitted,
188
+ RFC 8414 defaults it to authorization_code and implicit.""",
189
+ remediation="""Disable implicit and password grants and list the supported grants
190
+ explicitly, e.g. `["authorization_code", "refresh_token"]`.""",
191
+ )
192
+ def asm06(ctx: ScanContext) -> Iterator[Finding]:
193
+ for s, doc in auth_servers(ctx):
194
+ if "grant_types_supported" not in doc:
195
+ yield ctx.finding(
196
+ "MCPP-ASM06",
197
+ "grant_types_supported is omitted, so the RFC 8414 default includes implicit.",
198
+ location=s.issuer,
199
+ severity=Severity.LOW,
200
+ confidence=Confidence.MEDIUM,
201
+ key="default",
202
+ )
203
+ continue
204
+ bad = [
205
+ g for g in str_list(doc.get("grant_types_supported")) if g in ("implicit", "password")
206
+ ]
207
+ if bad:
208
+ yield ctx.finding(
209
+ "MCPP-ASM06",
210
+ f"grant_types_supported includes {', '.join(bad)}.",
211
+ location=s.issuer,
212
+ evidence=field_evidence(s, "grant_types_supported"),
213
+ )
214
+
215
+
216
+ @check(
217
+ id="MCPP-ASM07",
218
+ title="Response types incompatible with OAuth 2.1",
219
+ severity=Severity.MEDIUM,
220
+ references=[R.OAUTH21, R.RFC8414],
221
+ rationale="""MCP uses the authorization code flow, so `code` must be supported; response
222
+ types returning an access token from the authorization endpoint (`token`) are the
223
+ implicit flow, removed in OAuth 2.1.""",
224
+ remediation="""Support `code`; remove response types containing `token`.""",
225
+ )
226
+ def asm07(ctx: ScanContext) -> Iterator[Finding]:
227
+ for s, doc in auth_servers(ctx):
228
+ types = str_list(doc.get("response_types_supported"))
229
+ if "code" not in types:
230
+ yield ctx.finding(
231
+ "MCPP-ASM07",
232
+ f"response_types_supported {types} does not include code.",
233
+ location=s.issuer,
234
+ key="code",
235
+ evidence=field_evidence(s, "response_types_supported"),
236
+ )
237
+ implicit = [t for t in types if "token" in t.split()]
238
+ if implicit:
239
+ yield ctx.finding(
240
+ "MCPP-ASM07",
241
+ f"response_types_supported includes {', '.join(implicit)}.",
242
+ location=s.issuer,
243
+ key="token",
244
+ evidence=field_evidence(s, "response_types_supported"),
245
+ )
246
+
247
+
248
+ @check(
249
+ id="MCPP-ASM08",
250
+ title="Resource indicator (RFC 8707) enforcement not verified",
251
+ severity=Severity.INFO,
252
+ confidence=Confidence.LOW,
253
+ revisions=revisions_from(SpecRevision.R2025_06_18),
254
+ references=[R.RFC8707, R.MCP_AUTH_2025_06],
255
+ rationale="""Audience-restricted tokens are what stops a token minted for one MCP server
256
+ being replayed against another. No metadata field advertises RFC 8707 support, so a
257
+ passive scan cannot confirm it.""",
258
+ remediation="""Ensure the authorization server honors `resource` and sets `aud`, and that
259
+ the MCP server rejects tokens for other audiences. Verify it in a test environment:
260
+ request a token for another resource and confirm the MCP server answers 401.""",
261
+ )
262
+ def asm08(ctx: ScanContext) -> Iterator[Finding]:
263
+ if ctx.active:
264
+ return
265
+ for s, _doc in auth_servers(ctx):
266
+ yield ctx.finding(
267
+ "MCPP-ASM08",
268
+ "Audience restriction (RFC 8707) cannot be verified by a passive scan.",
269
+ location=s.issuer,
270
+ )
271
+
272
+
273
+ @check(
274
+ id="MCPP-ASM09",
275
+ title="Dynamic Client Registration endpoint exposed",
276
+ severity=Severity.INFO,
277
+ references=[R.RFC7591, R.MCP_CLIENT_REG_2026],
278
+ rationale="""Anonymous DCR lets anyone create clients (with arbitrary names, logos and
279
+ redirect URIs) that users will see on consent screens. It is allowed but worth knowing,
280
+ especially since 2026-07-28 deprecates DCR for MCP.""",
281
+ remediation="""If DCR is not needed, disable it. Otherwise rate-limit it, require an initial
282
+ access token where possible, and show the redirect hostname on consent.""",
283
+ )
284
+ def asm09(ctx: ScanContext) -> Iterator[Finding]:
285
+ for s, doc in auth_servers(ctx):
286
+ endpoint = doc.get("registration_endpoint")
287
+ if isinstance(endpoint, str):
288
+ yield ctx.finding(
289
+ "MCPP-ASM09",
290
+ f"registration_endpoint advertised: {endpoint}.",
291
+ location=s.issuer,
292
+ )
293
+
294
+
295
+ @check(
296
+ id="MCPP-ASM10",
297
+ title="Dynamic Client Registration without Client ID Metadata Documents",
298
+ severity=Severity.LOW,
299
+ revisions=revisions_from(SpecRevision.R2025_11_25),
300
+ references=[R.MCP_AUTH_2025_11, R.MCP_CLIENT_REG_2026, R.CIMD_DRAFT],
301
+ rationale="""Since 2025-11-25, CIMD is the recommended registration path (SHOULD) and DCR
302
+ is optional; 2026-07-28 deprecates DCR. An AS offering only DCR keeps the open
303
+ registration surface and gives clients no verifiable identity.""",
304
+ remediation="""Enable Client ID Metadata Document support and advertise
305
+ `client_id_metadata_document_supported: true`.""",
306
+ )
307
+ def asm10(ctx: ScanContext) -> Iterator[Finding]:
308
+ for s, doc in auth_servers(ctx):
309
+ if (
310
+ "registration_endpoint" in doc
311
+ and doc.get("client_id_metadata_document_supported") is not True
312
+ ):
313
+ yield ctx.finding(
314
+ "MCPP-ASM10",
315
+ "The authorization server offers DCR but not Client ID Metadata Documents.",
316
+ location=s.issuer,
317
+ severity=Severity.MEDIUM if ctx.revision.stateless else Severity.LOW,
318
+ )
319
+
320
+
321
+ @check(
322
+ id="MCPP-ASM11",
323
+ title="Authorization response iss parameter not supported",
324
+ severity=Severity.LOW,
325
+ revisions=revisions_from(SpecRevision.R2025_06_18),
326
+ references=[R.RFC9207, R.RFC9700, R.MCP_AUTH_SECURITY_2026],
327
+ rationale="""RFC 9207 `iss` in authorization responses is the standard defence against
328
+ mix-up attacks (RFC 9700 §2.1). 2026-07-28 makes it a SHOULD; it matters most when a
329
+ resource lists several authorization servers.""",
330
+ remediation="""Return `iss` in authorization responses and advertise
331
+ `authorization_response_iss_parameter_supported: true`.""",
332
+ )
333
+ def asm11(ctx: ScanContext) -> Iterator[Finding]:
334
+ servers = list(auth_servers(ctx))
335
+ for s, doc in servers:
336
+ if doc.get("authorization_response_iss_parameter_supported") is not True:
337
+ yield ctx.finding(
338
+ "MCPP-ASM11",
339
+ "authorization_response_iss_parameter_supported is not true"
340
+ + (
341
+ f" ({len(servers)} authorization servers: mix-up risk)."
342
+ if len(servers) > 1
343
+ else "."
344
+ ),
345
+ location=s.issuer,
346
+ severity=Severity.MEDIUM if len(servers) > 1 else Severity.LOW,
347
+ )
348
+
349
+
350
+ @check(
351
+ id="MCPP-ASM12",
352
+ title="Unsigned (alg=none) client authentication allowed",
353
+ severity=Severity.MEDIUM,
354
+ references=[R.RFC8414],
355
+ rationale="""RFC 8414 §2: the value `none` MUST NOT be used in the signing algorithm lists
356
+ for endpoint authentication; it would accept unsigned client assertions.""",
357
+ remediation="""Remove `none` from the *_auth_signing_alg_values_supported lists.""",
358
+ )
359
+ def asm12(ctx: ScanContext) -> Iterator[Finding]:
360
+ for s, doc in auth_servers(ctx):
361
+ for name in NO_NONE_ALG_FIELDS:
362
+ if "none" in [a.lower() for a in str_list(doc.get(name))]:
363
+ yield ctx.finding(
364
+ "MCPP-ASM12", f"{name} includes none.", location=s.issuer, key=name
365
+ )
366
+
367
+
368
+ @check(
369
+ id="MCPP-ASM13",
370
+ title="Default authorization endpoints without metadata (2025-03-26)",
371
+ severity=Severity.MEDIUM,
372
+ revisions=(SpecRevision.R2025_03_26,),
373
+ references=[R.MCP_AUTH_2025_03, R.RFC8414],
374
+ rationale="""In 2025-03-26 the MCP server is its own authorization server; without RFC 8414
375
+ metadata, clients guess `/authorize`, `/token` and `/register` and cannot verify PKCE
376
+ support or endpoint policy.""",
377
+ remediation="""Publish `/.well-known/oauth-authorization-server` at the server origin, or
378
+ move to the 2025-06-18+ model with a separate authorization server and PRM.""",
379
+ )
380
+ def asm13(ctx: ScanContext) -> Iterator[Finding]:
381
+ legacy = ctx.auth.legacy_as
382
+ if legacy is None or legacy.document is not None or not ctx.mcp.auth_required:
383
+ return
384
+ live = [ex for ex in ctx.auth.legacy_fallback_endpoints if ex.status not in (None, 404)]
385
+ yield ctx.finding(
386
+ "MCPP-ASM13",
387
+ "No authorization server metadata at the origin"
388
+ + (f"; {len(live)} default endpoint(s) respond." if live else "."),
389
+ location=legacy.issuer,
390
+ evidence=[exchange_evidence(ex, "default endpoint", 0) for ex in live],
391
+ )