Mostly safe โ a couple of notes worth reading.
Scanned 5/23/2026, 5:21:08 PMยทCached resultยทDeep Scanยท91 rulesยทView source โยทHow we decide โ
AIVSS Score
Low
Severity Breakdown
0
critical
0
high
25
medium
0
low
MCP Server Information
Findings
This package has a **B security grade** and a **64/100 safety score**, meaning itโs generally acceptable but carries notable risks. The 25 **medium-severity** issuesโmostly around **resource exhaustion (10)** and **server misconfigurations (15)**โcould lead to performance degradation or unintended exposure if not addressed, though no critical or high-severity flaws were found. Review the specific misconfigurations and resource limits before deploying to avoid operational disruptions.
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.
25 of 25 findings
25 findings
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
| 87 | - Tool / resource / prompt **descriptions** templated from runtime data? Static strings are safer; templated descriptions enable "tool poisoning" (adversarial metadata steering the LLM toward a dangerous tool). |
| 88 | - Descriptions mutated mid-session? Rug-pull surface: client approved the v1 description, server now advertises v2 behavior. |
| 89 | |
| 90 | **Smell:** `return { body: await fetch(url).then(r => r.text()) }` rendered directly in `format()`. Or: `description: \`Look up ${tenant.customLabel}\`` where `cus |
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
| 462 | async handler(input, ctx) { |
| 463 | if (queue.full()) throw rateLimited('Queue at capacity'); |
| 464 | const items = await fetch(input.ids); |
| 465 | if (items.length === 0) throw notFound(`No items match ${input.ids.length} IDs`); |
| 466 | return { items }; |
| 467 | } |
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
| 370 | // wire so format()-only clients see the hint mirrored into content[] text. |
| 371 | if (queue.full()) throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') }); |
| 372 | |
| 373 | const articles = await fetch(input.pmids); |
| 374 | if (articles.length === 0) { |
| 375 | // Dynamic recovery โ interpolate runtime context, override the contract default. |
| 376 | throw ctx.fail('no_pmid_match', `No data for ${input.pmids.length} PMIDs`, { |
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
| 122 | > **Declare contracts inline on each tool, even when similar across tools.** The contract is part of the tool's documented public surface โ reading one tool definition file should give the full picture (input, output, errors, handler, format). Don't extract a shared `errors[]` constant or contract module to deduplicate near-identical entries; per-tool repetition is the intended cost of locality, and dynamic `recovery` hints often need tool-specific runtime context anyway. If a code-cleanup pass |
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
| 401 | import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors'; |
| 402 | |
| 403 | export class NcbiService { |
| 404 | async fetch(pmids: string[], ctx: Context) { |
| 405 | const response = await fetchWithRetry(...); |
| 406 | if (!response.ok) { |
| 407 | throw serviceUnavailable(`NCBI returned HTTP ${response.status}`, { |
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
| 422 | output: z.object({ items: z.array(ItemSchema).describe('Resolved items') }), |
| 423 | async handler(input, ctx) { |
| 424 | if (queue.full()) throw ctx.fail('queue_full'); |
| 425 | const items = await fetch(input.ids); |
| 426 | if (items.length === 0) throw ctx.fail('no_match', `No items match ${input.ids.length} IDs`, { ids: input.ids }); |
| 427 | // ctx.fail('typo') โ TypeScript error: 'typo' isn't in the contract |
| 428 | return { items }; |
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
| 391 | #### Service-layer throws |
| 392 | |
| 393 | API-wrapping tools usually delegate to a service: `await ncbi.fetch(input, ctx)`. The throw lives in the service, not the handler. Services accept `ctx` (the unified Context) so they can call `ctx.log`, `ctx.recoveryFor`, etc. The handler doesn't catch โ it just bubbles, and the framework's auto-classifier preserves `data` on the wire. |
| 394 | |
| 395 | The contract entry on the tool and the `data: { reason }` on the service throw need to use the **same reason string** so the two sides |
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
| 48 | ], |
| 49 | |
| 50 | async handler(input, ctx) { |
| 51 | const articles = await ncbi.fetch(input.pmids); |
| 52 | if (articles.length === 0) { |
| 53 | throw ctx.fail('no_match', `None of ${input.pmids.length} PMIDs returned data`); |
| 54 | } |
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
| 422 | recovery: 'NCBI is degraded; retry in a few minutes.' }, |
| 423 | ], |
| 424 | async handler(input, ctx) { |
| 425 | return { articles: await ncbi.fetch(input.pmids, ctx) }; // throws bubble unchanged |
| 426 | }, |
| 427 | }); |
| 428 | ``` |
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
| 389 | // Works with both async and sync functions |
| 390 | const result = await ErrorHandler.tryCatch( |
| 391 | () => externalApi.fetch(url), |
| 392 | { |
| 393 | operation: 'ExternalApi.fetch', |
| 394 | context: { url }, |
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
| 64 | description: 'Search inventory items by query.', |
| 65 | annotations: { readOnlyHint: true }, |
| 66 | input: z.object({ |
| 67 | query: z.string().describe('Search terms'), |
| 68 | limit: z.number().default(10).describe('Max results'), |
| 69 | }), |
| 70 | output: z.object({ |
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
| 64 | description: 'Search inventory items by query.', |
| 65 | annotations: { readOnlyHint: true }, |
| 66 | input: z.object({ |
| 67 | query: z.string().describe('Search terms'), |
| 68 | limit: z.number().default(10).describe('Max results'), |
| 69 | }), |
| 70 | output: z.object({ |
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
| 186 | export const myTool = tool('my_tool', { |
| 187 | description: 'Does something useful.', |
| 188 | annotations: { readOnlyHint: true }, |
| 189 | input: z.object({ query: z.string().describe('Search query') }), |
| 190 | output: z.object({ |
| 191 | items: z.array(z.object({ |
| 192 | id: z.string().describe('Item ID'), |
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
| 118 | export const reviewCode = prompt('review_code', { |
| 119 | description: 'Review code for issues and best practices.', |
| 120 | args: z.object({ |
| 121 | code: z.string().describe('Code to review'), |
| 122 | language: z.string().optional().describe('Programming language'), |
| 123 | }), |
| 124 | generate: (args) => [ |
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
| 299 | ```typescript |
| 300 | output: z.object({ |
| 301 | path: z.string().describe('Resolved target path.'), |
| 302 | created: z.boolean().describe('True when the operation created a new target.'), |
| 303 | previousSizeInBytes: z.number().describe('Byte size before the mutation. Zero when created is true.'), |
| 304 | currentSizeInBytes: z.number().describe('Byte size after the mutation. Equals previous when no-op.'), |
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
| 494 | ```typescript |
| 495 | // Schema: keep permissive โ accepts empty strings from form clients |
| 496 | input: z.object({ |
| 497 | query: z.string().describe('Search terms'), |
| 498 | dateRange: z.object({ |
| 499 | minDate: z.string().describe('Start date (YYYY-MM-DD)'), |
| 500 | maxDate: z.string().describe('End date (YYYY-MM-DD)'), |
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
| 229 | export const fetchAndStage = tool('fetch_and_stage_germplasm', { |
| 230 | description: 'Fetch germplasm matching a query and stage it on a DataCanvas for follow-up SQL.', |
| 231 | input: z.object({ |
| 232 | query: z.string().describe('Search query'), |
| 233 | canvas_id: z |
| 234 | .string() |
| 235 | .optional() |
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
| 118 | export const reviewCode = prompt('review_code', { |
| 119 | description: 'Review code for issues and best practices.', |
| 120 | args: z.object({ |
| 121 | code: z.string().describe('Code to review'), |
| 122 | language: z.string().optional().describe('Programming language'), |
| 123 | }), |
| 124 | generate: (args) => [ |
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
| 74 | export const search = tool('search', { |
| 75 | description: 'Search for items by query.', |
| 76 | input: z.object({ |
| 77 | query: z.string().describe('Search query'), |
| 78 | limit: z.number().default(10).describe('Max results'), |
| 79 | }), |
| 80 | output: z.object({ |
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
| 23 | import { tool } from '@cyanheads/mcp-ts-core'; |
| 24 | |
| 25 | const myTool = tool('my_tool', { |
| 26 | input: z.object({ query: z.string().describe('Search query') }), |
| 27 | output: z.object({ result: z.string().describe('Search result') }), |
| 28 | auth: ['tool:my_tool:read'], |
| 29 | async handler(input, ctx) { |
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 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 | # README.md Conventions for MCP Servers |
| 2 | |
| 3 | Structure and content guide for creating or updating a README for an MCP server built on `@cyanheads/mcp-ts-core`. If a README already exists, use this as a reference to audit and improve it โ don't blindly rewrite sections that are already accurate. |
| 4 | |
| 5 | ## Structure |
| 6 | |
| 7 | Use this section order. Omit sections that don't apply (e.g., skip Docker/Workers if the server doesn't deploy there). |
| 8 | |
| 9 | ```text |
| 10 | # {Server Name} โ centered HTML block (h1 |
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 `"
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 node |
| 2 | /** |
| 3 | * @fileoverview Verifies that `skills/` (canonical) has been propagated to the |
| 4 | * local mirrors `.agents/skills/` and `.claude/skills/`. The maintenance skill |
| 5 | * updates `skills/` for downstream servers; the mirrors are what local agent |
| 6 | * toolchains actually read, and silent drift means agents run on stale guidance. |
| 7 | * |
| 8 | * Propagation is one-way (`skills/` โ mirrors), so only missing or |
| 9 | * content-drifted files are reported. Files that exist *only* in a mirror are |
| 10 | * typ |
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 node |
| 2 | /** |
| 3 | * @fileoverview MCP definition linter CLI. |
| 4 | * Discovers tool/resource/prompt definitions from conventional locations, |
| 5 | * runs `validateDefinitions()`, and reports results. |
| 6 | * |
| 7 | * Used by devcheck and as a standalone script: `bun run lint:mcp` / `npm run lint:mcp` |
| 8 | * |
| 9 | * Discovery strategy: |
| 10 | * 1. Globs for `*.tool.ts`, `*.resource.ts`, `*.prompt.ts` in known paths |
| 11 | * 2. Dynamically imports each file |
| 12 | * 3. Extracts exported definitions by duck-typing (has name/handler/ |
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 node |
| 2 | /** |
| 3 | * @fileoverview MCPB packaging linter โ validates env var alignment between |
| 4 | * `manifest.json` (MCPB bundle install UX) and `server.json` (MCP Registry |
| 5 | * discovery) for stdio packages. |
| 6 | * |
| 7 | * Used by devcheck and as a standalone script: `bun run lint:packaging` / |
| 8 | * `npm run lint:packaging`. |
| 9 | * |
| 10 | * Checks: |
| 11 | * 1. Manifest `name` must not contain a scope prefix (`@scope/`). |
| 12 | * 2. Every `user_config` entry must include `title` and `type` fields. |
| 13 | * 3. Every `${user_con |
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 node |
| 2 | /** |
| 3 | * @fileoverview One-shot migration: split the monolithic CHANGELOG.md into |
| 4 | * per-version files under `changelog/<major.minor>.x/<version>.md`. |
| 5 | * |
| 6 | * After the initial migration, this script is no longer needed โ the |
| 7 | * per-version files become the source of truth and `scripts/build-changelog.ts` |
| 8 | * regenerates CHANGELOG.md from them. Keep this around only long enough to |
| 9 | * verify the round-trip (split โ build โ diff). |
| 10 | * |
| 11 | * Behavior: |
| 12 | * โข Reads CHANGELOG.md, splits on |
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