mcp-audprobe 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mustafa ÖZTÜRK
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,268 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-audprobe
3
+ Version: 1.0.0
4
+ Summary: Actively test whether an MCP server you own validates bearer-token audience (aud), issuer (iss) and expiry (exp).
5
+ Author: Mustafa ÖZTÜRK
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bymustfa/mcp-audprobe
8
+ Project-URL: Repository, https://github.com/bymustfa/mcp-audprobe
9
+ Project-URL: Issues, https://github.com/bymustfa/mcp-audprobe/issues
10
+ Project-URL: Changelog, https://github.com/bymustfa/mcp-audprobe/blob/main/CHANGELOG.md
11
+ Project-URL: Documentation, https://github.com/bymustfa/mcp-audprobe#readme
12
+ Project-URL: Security Policy, https://github.com/bymustfa/mcp-audprobe/security/policy
13
+ Keywords: mcp,model-context-protocol,oauth,jwt,audience,aud,security,security-testing,bearer-token,authorization,github-action,ci
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Information Technology
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Security
26
+ Classifier: Topic :: Software Development :: Quality Assurance
27
+ Classifier: Topic :: Software Development :: Testing
28
+ Requires-Python: >=3.10
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: httpx>=0.27
32
+ Requires-Dist: PyJWT[crypto]>=2.8
33
+ Requires-Dist: cryptography>=42
34
+ Requires-Dist: starlette>=0.37
35
+ Requires-Dist: uvicorn>=0.30
36
+ Requires-Dist: rich>=13
37
+ Provides-Extra: demo
38
+ Requires-Dist: mcp<2,>=1.30.0; extra == "demo"
39
+ Requires-Dist: pillow>=10; extra == "demo"
40
+ Provides-Extra: test
41
+ Requires-Dist: pytest>=8; extra == "test"
42
+ Requires-Dist: mcp<2,>=1.30.0; extra == "test"
43
+ Requires-Dist: pillow>=10; extra == "test"
44
+ Requires-Dist: ruff>=0.5; extra == "test"
45
+ Dynamic: license-file
46
+
47
+ [**English**](https://github.com/bymustfa/mcp-audprobe/blob/main/README.md) | [Türkçe](https://github.com/bymustfa/mcp-audprobe/blob/main/README.tr.md)
48
+
49
+ [![CI](https://github.com/bymustfa/mcp-audprobe/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bymustfa/mcp-audprobe/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/mcp-audprobe)](https://pypi.org/project/mcp-audprobe/) [![CodeQL](https://github.com/bymustfa/mcp-audprobe/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/bymustfa/mcp-audprobe/actions/workflows/codeql.yml) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/bymustfa/mcp-audprobe/badge)](https://scorecard.dev/viewer/?uri=github.com/bymustfa/mcp-audprobe) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/bymustfa/mcp-audprobe/blob/main/LICENSE)
50
+
51
+ ![mcp-audprobe demo: vulnerable server FAILs, hardened server PASSes](https://raw.githubusercontent.com/bymustfa/mcp-audprobe/main/docs/demo.gif)
52
+
53
+ # mcp-audprobe
54
+
55
+ Actively test whether an **MCP server you own** really validates the bearer token's audience (`aud`),
56
+ issuer (`iss`) and expiry (`exp`). It mints test tokens with a local fake authorization server key,
57
+ sends them to your server, and reports **FAIL** if a token that must be rejected is accepted.
58
+
59
+ > ## ⚠️ Only test servers you own or are authorized to test
60
+ > `mcp-audprobe` sends authenticated requests with deliberately invalid tokens. Use it **only** against
61
+ > servers you operate or have explicit written permission to test. By default the CLI refuses any target
62
+ > that is not on loopback (`localhost`, `127.0.0.0/8`, `::1`); `--allow-remote` exists solely for your own
63
+ > staging/test deployments. Never point it at someone else's server. The demo and test-suite only talk to
64
+ > servers they start on `127.0.0.1`.
65
+
66
+ ## The problem
67
+
68
+ A resource server must accept only tokens issued *for it*. Two recent advisories show servers that checked
69
+ signature, issuer and expiry but not the audience, so a valid token for a *different* service of the same
70
+ authorization server was accepted:
71
+
72
+ - MCP Python SDK: [GHSA-w4fh-qvv9-3v23](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-w4fh-qvv9-3v23)
73
+ (CVE-2026-116684, medium). Affected `mcp` 1.10.0–1.29.1 and 2.0.0–2.1.1; fixed in 1.30.0 / 2.2.0.
74
+ **Upgrading alone changes nothing**: the patched versions add `AuthSettings(validate_token_resource=...)`,
75
+ which is off unless set. To enable the check you must (1) set `validate_token_resource=True` with
76
+ `resource_server_url` = the audience your authorization server puts in tokens for your server, **and**
77
+ (2) return the token's audience from `TokenVerifier.verify_token()` as `AccessToken.resource`. With only
78
+ the first change every request gets 401. (Confirmed against the advisory text and `mcp` 1.30.0 source:
79
+ `mcp/server/auth/settings.py`, `mcp/server/auth/middleware/bearer_auth.py`.)
80
+ - AWS API MCP Server: [GHSA-4prq-p2x5-w9fh](https://github.com/awslabs/mcp/security/advisories/GHSA-4prq-p2x5-w9fh)
81
+ (CVE-2026-115413, high). `awslabs.aws-api-mcp-server` >=1.3.0, <1.6.0 (streamable-http + OAuth) validated
82
+ signature, issuer and expiry but not the audience/client; fixed in 1.6.0, which requires operators to set an
83
+ expected audience or client ID.
84
+
85
+ Context: [thread](https://x.com/karlmehta/status/2115834904739033160). A config setting that is "off unless
86
+ you set it" is easy to miss, hence an *active* test that you can run in CI.
87
+
88
+ ## Install
89
+
90
+ Once the first release is on PyPI (see [Releases](https://github.com/bymustfa/mcp-audprobe/releases)):
91
+
92
+ ```bash
93
+ pipx install mcp-audprobe # recommended: isolated CLI (or: uvx mcp-audprobe --help)
94
+ pip install mcp-audprobe # into the current environment (Python >= 3.10)
95
+ mcp-audprobe --version
96
+ ```
97
+
98
+ From a clone (development):
99
+
100
+ ```bash
101
+ pip install -e . # CLI only (Python >= 3.10)
102
+ pip install -e ".[demo]" # + MCP Python SDK (>=1.30.0,<2) and Pillow, for the demo servers / PNG
103
+ pip install -e ".[test]" # + pytest, ruff
104
+ ```
105
+
106
+ ## How it works
107
+
108
+ 1. `mcp-audprobe auth-server` runs a **fake authorization server** on loopback: RS256 JWKS at
109
+ `/.well-known/jwks.json` plus OIDC/OAuth metadata. The RSA key is generated locally into `--key-file`.
110
+ 2. Your server under test is configured to trust that issuer/JWKS (in CI/dev only!).
111
+ 3. `mcp-audprobe probe` signs tokens with the same key file and sends each one as
112
+ `Authorization: Bearer …` on an MCP JSON-RPC `initialize` request (`POST`, `Accept: application/json, text/event-stream`).
113
+
114
+ | # | Check id | Token | Must be |
115
+ |---|----------|-------|---------|
116
+ | 0 | `CONTROL` | valid: correct iss, aud, signature, exp | **accepted**, else the run is `INCONCLUSIVE` (attack checks are `SKIPPED`) |
117
+ | 1 | `AUD-OTHER` | valid signature/iss/exp, `aud` = another service | rejected |
118
+ | 2 | `AUD-MISSING` | valid signature/iss/exp, **no** `aud` claim | rejected |
119
+ | 3 | `ISS-WRONG` | signed with the trusted key, `iss` ≠ trusted issuer (isolates the `iss` check) | rejected |
120
+ | 4 | `EXP-EXPIRED` | valid otherwise, `exp` one hour in the past | rejected |
121
+
122
+ **Definition of "accepted"**: an HTTP **2xx** response to the authenticated request. **Rejected** = **401 or 403**.
123
+ Anything else (other 4xx/5xx, redirects, network error/timeout) is "unexpected": the control then makes the run
124
+ `INCONCLUSIVE`, and for an attack token the check is `ERROR`. Verdicts: accepted → `FAIL`, rejected → `PASS`.
125
+
126
+ Overall result / exit code: `PASS` (0), `FAIL` (1, any attack token accepted), `INCONCLUSIVE` (2, control not
127
+ accepted or an `ERROR`), usage/safety error (3).
128
+
129
+ ## Usage
130
+
131
+ ```bash
132
+ # 1. fake authorization server (creates key.pem if missing; keep it out of git)
133
+ mcp-audprobe auth-server --port 9000 --issuer http://127.0.0.1:9000 --key-file key.pem &
134
+
135
+ # 2. start your MCP server so it trusts issuer http://127.0.0.1:9000 and JWKS
136
+ # http://127.0.0.1:9000/.well-known/jwks.json (fetched lazily; see demo/demo_server.py)
137
+
138
+ # 3. probe
139
+ mcp-audprobe probe http://127.0.0.1:8001/mcp \
140
+ --audience http://127.0.0.1:8001/mcp \
141
+ --issuer http://127.0.0.1:9000 --key-file key.pem \
142
+ --auth-server http://127.0.0.1:9000 # optional: verify the AS serves our key id first
143
+ ```
144
+
145
+ Options: `--json` (JSON on stdout instead of the table), `--json-out FILE` (table *and* JSON file),
146
+ `--other-audience URL`, `--timeout S`, `--no-color`, `--allow-remote`. `python -m mcp_audprobe` also works.
147
+
148
+ ## CI / GitHub Action
149
+
150
+ `action.yml` (composite) installs the tool, starts the fake auth server in the background, probes, writes
151
+ `audprobe-report.json`, stops the auth server, and fails the job on FAIL/INCONCLUSIVE. Your server must be
152
+ started in an earlier step on loopback, trusting `http://127.0.0.1:9000` (default `auth-port`).
153
+
154
+ ```yaml
155
+ - name: Start my MCP server (127.0.0.1:8001, trusts the fake AS at http://127.0.0.1:9000)
156
+ run: ./start-server.sh
157
+ - uses: bymustfa/mcp-audprobe@v1
158
+ with:
159
+ url: http://127.0.0.1:8001/mcp
160
+ audience: http://127.0.0.1:8001/mcp
161
+ ```
162
+
163
+ > `@v1` is a floating major tag that exists **after the first release** (until then use `@main` or a commit SHA).
164
+ > For supply-chain safety you can instead pin a full commit SHA: `uses: bymustfa/mcp-audprobe@<sha> # v1.0.0`.
165
+
166
+ Full example: [`examples/audprobe-workflow.yml`](examples/audprobe-workflow.yml).
167
+
168
+ | Input | Default | Meaning |
169
+ |-------|---------|---------|
170
+ | `url` (required) | | MCP endpoint of **your** server, e.g. `http://127.0.0.1:8001/mcp` |
171
+ | `audience` (required) | | audience/resource the server must accept (usually its own URL) |
172
+ | `auth-port` | `9000` | port for the fake authorization server |
173
+ | `issuer` | `http://127.0.0.1:<auth-port>` | issuer your server trusts |
174
+ | `other-audience` | `https://other-service.example/mcp` | audience for the wrong-audience token |
175
+ | `json-output` | `audprobe-report.json` | path for the JSON report |
176
+ | `allow-remote` | `false` | allow a non-loopback target (own staging only) |
177
+ | `python-version` | `3.12` | Python used to run the tool |
178
+
179
+ Outputs: `result` (`PASS`/`FAIL`/`INCONCLUSIVE`) and `report` (path of the JSON report). Check
180
+ [`action.yml`](https://github.com/bymustfa/mcp-audprobe/blob/main/action.yml) for the authoritative list.
181
+
182
+ ## JSON schema (`schema_version: 1`)
183
+
184
+ ```jsonc
185
+ {
186
+ "schema_version": 1, "tool": "mcp-audprobe", "tool_version": "0.1.0",
187
+ "target": "http://127.0.0.1:18001/mcp", "expected_audience": "…", "other_audience": "…", "issuer": "…",
188
+ "request": "POST initialize (JSON-RPC) with Authorization: Bearer <token>",
189
+ "control": { "id": "CONTROL", "name": "…", "description": "…",
190
+ "token_claims": {"iss": "…", "aud": "…", "exp": 0}, // aud absent when not sent
191
+ "aud_present": true, "http_status": 200 /* null on network error */,
192
+ "outcome": "accepted" /* accepted|rejected|unexpected */, "error": null, "accepted": true },
193
+ "checks": [ { "id": "AUD-OTHER" /* AUD-OTHER|AUD-MISSING|ISS-WRONG|EXP-EXPIRED */, "name": "…", "description": "…",
194
+ "token_claims": {…}, "aud_present": true, "http_status": 200,
195
+ "outcome": "accepted" /* null when SKIPPED */,
196
+ "verdict": "FAIL" /* PASS|FAIL|ERROR|SKIPPED */, "error": null } ],
197
+ "summary": { "passed": 2, "failed": 2, "errors": 0, "skipped": 0 },
198
+ "result": "FAIL", // PASS | FAIL | INCONCLUSIVE
199
+ "exit_code": 1
200
+ }
201
+ ```
202
+
203
+ Raw tokens are never written to the report. Field names and order are stable within a `schema_version`.
204
+
205
+ ## Demo
206
+
207
+ ```bash
208
+ pip install -e ".[demo]" # or: make install
209
+ ./demo/run_demo.sh # or: make demo
210
+ ```
211
+
212
+ Starts the fake auth server and two real MCP Python SDK servers (`demo/demo_server.py`) on 127.0.0.1,
213
+ probes both, saves everything to `demo/output/` (`*.txt`, `*.json`, `demo.png`), then stops all processes.
214
+
215
+ - **vulnerable**: verifies signature, `iss`, `exp`, not `aud`; `validate_token_resource=False`; `verify_token()`
216
+ does not set `AccessToken.resource`.
217
+ - **hardened**: same verifier, but `verify_token()` returns the token's `aud` as `AccessToken.resource` and
218
+ `AuthSettings(validate_token_resource=True, resource_server_url=<its own URL>)`.
219
+
220
+ ![demo](https://raw.githubusercontent.com/bymustfa/mcp-audprobe/main/demo/output/demo.png)
221
+
222
+ Expected: vulnerable = FAIL (`AUD-OTHER`, `AUD-MISSING` accepted), hardened = PASS. The vulnerable server still
223
+ rejects wrong-`iss` and expired tokens: exactly the AWS advisory's description (signature, issuer and expiry
224
+ checked, audience not). Tests: `pytest` (unit tests for minting and verdict logic, mocked-transport probe
225
+ tests, and an end-to-end test against both demo servers).
226
+
227
+ ## Limitations
228
+
229
+ - Black-box, HTTP-level only: it observes status codes, not why a server rejected a token. A 401 for an unrelated
230
+ reason (e.g. server cannot fetch JWKS) looks like a pass for attack checks; the `CONTROL` token guards against
231
+ the "rejects everything" case.
232
+ - Your server must be configured to trust the fake issuer/JWKS. Run it against a dev/CI configuration of the
233
+ *same code*, not production with real trust anchors; a production config will reject the control token
234
+ (→ `INCONCLUSIVE`).
235
+ - Tests the audience as an exact string/URL you provide. Servers that accept several audiences or list-valued
236
+ `aud`, scope/client-ID based checks (e.g. Cognito `client_id`), opaque (non-JWT) tokens, DPoP and
237
+ mTLS-bound tokens are not covered.
238
+ - The wrong-`iss` token uses the trusted key, so it tests the `iss` comparison, not key/issuer binding.
239
+ - Uses `initialize` only. Servers that enforce auth later (per tool call) or need sessions may need more.
240
+ - Clock skew leeway on the server is not probed (expired token is 1 h old).
241
+ - Demo servers are for demonstration, not production templates. Developed against `mcp` 1.30.0; the demo servers use the 1.x `FastMCP` API (`mcp>=1.30.0,<2`; `mcp` 2.x renamed it and is not supported by the demo yet).
242
+
243
+ ## Comparison with authgent
244
+
245
+ [authgent](https://github.com/authgent/authgent) (Apache-2.0) was read on 2026-11-01 (README,
246
+ `docs/methodology.md`, `server/authgent_server/scanner.py`, read-only). Its `authgent-server lint <mcp-url>`
247
+ is an MCP-OAuth **conformance scanner**: it inspects discovery metadata (RFC 9728 protected resource metadata,
248
+ RFC 8414 AS metadata), advertised capabilities (PKCE methods, RFC 8707 resource indicators, DCR, `iss`
249
+ parameter, …), plus a few probes of the authorization server (PKCE `plain` at `/authorize`, duplicate DCR
250
+ registrations) and a heuristic `GET /`. Its `MCP-AUD-001` only checks that the AS **advertises**
251
+ `resource_indicators_supported`.
252
+
253
+ **Based on that review, authgent does not appear to submit forged or mis-audienced bearer tokens to the MCP
254
+ resource server, so it does not perform this active `aud`/`iss`/`exp` acceptance test.** It can tell you the AS
255
+ *supports* resource indicators; it cannot tell you whether your server *rejects* a token minted for another
256
+ audience, which is the failure in the two advisories above. The tools are complementary: authgent audits
257
+ metadata/spec conformance of a (possibly remote) server and also ships an AS and SDKs; mcp-audprobe is a narrow
258
+ black-box test of token validation on servers you run locally with a throwaway fake issuer. (I did not audit
259
+ authgent's own SDK middleware, nor verify claims beyond the files named above; its behaviour may change.)
260
+
261
+ ## Contributing
262
+
263
+ See [CONTRIBUTING.md](https://github.com/bymustfa/mcp-audprobe/blob/main/CONTRIBUTING.md), the [Code of Conduct](https://github.com/bymustfa/mcp-audprobe/blob/main/CODE_OF_CONDUCT.md) and the
264
+ [Security Policy](https://github.com/bymustfa/mcp-audprobe/blob/main/SECURITY.md). Türkçe: [README.tr.md](https://github.com/bymustfa/mcp-audprobe/blob/main/README.tr.md).
265
+
266
+ ## License
267
+
268
+ MIT, see [LICENSE](https://github.com/bymustfa/mcp-audprobe/blob/main/LICENSE).
@@ -0,0 +1,222 @@
1
+ [**English**](https://github.com/bymustfa/mcp-audprobe/blob/main/README.md) | [Türkçe](https://github.com/bymustfa/mcp-audprobe/blob/main/README.tr.md)
2
+
3
+ [![CI](https://github.com/bymustfa/mcp-audprobe/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bymustfa/mcp-audprobe/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/mcp-audprobe)](https://pypi.org/project/mcp-audprobe/) [![CodeQL](https://github.com/bymustfa/mcp-audprobe/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/bymustfa/mcp-audprobe/actions/workflows/codeql.yml) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/bymustfa/mcp-audprobe/badge)](https://scorecard.dev/viewer/?uri=github.com/bymustfa/mcp-audprobe) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/bymustfa/mcp-audprobe/blob/main/LICENSE)
4
+
5
+ ![mcp-audprobe demo: vulnerable server FAILs, hardened server PASSes](https://raw.githubusercontent.com/bymustfa/mcp-audprobe/main/docs/demo.gif)
6
+
7
+ # mcp-audprobe
8
+
9
+ Actively test whether an **MCP server you own** really validates the bearer token's audience (`aud`),
10
+ issuer (`iss`) and expiry (`exp`). It mints test tokens with a local fake authorization server key,
11
+ sends them to your server, and reports **FAIL** if a token that must be rejected is accepted.
12
+
13
+ > ## ⚠️ Only test servers you own or are authorized to test
14
+ > `mcp-audprobe` sends authenticated requests with deliberately invalid tokens. Use it **only** against
15
+ > servers you operate or have explicit written permission to test. By default the CLI refuses any target
16
+ > that is not on loopback (`localhost`, `127.0.0.0/8`, `::1`); `--allow-remote` exists solely for your own
17
+ > staging/test deployments. Never point it at someone else's server. The demo and test-suite only talk to
18
+ > servers they start on `127.0.0.1`.
19
+
20
+ ## The problem
21
+
22
+ A resource server must accept only tokens issued *for it*. Two recent advisories show servers that checked
23
+ signature, issuer and expiry but not the audience, so a valid token for a *different* service of the same
24
+ authorization server was accepted:
25
+
26
+ - MCP Python SDK: [GHSA-w4fh-qvv9-3v23](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-w4fh-qvv9-3v23)
27
+ (CVE-2026-116684, medium). Affected `mcp` 1.10.0–1.29.1 and 2.0.0–2.1.1; fixed in 1.30.0 / 2.2.0.
28
+ **Upgrading alone changes nothing**: the patched versions add `AuthSettings(validate_token_resource=...)`,
29
+ which is off unless set. To enable the check you must (1) set `validate_token_resource=True` with
30
+ `resource_server_url` = the audience your authorization server puts in tokens for your server, **and**
31
+ (2) return the token's audience from `TokenVerifier.verify_token()` as `AccessToken.resource`. With only
32
+ the first change every request gets 401. (Confirmed against the advisory text and `mcp` 1.30.0 source:
33
+ `mcp/server/auth/settings.py`, `mcp/server/auth/middleware/bearer_auth.py`.)
34
+ - AWS API MCP Server: [GHSA-4prq-p2x5-w9fh](https://github.com/awslabs/mcp/security/advisories/GHSA-4prq-p2x5-w9fh)
35
+ (CVE-2026-115413, high). `awslabs.aws-api-mcp-server` >=1.3.0, <1.6.0 (streamable-http + OAuth) validated
36
+ signature, issuer and expiry but not the audience/client; fixed in 1.6.0, which requires operators to set an
37
+ expected audience or client ID.
38
+
39
+ Context: [thread](https://x.com/karlmehta/status/2115834904739033160). A config setting that is "off unless
40
+ you set it" is easy to miss, hence an *active* test that you can run in CI.
41
+
42
+ ## Install
43
+
44
+ Once the first release is on PyPI (see [Releases](https://github.com/bymustfa/mcp-audprobe/releases)):
45
+
46
+ ```bash
47
+ pipx install mcp-audprobe # recommended: isolated CLI (or: uvx mcp-audprobe --help)
48
+ pip install mcp-audprobe # into the current environment (Python >= 3.10)
49
+ mcp-audprobe --version
50
+ ```
51
+
52
+ From a clone (development):
53
+
54
+ ```bash
55
+ pip install -e . # CLI only (Python >= 3.10)
56
+ pip install -e ".[demo]" # + MCP Python SDK (>=1.30.0,<2) and Pillow, for the demo servers / PNG
57
+ pip install -e ".[test]" # + pytest, ruff
58
+ ```
59
+
60
+ ## How it works
61
+
62
+ 1. `mcp-audprobe auth-server` runs a **fake authorization server** on loopback: RS256 JWKS at
63
+ `/.well-known/jwks.json` plus OIDC/OAuth metadata. The RSA key is generated locally into `--key-file`.
64
+ 2. Your server under test is configured to trust that issuer/JWKS (in CI/dev only!).
65
+ 3. `mcp-audprobe probe` signs tokens with the same key file and sends each one as
66
+ `Authorization: Bearer …` on an MCP JSON-RPC `initialize` request (`POST`, `Accept: application/json, text/event-stream`).
67
+
68
+ | # | Check id | Token | Must be |
69
+ |---|----------|-------|---------|
70
+ | 0 | `CONTROL` | valid: correct iss, aud, signature, exp | **accepted**, else the run is `INCONCLUSIVE` (attack checks are `SKIPPED`) |
71
+ | 1 | `AUD-OTHER` | valid signature/iss/exp, `aud` = another service | rejected |
72
+ | 2 | `AUD-MISSING` | valid signature/iss/exp, **no** `aud` claim | rejected |
73
+ | 3 | `ISS-WRONG` | signed with the trusted key, `iss` ≠ trusted issuer (isolates the `iss` check) | rejected |
74
+ | 4 | `EXP-EXPIRED` | valid otherwise, `exp` one hour in the past | rejected |
75
+
76
+ **Definition of "accepted"**: an HTTP **2xx** response to the authenticated request. **Rejected** = **401 or 403**.
77
+ Anything else (other 4xx/5xx, redirects, network error/timeout) is "unexpected": the control then makes the run
78
+ `INCONCLUSIVE`, and for an attack token the check is `ERROR`. Verdicts: accepted → `FAIL`, rejected → `PASS`.
79
+
80
+ Overall result / exit code: `PASS` (0), `FAIL` (1, any attack token accepted), `INCONCLUSIVE` (2, control not
81
+ accepted or an `ERROR`), usage/safety error (3).
82
+
83
+ ## Usage
84
+
85
+ ```bash
86
+ # 1. fake authorization server (creates key.pem if missing; keep it out of git)
87
+ mcp-audprobe auth-server --port 9000 --issuer http://127.0.0.1:9000 --key-file key.pem &
88
+
89
+ # 2. start your MCP server so it trusts issuer http://127.0.0.1:9000 and JWKS
90
+ # http://127.0.0.1:9000/.well-known/jwks.json (fetched lazily; see demo/demo_server.py)
91
+
92
+ # 3. probe
93
+ mcp-audprobe probe http://127.0.0.1:8001/mcp \
94
+ --audience http://127.0.0.1:8001/mcp \
95
+ --issuer http://127.0.0.1:9000 --key-file key.pem \
96
+ --auth-server http://127.0.0.1:9000 # optional: verify the AS serves our key id first
97
+ ```
98
+
99
+ Options: `--json` (JSON on stdout instead of the table), `--json-out FILE` (table *and* JSON file),
100
+ `--other-audience URL`, `--timeout S`, `--no-color`, `--allow-remote`. `python -m mcp_audprobe` also works.
101
+
102
+ ## CI / GitHub Action
103
+
104
+ `action.yml` (composite) installs the tool, starts the fake auth server in the background, probes, writes
105
+ `audprobe-report.json`, stops the auth server, and fails the job on FAIL/INCONCLUSIVE. Your server must be
106
+ started in an earlier step on loopback, trusting `http://127.0.0.1:9000` (default `auth-port`).
107
+
108
+ ```yaml
109
+ - name: Start my MCP server (127.0.0.1:8001, trusts the fake AS at http://127.0.0.1:9000)
110
+ run: ./start-server.sh
111
+ - uses: bymustfa/mcp-audprobe@v1
112
+ with:
113
+ url: http://127.0.0.1:8001/mcp
114
+ audience: http://127.0.0.1:8001/mcp
115
+ ```
116
+
117
+ > `@v1` is a floating major tag that exists **after the first release** (until then use `@main` or a commit SHA).
118
+ > For supply-chain safety you can instead pin a full commit SHA: `uses: bymustfa/mcp-audprobe@<sha> # v1.0.0`.
119
+
120
+ Full example: [`examples/audprobe-workflow.yml`](examples/audprobe-workflow.yml).
121
+
122
+ | Input | Default | Meaning |
123
+ |-------|---------|---------|
124
+ | `url` (required) | | MCP endpoint of **your** server, e.g. `http://127.0.0.1:8001/mcp` |
125
+ | `audience` (required) | | audience/resource the server must accept (usually its own URL) |
126
+ | `auth-port` | `9000` | port for the fake authorization server |
127
+ | `issuer` | `http://127.0.0.1:<auth-port>` | issuer your server trusts |
128
+ | `other-audience` | `https://other-service.example/mcp` | audience for the wrong-audience token |
129
+ | `json-output` | `audprobe-report.json` | path for the JSON report |
130
+ | `allow-remote` | `false` | allow a non-loopback target (own staging only) |
131
+ | `python-version` | `3.12` | Python used to run the tool |
132
+
133
+ Outputs: `result` (`PASS`/`FAIL`/`INCONCLUSIVE`) and `report` (path of the JSON report). Check
134
+ [`action.yml`](https://github.com/bymustfa/mcp-audprobe/blob/main/action.yml) for the authoritative list.
135
+
136
+ ## JSON schema (`schema_version: 1`)
137
+
138
+ ```jsonc
139
+ {
140
+ "schema_version": 1, "tool": "mcp-audprobe", "tool_version": "0.1.0",
141
+ "target": "http://127.0.0.1:18001/mcp", "expected_audience": "…", "other_audience": "…", "issuer": "…",
142
+ "request": "POST initialize (JSON-RPC) with Authorization: Bearer <token>",
143
+ "control": { "id": "CONTROL", "name": "…", "description": "…",
144
+ "token_claims": {"iss": "…", "aud": "…", "exp": 0}, // aud absent when not sent
145
+ "aud_present": true, "http_status": 200 /* null on network error */,
146
+ "outcome": "accepted" /* accepted|rejected|unexpected */, "error": null, "accepted": true },
147
+ "checks": [ { "id": "AUD-OTHER" /* AUD-OTHER|AUD-MISSING|ISS-WRONG|EXP-EXPIRED */, "name": "…", "description": "…",
148
+ "token_claims": {…}, "aud_present": true, "http_status": 200,
149
+ "outcome": "accepted" /* null when SKIPPED */,
150
+ "verdict": "FAIL" /* PASS|FAIL|ERROR|SKIPPED */, "error": null } ],
151
+ "summary": { "passed": 2, "failed": 2, "errors": 0, "skipped": 0 },
152
+ "result": "FAIL", // PASS | FAIL | INCONCLUSIVE
153
+ "exit_code": 1
154
+ }
155
+ ```
156
+
157
+ Raw tokens are never written to the report. Field names and order are stable within a `schema_version`.
158
+
159
+ ## Demo
160
+
161
+ ```bash
162
+ pip install -e ".[demo]" # or: make install
163
+ ./demo/run_demo.sh # or: make demo
164
+ ```
165
+
166
+ Starts the fake auth server and two real MCP Python SDK servers (`demo/demo_server.py`) on 127.0.0.1,
167
+ probes both, saves everything to `demo/output/` (`*.txt`, `*.json`, `demo.png`), then stops all processes.
168
+
169
+ - **vulnerable**: verifies signature, `iss`, `exp`, not `aud`; `validate_token_resource=False`; `verify_token()`
170
+ does not set `AccessToken.resource`.
171
+ - **hardened**: same verifier, but `verify_token()` returns the token's `aud` as `AccessToken.resource` and
172
+ `AuthSettings(validate_token_resource=True, resource_server_url=<its own URL>)`.
173
+
174
+ ![demo](https://raw.githubusercontent.com/bymustfa/mcp-audprobe/main/demo/output/demo.png)
175
+
176
+ Expected: vulnerable = FAIL (`AUD-OTHER`, `AUD-MISSING` accepted), hardened = PASS. The vulnerable server still
177
+ rejects wrong-`iss` and expired tokens: exactly the AWS advisory's description (signature, issuer and expiry
178
+ checked, audience not). Tests: `pytest` (unit tests for minting and verdict logic, mocked-transport probe
179
+ tests, and an end-to-end test against both demo servers).
180
+
181
+ ## Limitations
182
+
183
+ - Black-box, HTTP-level only: it observes status codes, not why a server rejected a token. A 401 for an unrelated
184
+ reason (e.g. server cannot fetch JWKS) looks like a pass for attack checks; the `CONTROL` token guards against
185
+ the "rejects everything" case.
186
+ - Your server must be configured to trust the fake issuer/JWKS. Run it against a dev/CI configuration of the
187
+ *same code*, not production with real trust anchors; a production config will reject the control token
188
+ (→ `INCONCLUSIVE`).
189
+ - Tests the audience as an exact string/URL you provide. Servers that accept several audiences or list-valued
190
+ `aud`, scope/client-ID based checks (e.g. Cognito `client_id`), opaque (non-JWT) tokens, DPoP and
191
+ mTLS-bound tokens are not covered.
192
+ - The wrong-`iss` token uses the trusted key, so it tests the `iss` comparison, not key/issuer binding.
193
+ - Uses `initialize` only. Servers that enforce auth later (per tool call) or need sessions may need more.
194
+ - Clock skew leeway on the server is not probed (expired token is 1 h old).
195
+ - Demo servers are for demonstration, not production templates. Developed against `mcp` 1.30.0; the demo servers use the 1.x `FastMCP` API (`mcp>=1.30.0,<2`; `mcp` 2.x renamed it and is not supported by the demo yet).
196
+
197
+ ## Comparison with authgent
198
+
199
+ [authgent](https://github.com/authgent/authgent) (Apache-2.0) was read on 2026-11-01 (README,
200
+ `docs/methodology.md`, `server/authgent_server/scanner.py`, read-only). Its `authgent-server lint <mcp-url>`
201
+ is an MCP-OAuth **conformance scanner**: it inspects discovery metadata (RFC 9728 protected resource metadata,
202
+ RFC 8414 AS metadata), advertised capabilities (PKCE methods, RFC 8707 resource indicators, DCR, `iss`
203
+ parameter, …), plus a few probes of the authorization server (PKCE `plain` at `/authorize`, duplicate DCR
204
+ registrations) and a heuristic `GET /`. Its `MCP-AUD-001` only checks that the AS **advertises**
205
+ `resource_indicators_supported`.
206
+
207
+ **Based on that review, authgent does not appear to submit forged or mis-audienced bearer tokens to the MCP
208
+ resource server, so it does not perform this active `aud`/`iss`/`exp` acceptance test.** It can tell you the AS
209
+ *supports* resource indicators; it cannot tell you whether your server *rejects* a token minted for another
210
+ audience, which is the failure in the two advisories above. The tools are complementary: authgent audits
211
+ metadata/spec conformance of a (possibly remote) server and also ships an AS and SDKs; mcp-audprobe is a narrow
212
+ black-box test of token validation on servers you run locally with a throwaway fake issuer. (I did not audit
213
+ authgent's own SDK middleware, nor verify claims beyond the files named above; its behaviour may change.)
214
+
215
+ ## Contributing
216
+
217
+ See [CONTRIBUTING.md](https://github.com/bymustfa/mcp-audprobe/blob/main/CONTRIBUTING.md), the [Code of Conduct](https://github.com/bymustfa/mcp-audprobe/blob/main/CODE_OF_CONDUCT.md) and the
218
+ [Security Policy](https://github.com/bymustfa/mcp-audprobe/blob/main/SECURITY.md). Türkçe: [README.tr.md](https://github.com/bymustfa/mcp-audprobe/blob/main/README.tr.md).
219
+
220
+ ## License
221
+
222
+ MIT, see [LICENSE](https://github.com/bymustfa/mcp-audprobe/blob/main/LICENSE).
@@ -0,0 +1,71 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mcp-audprobe"
7
+ version = "1.0.0"
8
+ description = "Actively test whether an MCP server you own validates bearer-token audience (aud), issuer (iss) and expiry (exp)."
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Mustafa ÖZTÜRK" }]
14
+ keywords = [
15
+ "mcp", "model-context-protocol", "oauth", "jwt", "audience", "aud", "security",
16
+ "security-testing", "bearer-token", "authorization", "github-action", "ci",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Environment :: Console",
21
+ "Intended Audience :: Developers",
22
+ "Intended Audience :: Information Technology",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3 :: Only",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Topic :: Security",
31
+ "Topic :: Software Development :: Quality Assurance",
32
+ "Topic :: Software Development :: Testing",
33
+ ]
34
+ dependencies = [
35
+ "httpx>=0.27",
36
+ "PyJWT[crypto]>=2.8",
37
+ "cryptography>=42",
38
+ "starlette>=0.37",
39
+ "uvicorn>=0.30",
40
+ "rich>=13",
41
+ ]
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/bymustfa/mcp-audprobe"
45
+ Repository = "https://github.com/bymustfa/mcp-audprobe"
46
+ Issues = "https://github.com/bymustfa/mcp-audprobe/issues"
47
+ Changelog = "https://github.com/bymustfa/mcp-audprobe/blob/main/CHANGELOG.md"
48
+ Documentation = "https://github.com/bymustfa/mcp-audprobe#readme"
49
+ "Security Policy" = "https://github.com/bymustfa/mcp-audprobe/security/policy"
50
+
51
+ [project.optional-dependencies]
52
+ # The demo servers use the real MCP Python SDK. >=1.30.0 is the first 1.x release with validate_token_resource.
53
+ demo = ["mcp>=1.30.0,<2", "pillow>=10"]
54
+ test = ["pytest>=8", "mcp>=1.30.0,<2", "pillow>=10", "ruff>=0.5"]
55
+
56
+ [project.scripts]
57
+ mcp-audprobe = "mcp_audprobe.cli:main"
58
+
59
+ [tool.setuptools.packages.find]
60
+ where = ["src"]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
64
+
65
+ [tool.ruff]
66
+ line-length = 120
67
+ target-version = "py310"
68
+ src = ["src", "tests", "demo"]
69
+
70
+ [tool.ruff.lint]
71
+ select = ["E", "F", "W", "I", "B", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """mcp-audprobe: active audience validation tester for MCP servers you own."""
2
+
3
+ __version__ = "1.0.0" # x-release-please-version
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,47 @@
1
+ """Minimal fake authorization server: JWKS + OAuth/OIDC discovery metadata.
2
+
3
+ It issues nothing over HTTP. Tokens are minted locally by the probe with the same key file; the
4
+ MCP server under test only needs to trust this server's JWKS. Demo/testing use only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from starlette.applications import Starlette
10
+ from starlette.requests import Request
11
+ from starlette.responses import JSONResponse
12
+ from starlette.routing import Route
13
+
14
+ from .tokens import KeyMaterial
15
+
16
+
17
+ def create_app(km: KeyMaterial, issuer: str) -> Starlette:
18
+ issuer = issuer.rstrip("/")
19
+ jwks_uri = f"{issuer}/.well-known/jwks.json"
20
+
21
+ async def jwks(_: Request) -> JSONResponse:
22
+ return JSONResponse(km.jwks())
23
+
24
+ async def metadata(_: Request) -> JSONResponse:
25
+ return JSONResponse(
26
+ {
27
+ "issuer": issuer,
28
+ "jwks_uri": jwks_uri,
29
+ "id_token_signing_alg_values_supported": ["RS256"],
30
+ "response_types_supported": ["code"],
31
+ "subject_types_supported": ["public"],
32
+ "token_endpoint_auth_methods_supported": ["none"],
33
+ "note": "mcp-audprobe fake authorization server (testing only)",
34
+ }
35
+ )
36
+
37
+ async def health(_: Request) -> JSONResponse:
38
+ return JSONResponse({"status": "ok", "kid": km.kid})
39
+
40
+ return Starlette(
41
+ routes=[
42
+ Route("/.well-known/jwks.json", jwks),
43
+ Route("/.well-known/openid-configuration", metadata),
44
+ Route("/.well-known/oauth-authorization-server", metadata),
45
+ Route("/health", health),
46
+ ]
47
+ )