Use with caution. Address findings before production.
Scanned 6/24/2026, 7:25:58 PMยทCached resultยทDeep Scanยท91 rulesยทHow we decide โ
AIVSS Score
Medium
Severity Breakdown
0
critical
4
high
41
medium
0
low
MCP Server Information
Findings
This package carries significant security concerns with a C grade and 49/100 safety score, driven primarily by 21 resource exhaustion vulnerabilities and 17 server configuration issues that could enable denial-of-service attacks or misconfigurations. Additionally, it has 2 vulnerable dependencies and 2 data exfiltration risks among its 41 medium-severity findings, suggesting potential exposure of sensitive information. You should address these issues or seek alternatives before deploying this in production.
No known CVEs found for this package or its dependencies.
Scan Details
Done
Sign in to save scan history and re-scan automatically on new commits.
Building your own MCP server?
Same rules, same LLM judges, same grade. Private scans stay isolated to your account and never appear in the public registry. Required for code your team hasnโt shipped yet.
Showing 1โ30 of 45 findings
45 findings
A variable named like a secret (secret/token/apikey/password/ credential/private_key/bearer) is emitted to a logger, stdout, HTTP response, MCP tool response, or file write without redaction. If the value is genuinely not sensitive, rename it; otherwise wrap with a redaction helper.
Evidence
| 344 | const started = flags.yes |
| 345 | ? false |
| 346 | : await offerComposeUp({ targetDir, port }, deps) |
| 347 | prompts.print( |
| 348 | buildLocalConnectMessage({ targetDir, token, started, port, tokenWritten }), |
| 349 | ) |
| 350 | return 0 |
| 351 | } |
Remediation
Do not return raw secret material from MCP tools or emit it to logs. Redact via a helper (`mask`, `redact`, `sanitize`), hash via `hashlib` / `crypto.createHash`, or expose only the length / a short prefix. For Secrets Manager and KMS responses, avoid returning `SecretString` / `Plaintext` from tool handlers โ return an opaque reference or a derived value instead.
A variable named like a secret (secret/token/apikey/password/ credential/private_key/bearer) is emitted to a logger, stdout, HTTP response, MCP tool response, or file write without redaction. If the value is genuinely not sensitive, rename it; otherwise wrap with a redaction helper.
Evidence
| 436 | obsidianAuthToken === "" |
| 437 | ? false |
| 438 | : await offerComposeUp({ targetDir, port }, deps) |
| 439 | prompts.print( |
| 440 | buildRemoteConnectMessage({ |
| 441 | targetDir, |
| 442 | token, |
| 443 | publicUrl, |
| 444 | started, |
| 445 | obsidianTokenMissing: obsidianAuthToken === "", |
| 446 | tokenWritten, |
| 447 | }), |
| 448 | ) |
| 449 | return 0 |
Remediation
Do not return raw secret material from MCP tools or emit it to logs. Redact via a helper (`mask`, `redact`, `sanitize`), hash via `hashlib` / `crypto.createHash`, or expose only the length / a short prefix. For Secrets Manager and KMS responses, avoid returning `SecretString` / `Plaintext` from tool handlers โ return an opaque reference or a derived value instead.
MCP prompt handler returns a template that interpolates an untrusted handler argument (f-string `{x}`, template-literal `${x}`, string concatenation, or `.format()`/`%` formatting) into the prompt text. The host LLM that consumes this prompt via `prompts/get` will read the attacker-controlled text as part of its instructions โ pivot to arbitrary LLM behaviour. Wrap the parameter in `<untrusted>...</untrusted>`, run it through `escape_for_prompt` / `sanitize_prompt`, or pass via `json.dumps` / `
Evidence
| 1 | /** MCP prompt definitions โ user-initiated guided workflows over the vault. |
| 2 | * |
| 3 | * Prompts are the counterpart to tools: tools are model-driven, while prompts |
| 4 | * are user-initiated โ the client surfaces them as slash commands, a "+" menu, |
| 5 | * or similar โ and assemble live vault content at invocation time. Each handler |
| 6 | * gathers from the same data layer the tools use, then returns a single text |
| 7 | * message โ no embedded procedure that can drift, just live content plus thin, |
| 8 | * durable instruction |
Remediation
Wrap the parameter in `<untrusted>...</untrusted>` so the LLM treats it as data, run it through `escape_for_prompt` / `sanitize_prompt`, or pass via `json.dumps` / `JSON.stringify`. If you control the prompt template, prefer keeping it static and putting user data in a separate `role: "user"` message.
File mounts an HTTP route that handles MCP `tools/list` (Express / Fastify / FastAPI / Flask) but the route โ and the router it sits behind โ has no auth middleware applied. An anonymous client can enumerate every tool the server exposes, scope the attack surface, and (if `tools/call` shares the route) invoke them. Apply auth at the route or router level: Express `passport.authenticate(...)` / a `requireAuth`-style middleware, FastAPI `Depends(get_current_user)` or `Depends(verify_jwt)`, Flask
Evidence
| 1 | /** MCP session routes โ transport lifecycle, session creation, request routing. */ |
| 2 | |
| 3 | import { Router } from "express" |
| 4 | import type { Request, Response } from "express" |
| 5 | import { randomUUID } from "node:crypto" |
| 6 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" |
| 7 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" |
| 8 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js" |
| 9 | import { requireBearerAuth } from "@modelcontextprot |
Remediation
Apply auth middleware at the route or router level: - Express / Fastify / Koa: `passport.authenticate(...)`, `requireAuth`, `verifyToken`, or an equivalent JWT middleware applied via `router.use(authMw)` or as a per-route handler. - FastAPI: `Depends(get_current_user)`, `OAuth2PasswordBearer`, `HTTPBearer`, or `verify_jwt` dependency. - Flask: `@login_required`, `@auth_required`, `@jwt_required`, or call `verify_jwt_in_request()` in the handler. Mounting MCP behind a s
env-var==7.5.0 last released 765 days ago (>730d) โ possible abandoned package
Remediation
Typosquat: verify you meant the popular package. If so, correct the spelling; if you truly intended the less-common name, suppress with an inline waiver. Stale release: check whether the package has a maintained fork or successor. If no patched release exists, vendor the code or migrate to an active alternative before the unmaintained code accrues unfixed CVEs.
gray-matter==4.0.3 last released 1887 days ago (>730d) โ possible abandoned package
Remediation
Typosquat: verify you meant the popular package. If so, correct the spelling; if you truly intended the less-common name, suppress with an inline waiver. Stale release: check whether the package has a maintained fork or successor. If no patched release exists, vendor the code or migrate to an active alternative before the unmaintained code accrues unfixed CVEs.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 52 | "CMD", |
| 53 | "node", |
| 54 | "-e", |
| 55 | "fetch('http://127.0.0.1:8000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", |
| 56 | ] |
| 57 | interval: 15s |
| 58 | timeout: 5s |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 381 | const harness = await setupHarness() |
| 382 | vi.mocked(isInitializeRequest).mockReturnValue(false) |
| 383 | |
| 384 | const response = await fetch(harness.url(), { |
| 385 | method: "POST", |
| 386 | headers: baseHeaders, |
| 387 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 570 | sessionId, |
| 571 | }) |
| 572 | // The session should be gone โ confirm with a GET that should 404. |
| 573 | const followUp = await fetch(harness.url(), { |
| 574 | method: "GET", |
| 575 | headers: { ...baseHeaders, "mcp-session-id": sessionId }, |
| 576 | }) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 268 | const customConfig = loadConfig({ MEMORY_DIR: "Profile" }) |
| 269 | const harness = await setupHarness({ config: customConfig }) |
| 270 | vi.mocked(isInitializeRequest).mockReturnValue(true) |
| 271 | await fetch(harness.url(), { |
| 272 | method: "POST", |
| 273 | headers: { ...baseHeaders }, |
| 274 | body: JSON.stringify(initializeBody), |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 497 | const harness = await setupHarness() |
| 498 | const { sessionId, transport } = await createSession(harness) |
| 499 | |
| 500 | const response = await fetch(harness.url(), { |
| 501 | method: "DELETE", |
| 502 | headers: { ...baseHeaders, "mcp-session-id": sessionId }, |
| 503 | }) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 52 | "CMD", |
| 53 | "node", |
| 54 | "-e", |
| 55 | "fetch('http://127.0.0.1:8000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", |
| 56 | ] |
| 57 | interval: 15s |
| 58 | timeout: 5s |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 78 | "CMD", |
| 79 | "node", |
| 80 | "-e", |
| 81 | "fetch('http://127.0.0.1:8000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", |
| 82 | ] |
| 83 | interval: 15s |
| 84 | timeout: 5s |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 525 | it("returns 404 when the mcp-session-id header is missing", async () => { |
| 526 | const harness = await setupHarness() |
| 527 | |
| 528 | const response = await fetch(harness.url(), { |
| 529 | method: "DELETE", |
| 530 | headers: baseHeaders, |
| 531 | }) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 77 | "CMD", |
| 78 | "node", |
| 79 | "-e", |
| 80 | "fetch('http://127.0.0.1:8000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", |
| 81 | ] |
| 82 | interval: 15s |
| 83 | timeout: 5s |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 77 | "CMD", |
| 78 | "node", |
| 79 | "-e", |
| 80 | "fetch('http://127.0.0.1:8000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", |
| 81 | ] |
| 82 | interval: 15s |
| 83 | timeout: 5s |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 549 | it("returns 401 when bearer auth rejects", async () => { |
| 550 | const harness = await setupHarness({ authMiddleware: denyAuth }) |
| 551 | |
| 552 | const response = await fetch(harness.url(), { |
| 553 | method: "DELETE", |
| 554 | headers: { ...baseHeaders, "mcp-session-id": "anything" }, |
| 555 | }) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 483 | it("returns 401 when bearer auth rejects", async () => { |
| 484 | const harness = await setupHarness({ authMiddleware: denyAuth }) |
| 485 | |
| 486 | const response = await fetch(harness.url(), { |
| 487 | method: "GET", |
| 488 | headers: { ...baseHeaders, "mcp-session-id": "anything" }, |
| 489 | }) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 471 | it("returns 404 when the session id is unknown", async () => { |
| 472 | const harness = await setupHarness() |
| 473 | |
| 474 | const response = await fetch(harness.url(), { |
| 475 | method: "GET", |
| 476 | headers: { ...baseHeaders, "mcp-session-id": "missing" }, |
| 477 | }) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 194 | const createSession = async ( |
| 195 | harness: Harness, |
| 196 | ): Promise<{ sessionId: string; transport: TransportMock }> => { |
| 197 | const response = await fetch(harness.url(), { |
| 198 | method: "POST", |
| 199 | headers: baseHeaders, |
| 200 | body: JSON.stringify(initializeBody), |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 438 | const { sessionId, transport } = await createSession(harness) |
| 439 | transport.handleRequest.mockClear() |
| 440 | |
| 441 | const response = await fetch(harness.url(), { |
| 442 | method: "GET", |
| 443 | headers: { ...baseHeaders, "mcp-session-id": sessionId }, |
| 444 | }) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 537 | it("returns 404 when the session id is unknown", async () => { |
| 538 | const harness = await setupHarness() |
| 539 | |
| 540 | const response = await fetch(harness.url(), { |
| 541 | method: "DELETE", |
| 542 | headers: { ...baseHeaders, "mcp-session-id": "ghost" }, |
| 543 | }) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 453 | it("returns 404 when the mcp-session-id header is missing", async () => { |
| 454 | const harness = await setupHarness() |
| 455 | |
| 456 | const response = await fetch(harness.url(), { |
| 457 | method: "GET", |
| 458 | headers: baseHeaders, |
| 459 | }) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 514 | // The session should be gone from the map โ verified via a follow-up |
| 515 | // GET that should now 404. |
| 516 | const followUp = await fetch(harness.url(), { |
| 517 | method: "GET", |
| 518 | headers: { ...baseHeaders, "mcp-session-id": sessionId }, |
| 519 | }) |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 420 | it("returns 401 and never enters the route handler when bearer auth rejects", async () => { |
| 421 | const harness = await setupHarness({ authMiddleware: denyAuth }) |
| 422 | |
| 423 | const response = await fetch(harness.url(), { |
| 424 | method: "POST", |
| 425 | headers: baseHeaders, |
| 426 | body: JSON.stringify(initializeBody), |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 358 | vi.mocked(isInitializeRequest).mockReturnValue(false) |
| 359 | |
| 360 | const followUp = { jsonrpc: "2.0", id: 2, method: "tools/list" } |
| 361 | const response = await fetch(harness.url(), { |
| 362 | method: "POST", |
| 363 | headers: { ...baseHeaders, "mcp-session-id": sessionId }, |
| 364 | body: JSON.stringify(followUp), |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
Network / IO / subprocess call without an explicit timeout. A malicious or hung upstream (HTTP host, socket peer, child process) can pin threads, exhaust connection/process pools, and make the MCP server unresponsive. Always pass a bounded timeout. v2 extends v1 with subprocess coverage (R03 from the legacy readiness audit).
Evidence
| 400 | it("returns 404 when the session id is unknown", async () => { |
| 401 | const harness = await setupHarness() |
| 402 | |
| 403 | const response = await fetch(harness.url(), { |
| 404 | method: "POST", |
| 405 | headers: { ...baseHeaders, "mcp-session-id": "ghost-session" }, |
| 406 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), |
Remediation
Pass timeout= on every call: - HTTP: `requests.get(url, timeout=5)`, `httpx.get(url, timeout=5.0)` - Node fetch: `AbortSignal.timeout(5000)` - Subprocess: `subprocess.run(["cmd"], timeout=30, check=True)` Pick a value short enough to fail fast and retry.
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 220 | Returns: Confirmation message.`, |
| 221 | inputSchema: { |
| 222 | path: z.string().min(1).describe("Vault-relative path for the note"), |
| 223 | body: z |
| 224 | .string() |
| 225 | .describe("Markdown body content (no frontmatter fences)"), |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" โ the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 766 | Returns: Confirmation message.`, |
| 767 | inputSchema: { |
| 768 | path: z.string().min(1).describe("Vault-relative path to the note"), |
| 769 | properties: z |
| 770 | .record(z.string().min(1), z.unknown()) |
| 771 | .describe( |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" โ the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 462 | Returns: Confirmation message with the number of lines removed and a truncated preview of the deleted text.`, |
| 463 | inputSchema: { |
| 464 | path: z.string().min(1).describe("Vault-relative path to the note"), |
| 465 | start_anchor: z |
| 466 | .string() |
| 467 | .min(1) |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" โ the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 560 | Returns: JSON with path (the queried note), outgoing_links (array of { path, title, exists, kind, bytes }, sorted by target path), and count. kind is "note" or "asset". bytes is the on-disk file size (null for broken links and assets).`, |
| 561 | inputSchema: { |
| 562 | path: z.string().min(1).describe("Vault-relative path to the note"), |
| 563 | }, |
| 564 | annotations: { |
| 565 | readOnlyHint: true, |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" โ the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 300 | Returns: Confirmation message.`, |
| 301 | inputSchema: { |
| 302 | path: z.string().min(1).describe("Vault-relative path to the note"), |
| 303 | operation: z |
| 304 | .enum(["append", "prepend", "replace", "insert_before"]) |
| 305 | .describe("Patch operation to apply"), |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" โ the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 383 | Returns: Confirmation message with replacement count.`, |
| 384 | inputSchema: { |
| 385 | path: z.string().min(1).describe("Vault-relative path to the note"), |
| 386 | old_text: z |
| 387 | .string() |
| 388 | .min(1) |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" โ the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
Package declares an install-time hook (npm postinstall/preinstall/prepare, setup.py cmdclass override, custom setuptools install class, or non-default pyproject build-backend). Anyone installing this package runs the hook. Confirm the hook is necessary and review its contents; prefer shipping a plain library without install-time execution.
Evidence
| 45 | "lint:fix": "eslint --fix .", |
| 46 | "test": "vitest run", |
| 47 | "test:watch": "vitest", |
| 48 | "prepare": "husky" |
| 49 | }, |
| 50 | "lint-staged": { |
| 51 | "*.{json,md}": "prettier --write", |
Remediation
Prefer libraries that do not require install-time code execution: - Drop `postinstall`/`preinstall`/`prepare` scripts if the work can happen at runtime or build-time instead. - Ship pre-built native binaries rather than compiling via a custom `cmdclass` or `build_ext` override. - For Dockerfiles: replace `RUN curl โฆ | sh` with a pinned download + checksum verification + explicit `RUN` of a named script. - If the hook is unavoidable, document exactly what it does so downstream reviewers
MCP manifest declares tools but no authentication field is present (none of: auth, authorization, bearer, oauth, mtls, apiKey, api_key, basic, token, authToken). Absence is a weak signal โ confirm whether the server relies on network-layer or host-level auth, or declare the real mechanism explicitly so reviewers can audit it.
Evidence
| 1 | # Local Quickstart |
| 2 | |
| 3 | Run Vault Cortex on your machine against a local Obsidian vault. No cloud, no |
| 4 | Obsidian Sync โ just Docker and a folder of `.md` files. |
| 5 | |
| 6 | > **Fastest path:** `npx vault-cortex@latest init` does all of the below |
| 7 | > interactively โ generates the token and config files, starts the server, and |
| 8 | > prints the connection details. The steps below are the manual equivalent. |
| 9 | |
| 10 | ## Prerequisites |
| 11 | |
| 12 | - [Docker](https://docs.docker.com/get-docker/) (v20.10+) |
| 13 | - An Obsidian vault (or any folder of M |
Remediation
Declare a real authentication mechanism in the manifest, matching what the running server actually enforces: - `"auth": "bearer"` with a token scheme documented for callers - `"auth": "oauth"` / `"oauth2": { ... }` for delegated flows - `"apiKey": { "header": "X-API-Key", "prefix": "..." }` - `"mtls": true` when client certificates are required If the server is intentionally unauthenticated (stdio-only, local developer tool, trusted-host network), document the assumption in the manifest via a `"
MCP manifest declares tools but no authentication field is present (none of: auth, authorization, bearer, oauth, mtls, apiKey, api_key, basic, token, authToken). Absence is a weak signal โ confirm whether the server relies on network-layer or host-level auth, or declare the real mechanism explicitly so reviewers can audit it.
Evidence
| 1 | <p align="center"> |
| 2 | <img src="./assets/banner.svg" width="720" alt="Vault Cortex"> |
| 3 | </p> |
| 4 | |
| 5 | <div align="center"> |
| 6 | |
| 7 | [](https://github.com/aliasunder/vault-cortex/actions/workflows/ci.yml) |
| 8 | [](https://g |
Remediation
Declare a real authentication mechanism in the manifest, matching what the running server actually enforces: - `"auth": "bearer"` with a token scheme documented for callers - `"auth": "oauth"` / `"oauth2": { ... }` for delegated flows - `"apiKey": { "header": "X-API-Key", "prefix": "..." }` - `"mtls": true` when client certificates are required If the server is intentionally unauthenticated (stdio-only, local developer tool, trusted-host network), document the assumption in the manifest via a `"
File registers a state-changing HTTP route (POST / PUT / PATCH / DELETE) but no CSRF protection middleware is applied anywhere in the file. If the server uses cookie-based session auth, a cross-site request from any origin can hit this route while the user's cookies ride along. Apply CSRF middleware: - Express: `csurf` / `csrf-csrf` / `lusca.csrf()` - FastAPI: `fastapi-csrf-protect` - Flask: `flask_wtf.csrf.CSRFProtect` Or, if the route is a JSON API authenticated by bearer tokens (no co
Evidence
| 1 | /** MCP session routes โ transport lifecycle, session creation, request routing. */ |
| 2 | |
| 3 | import { Router } from "express" |
| 4 | import type { Request, Response } from "express" |
| 5 | import { randomUUID } from "node:crypto" |
| 6 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" |
| 7 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" |
| 8 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js" |
| 9 | import { requireBearerAuth } from "@modelcontextprot |
Remediation
Apply CSRF middleware at the route or router level: - Express: `app.use(csurf())` / `csrf-csrf` package - FastAPI: `fastapi-csrf-protect` with `Depends(...)` - Flask: `CSRFProtect(app)` from `flask_wtf.csrf` Or move to bearer-token auth and set `SameSite=Strict` / `SameSite=Lax` on any session cookies. Document the choice in the project README so reviewers can confirm intent.
File registers a state-changing HTTP route (POST / PUT / PATCH / DELETE) but no CSRF protection middleware is applied anywhere in the file. If the server uses cookie-based session auth, a cross-site request from any origin can hit this route while the user's cookies ride along. Apply CSRF middleware: - Express: `csurf` / `csrf-csrf` / `lusca.csrf()` - FastAPI: `fastapi-csrf-protect` - Flask: `flask_wtf.csrf.CSRFProtect` Or, if the route is a JSON API authenticated by bearer tokens (no co
Evidence
| 1 | /** OAuth HTTP routes โ SDK auth router + consent form handler. */ |
| 2 | |
| 3 | import express, { Router } from "express" |
| 4 | import type { Request, Response } from "express" |
| 5 | import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js" |
| 6 | import { safeEqual } from "../../auth.js" |
| 7 | import { renderConsentPage } from "./consent-page.js" |
| 8 | import type { OAuthProvider } from "./oauth-provider.js" |
| 9 | |
| 10 | export type OAuthRoutesOptions = { |
| 11 | authToken: string |
| 12 | serverUrl: URL |
| 13 | oauthProvider: OAuthProvider |
| 14 | se |
Remediation
Apply CSRF middleware at the route or router level: - Express: `app.use(csurf())` / `csrf-csrf` package - FastAPI: `fastapi-csrf-protect` with `Depends(...)` - Flask: `CSRFProtect(app)` from `flask_wtf.csrf` Or move to bearer-token auth and set `SameSite=Strict` / `SameSite=Lax` on any session cookies. Document the choice in the project README so reviewers can confirm intent.
Time-of-check-to-time-of-use race. Code calls `os.path.exists` / `fs.existsSync` to check a path, then `open` / `readFileSync` / `unlink` on the same name within a few lines โ without a lock or atomic-open. An attacker who can race the filesystem (symlink, file replacement) between the check and the use gets the action applied to a different target. Replace the check-then-use pattern with the action's own error handling: try the open and catch FileNotFoundError / ENOENT. For atomic creation use
Evidence
| 1 | import { |
| 2 | chmodSync, |
| 3 | existsSync, |
| 4 | mkdirSync, |
| 5 | readFileSync, |
| 6 | writeFileSync, |
| 7 | } from "node:fs" |
| 8 | import { join } from "node:path" |
| 9 | import { fileURLToPath } from "node:url" |
| 10 | |
| 11 | export type Mode = "local" | "remote" |
| 12 | |
| 13 | export type FileToWrite = { |
| 14 | /** Filename inside the target directory (e.g. "docker-compose.yml"). */ |
| 15 | name: string |
| 16 | content: string |
| 17 | /** Unix permission bits for files holding secrets (e.g. 0o600 for .env). */ |
| 18 | mode?: number |
| 19 | } |
| 20 | |
| 21 | export type FileWriteResult = { |
| 22 | name: string |
| 23 | stat |
Remediation
Replace check-then-use with action-then-handle: Python: `try: with open(p) as f: ... except FileNotFoundError: ...` Node: `try { fs.readFileSync(p); } catch (e) { if (e.code === "ENOENT") ... }` For atomic file creation: Python: `os.open(p, os.O_RDWR | os.O_CREAT | os.O_EXCL)` Node: `fs.open(p, "wx")` โ fails if file exists, no race. When you genuinely must check first, use `flock` (Python `fcntl`) or a similar per-process advisory lock to make the window uninteresting to a
Time-of-check-to-time-of-use race. Code calls `os.path.exists` / `fs.existsSync` to check a path, then `open` / `readFileSync` / `unlink` on the same name within a few lines โ without a lock or atomic-open. An attacker who can race the filesystem (symlink, file replacement) between the check and the use gets the action applied to a different target. Replace the check-then-use pattern with the action's own error handling: try the open and catch FileNotFoundError / ENOENT. For atomic creation use
Evidence
| 1 | /// <reference path="./.sst/platform/config.d.ts" /> |
| 2 | |
| 3 | // SST 4 forbids top-level imports in sst.config.ts โ everything has |
| 4 | // to be dynamically imported inside `run()`. See readSshPublicKey |
| 5 | // below for the only filesystem access we need. |
| 6 | |
| 7 | // Module-level so app() and run() share the same value. |
| 8 | // env-var can't be used here (SST forbids imports outside run()). |
| 9 | const awsRegion = process.env.AWS_REGION ?? "us-east-1" |
| 10 | |
| 11 | export default $config({ |
| 12 | app() { |
| 13 | return { |
| 14 | name: "vault-cortex", |
| 15 | |
Remediation
Replace check-then-use with action-then-handle: Python: `try: with open(p) as f: ... except FileNotFoundError: ...` Node: `try { fs.readFileSync(p); } catch (e) { if (e.code === "ENOENT") ... }` For atomic file creation: Python: `os.open(p, os.O_RDWR | os.O_CREAT | os.O_EXCL)` Node: `fs.open(p, "wx")` โ fails if file exists, no race. When you genuinely must check first, use `flock` (Python `fcntl`) or a similar per-process advisory lock to make the window uninteresting to a
Time-of-check-to-time-of-use race. Code calls `os.path.exists` / `fs.existsSync` to check a path, then `open` / `readFileSync` / `unlink` on the same name within a few lines โ without a lock or atomic-open. An attacker who can race the filesystem (symlink, file replacement) between the check and the use gets the action applied to a different target. Replace the check-then-use pattern with the action's own error handling: try the open and catch FileNotFoundError / ENOENT. For atomic creation use
Evidence
| 1 | #!/usr/bin/env tsx |
| 2 | /** |
| 3 | * Deployment helper. |
| 4 | * |
| 5 | * Subcommands: |
| 6 | * docker:build Build the vault-mcp image locally |
| 7 | * docker:push Push to GHCR |
| 8 | * docker:publish Build + push |
| 9 | * lightsail:up SCP docker-compose.yml + ~/.config/vault-cortex/.env |
| 10 | * to the VM, then `docker compose pull && up -d` over SSH |
| 11 | * |
| 12 | * The image is always `ghcr.io/${GHCR_USER}/vault-mcp:latest`. The |
| 13 | * Lightsail IP is fetched from AWS (`aws lightsail get-static-ip`) |
| 14 | * using the stage in ` |
Remediation
Replace check-then-use with action-then-handle: Python: `try: with open(p) as f: ... except FileNotFoundError: ...` Node: `try { fs.readFileSync(p); } catch (e) { if (e.code === "ENOENT") ... }` For atomic file creation: Python: `os.open(p, os.O_RDWR | os.O_CREAT | os.O_EXCL)` Node: `fs.open(p, "wx")` โ fails if file exists, no race. When you genuinely must check first, use `flock` (Python `fcntl`) or a similar per-process advisory lock to make the window uninteresting to a
Time-of-check-to-time-of-use race. Code calls `os.path.exists` / `fs.existsSync` to check a path, then `open` / `readFileSync` / `unlink` on the same name within a few lines โ without a lock or atomic-open. An attacker who can race the filesystem (symlink, file replacement) between the check and the use gets the action applied to a different target. Replace the check-then-use pattern with the action's own error handling: try the open and catch FileNotFoundError / ENOENT. For atomic creation use
Evidence
| 1 | #!/usr/bin/env bash |
| 2 | # Update CHANGELOG.md with release notes for a given version. |
| 3 | # Usage: update-changelog.sh <version> <notes-file> |
| 4 | # Replaces [Unreleased] section if present, otherwise inserts after header. |
| 5 | |
| 6 | set -euo pipefail |
| 7 | |
| 8 | VERSION="${1:?Usage: update-changelog.sh <version> <notes-file>}" |
| 9 | NOTES_FILE="${2:?Usage: update-changelog.sh <version> <notes-file>}" |
| 10 | |
| 11 | NOTES=$(cat "$NOTES_FILE") |
| 12 | DATE=$(date +%Y-%m-%d) |
| 13 | |
| 14 | python3 - "$VERSION" "$DATE" "$NOTES" << 'PYTHON' |
| 15 | import sys, re, os |
| 16 | |
| 17 | version, date |
Remediation
Replace check-then-use with action-then-handle: Python: `try: with open(p) as f: ... except FileNotFoundError: ...` Node: `try { fs.readFileSync(p); } catch (e) { if (e.code === "ENOENT") ... }` For atomic file creation: Python: `os.open(p, os.O_RDWR | os.O_CREAT | os.O_EXCL)` Node: `fs.open(p, "wx")` โ fails if file exists, no race. When you genuinely must check first, use `flock` (Python `fcntl`) or a similar per-process advisory lock to make the window uninteresting to a
MCP tool file registers a tool, performs a destructive sink (fs.unlink / shutil.rmtree / DROP TABLE / DELETE FROM / TRUNCATE / UPDATE ... SET / HTTP DELETE|PUT|PATCH / subprocess / exec / spawn), and emits no audit event anywhere in the file. Without an audit event, an investigator cannot answer "who deleted record X on day Y?" โ the irreversible action leaves no trail. Closes the OWASP MCP Top 10:2025 MCP08 (Lack of Audit and Telemetry) gap. Distinct from MCP-201 (no confirmation) and MCP-283
Evidence
| 1 | /** MCP session routes โ transport lifecycle, session creation, request routing. */ |
| 2 | |
| 3 | import { Router } from "express" |
| 4 | import type { Request, Response } from "express" |
| 5 | import { randomUUID } from "node:crypto" |
| 6 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" |
| 7 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" |
| 8 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js" |
| 9 | import { requireBearerAuth } from "@modelcontextprot |
Remediation
Add a structured audit-event emit immediately after every destructive sink. Minimum schema: actor, action, target, outcome, request_id. Python: from acme.audit import audit_log @mcp.tool() def delete_record(token: str, record_id: str) -> dict: actor = verify_token(token) db.execute("DELETE FROM records WHERE id = %s", (record_id,)) audit_log( actor=actor.sub, action="delete_record", target=record_id, outcome=
MCP tool file registers a tool, performs a destructive sink (fs.unlink / shutil.rmtree / DROP TABLE / DELETE FROM / TRUNCATE / UPDATE ... SET / HTTP DELETE|PUT|PATCH / subprocess / exec / spawn), and emits no audit event anywhere in the file. Without an audit event, an investigator cannot answer "who deleted record X on day Y?" โ the irreversible action leaves no trail. Closes the OWASP MCP Top 10:2025 MCP08 (Lack of Audit and Telemetry) gap. Distinct from MCP-201 (no confirmation) and MCP-283
Evidence
| 1 | /** |
| 2 | * OAuth 2.1 provider for vault-cortex. |
| 3 | * |
| 4 | * - Dynamic client registration (Claude Desktop, Perplexity self-register) |
| 5 | * - Authorization code flow with PKCE |
| 6 | * - JWT access tokens (HS256, verifiable by Lambda + Express) |
| 7 | * - Backward-compatible static token verification (MCP_AUTH_TOKEN) |
| 8 | * - SQLite persistence for refresh tokens + clients (survives restarts) |
| 9 | * - Consent page gated by the server's auth token |
| 10 | */ |
| 11 | |
| 12 | import Database from "better-sqlite3" |
| 13 | import { randomUUID, randomBytes } from |
Remediation
Add a structured audit-event emit immediately after every destructive sink. Minimum schema: actor, action, target, outcome, request_id. Python: from acme.audit import audit_log @mcp.tool() def delete_record(token: str, record_id: str) -> dict: actor = verify_token(token) db.execute("DELETE FROM records WHERE id = %s", (record_id,)) audit_log( actor=actor.sub, action="delete_record", target=record_id, outcome=
MCP tool file registers a tool, performs a destructive sink (fs.unlink / shutil.rmtree / DROP TABLE / DELETE FROM / TRUNCATE / UPDATE ... SET / HTTP DELETE|PUT|PATCH / subprocess / exec / spawn), and emits no audit event anywhere in the file. Without an audit event, an investigator cannot answer "who deleted record X on day Y?" โ the irreversible action leaves no trail. Closes the OWASP MCP Top 10:2025 MCP08 (Lack of Audit and Telemetry) gap. Distinct from MCP-201 (no confirmation) and MCP-283
Evidence
| 1 | import { |
| 2 | describe, |
| 3 | it, |
| 4 | expect, |
| 5 | beforeEach, |
| 6 | afterEach, |
| 7 | onTestFinished, |
| 8 | vi, |
| 9 | } from "vitest" |
| 10 | import express from "express" |
| 11 | import { randomUUID } from "node:crypto" |
| 12 | import type { Server } from "node:http" |
| 13 | import type { AddressInfo } from "node:net" |
| 14 | import { createMcpRouter } from "../mcp-router.js" |
| 15 | import { loadConfig } from "../../config.js" |
| 16 | import type { SearchIndex } from "../../search/search-index.js" |
| 17 | import type { OAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/p |
Remediation
Add a structured audit-event emit immediately after every destructive sink. Minimum schema: actor, action, target, outcome, request_id. Python: from acme.audit import audit_log @mcp.tool() def delete_record(token: str, record_id: str) -> dict: actor = verify_token(token) db.execute("DELETE FROM records WHERE id = %s", (record_id,)) audit_log( actor=actor.sub, action="delete_record", target=record_id, outcome=
vault-cortex
+2 more โ click to filter