Mostly safe โ a couple of notes worth reading.
Scanned 5/3/2026, 7:26:14 PMยทCached resultยทFast Scanยท45 rulesยทHow we decide โ
AIVSS Score
Low
Severity Breakdown
0
critical
0
high
18
medium
2
low
MCP Server Information
Findings
This package earns a B grade with a safety score of 79/100 but carries moderate risk due to 18 medium-severity findings, primarily around verbose error handling (12 instances) and server configuration issues (4 instances). The main concerns are information disclosure through overly detailed error messages and potential resource exhaustion vulnerabilities, though no critical or high-severity flaws were detected. Installation is acceptable if you can mitigate the verbose logging and review the server configuration recommendations before deployment.
No known CVEs found for this package or its dependencies.
Scan Details
Want deeper analysis?
Fast scan found 20 findings using rule-based analysis. Upgrade for LLM consensus across 5 judges, AI-generated remediation, and cross-file taint analysis.
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.
20 of 20 findings
20 findings
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 210 | return "\n".join(content_parts) |
| 211 | |
| 212 | except httpx.HTTPStatusError as e: |
| 213 | return f"Error: HTTP {e.response.status_code} - {str(e)}" |
| 214 | except Exception as e: # noqa: BLE001 |
| 215 | return f"Error: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 173 | except Exception as e: # noqa: BLE001 |
| 174 | logger.exception("Unexpected error in get_metrics") |
| 175 | return f"Error: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 84 | return f"Error: Dataset with ID '{dataset_id}' not found." |
| 85 | return f"Error: HTTP {e.response.status_code} - {str(e)}" |
| 86 | except Exception as e: # noqa: BLE001 |
| 87 | return f"Error: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 134 | return f"Error: Dataservice with ID '{dataservice_id}' not found." |
| 135 | return f"Error: HTTP {e.response.status_code} - {str(e)}" |
| 136 | except Exception as e: # noqa: BLE001 |
| 137 | return f"Error fetching OpenAPI spec: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 82 | except httpx.HTTPStatusError as e: |
| 83 | if e.response.status_code == 404: |
| 84 | return f"Error: Dataset with ID '{dataset_id}' not found." |
| 85 | return f"Error: HTTP {e.response.status_code} - {str(e)}" |
| 86 | except Exception as e: # noqa: BLE001 |
| 87 | return f"Error: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 88 | except httpx.HTTPStatusError as e: |
| 89 | if e.response.status_code == 404: |
| 90 | return f"Error: Dataservice with ID '{dataservice_id}' not found." |
| 91 | return f"Error: HTTP {e.response.status_code} - {str(e)}" |
| 92 | except Exception as e: # noqa: BLE001 |
| 93 | return f"Error: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 114 | except httpx.HTTPStatusError as e: |
| 115 | return f"Error: HTTP {e.response.status_code} - {str(e)}" |
| 116 | except Exception as e: # noqa: BLE001 |
| 117 | return f"Error: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 212 | except httpx.HTTPStatusError as e: |
| 213 | return f"Error: HTTP {e.response.status_code} - {str(e)}" |
| 214 | except Exception as e: # noqa: BLE001 |
| 215 | return f"Error: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 73 | return "\n".join(content_parts) |
| 74 | |
| 75 | except Exception as e: # noqa: BLE001 |
| 76 | return f"Error: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 90 | return f"Error: Dataservice with ID '{dataservice_id}' not found." |
| 91 | return f"Error: HTTP {e.response.status_code} - {str(e)}" |
| 92 | except Exception as e: # noqa: BLE001 |
| 93 | return f"Error: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 112 | return "\n".join(content_parts) |
| 113 | |
| 114 | except httpx.HTTPStatusError as e: |
| 115 | return f"Error: HTTP {e.response.status_code} - {str(e)}" |
| 116 | except Exception as e: # noqa: BLE001 |
| 117 | return f"Error: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
Full exception detail or stack trace returned to the caller. Leaking tracebacks exposes internal paths, library versions, and query structure โ useful recon for attackers.
Evidence
| 132 | except httpx.HTTPStatusError as e: |
| 133 | if e.response.status_code == 404: |
| 134 | return f"Error: Dataservice with ID '{dataservice_id}' not found." |
| 135 | return f"Error: HTTP {e.response.status_code} - {str(e)}" |
| 136 | except Exception as e: # noqa: BLE001 |
| 137 | return f"Error fetching OpenAPI spec: {str(e)}" |
Remediation
Log the full exception server-side with a correlation ID; return only {"error_id": id, "message": "internal error"} to the caller. Never enable Flask debug mode in production.
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
| 15 | - MATOMO_AUTH_TOKEN=${MATOMO_AUTH_TOKEN} |
| 16 | restart: unless-stopped |
| 17 | healthcheck: |
| 18 | test: ["CMD", "python", "-c", "import os, urllib.request; port = os.getenv('MCP_PORT', '8000'); urllib.request.urlopen(f'http://localhost:{port}/health')"] |
| 19 | interval: 30s |
| 20 | timeout: 10s |
| 21 | retries: 3 |
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
| 353 | async def search_organizations( |
| 354 | query: str = "", |
| 355 | page: int = 1, |
| 356 | page_size: int = 20, |
| 357 | sort: str | None = None, |
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
| 65 | async def app(scope, receive, send): |
| 66 | # We only track HTTP requests (The /mcp endpoint and others) |
| 67 | if scope["type"] == "http": |
| 68 | path: str = scope.get("path", "") |
| 69 | |
| 70 | # Handle /health endpoint (no tracking) |
| 71 | if path == "/health": |
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
| 17 | ) |
| 18 | @log_tool |
| 19 | async def search_organizations( |
| 20 | query: str = "", |
| 21 | page: int = 1, |
| 22 | page_size: int = 20, |
| 23 | sort: str | None = None, |
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
Dockerfile never sets a non-root `USER` directive, so the CMD runs as root by default. Any RCE or library-level vulnerability exploited inside this container gets full privileges (MCP Top-10 R3). Add `USER <non-root>` before CMD / ENTRYPOINT in the final stage โ e.g. `USER 1000`, `USER nobody`, or `USER nonroot` on distroless.
Evidence
| 1 | FROM astral/uv:python3.14-trixie-slim |
| 2 | |
| 3 | # Install needed apt packages |
| 4 | RUN apt-get update -y && \ |
| 5 | apt-get install -y --no-install-recommends git && \ |
| 6 | rm -rf /var/lib/apt/lists/* |
| 7 | |
| 8 | # Install dependencies |
| 9 | WORKDIR /app |
| 10 | ADD . /app/ |
| 11 | RUN uv sync --frozen |
| 12 | |
| 13 | # Expose port (default 8000, configurable via MCP_PORT env var) |
| 14 | EXPOSE 8000 |
| 15 | |
| 16 | # Run |
| 17 | ENTRYPOINT ["uv", "run"] |
| 18 | CMD ["python", "main.py"] |
Remediation
Create and switch to a non-root user before the CMD / ENTRYPOINT: RUN adduser --system --uid 1000 app USER 1000 Or reuse the base image's shipped non-root account (e.g. `USER nobody`, `USER nonroot` on distroless). Multi-stage builds only need the USER directive in the final stage.
GitHub Actions `uses:` reference is not pinned to a 40-character commit SHA. Tags (`@v4`) and branches (`@main`) are mutable โ a compromised maintainer or a tag rewrite can substitute malicious code into your CI pipeline silently. Pin to a SHA: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab`. For readability, include the version as a trailing comment: `# v4.1.1`. Tools like `pinact` / `ratchet` automate this. Allowed unpinned forms (excluded by the rule): - Local actions `.
Evidence
| 23 | github.event.pull_request.author_association != 'MEMBER' && |
| 24 | github.event.pull_request.author_association != 'COLLABORATOR') |
| 25 | steps: |
| 26 | - uses: actions/github-script@v7 |
| 27 | with: |
| 28 | script: | |
| 29 | const isIssue = context.eventName === "issues"; |
Remediation
Pin every `uses:` to a 40-character commit SHA. Trailing comment with the version helps reviewers: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v4.1.1` Automate the migration with `pinact` (https://github.com/suzuki-shunsuke/pinact) or `ratchet` (https://github.com/sethvargo/ratchet). Add a `pinact run --check` pre-commit hook so future PRs stay pinned. Re-pin when the action releases a new version โ Dependabot can do this automatically with `version-update-strategy: inc
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 176 | # Try JSON first, then YAML |
| 177 | try: |
| 178 | return json.loads(content) |
| 179 | except (json.JSONDecodeError, ValueError): |
| 180 | pass |
| 181 | try: |
| 182 | return yaml.safe_load(content) |
| 183 | except yaml.YAMLError: |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.
Silent error swallowing detected. An except clause that does pass or ... discards the exception with no log, no metric, and no trace. This blinds incident response and hides real failures.
Evidence
| 180 | except (json.JSONDecodeError, ValueError): |
| 181 | pass |
| 182 | try: |
| 183 | return yaml.safe_load(content) |
| 184 | except yaml.YAMLError: |
| 185 | pass |
| 186 | |
| 187 | raise ValueError(f"Could not parse OpenAPI spec from {url} as JSON or YAML") |
| 188 | finally: |
Remediation
Log the exception at minimum (`logger.exception(e)`), emit a metric, or re-raise if the error is not recoverable. If you genuinely want to ignore an exception, say so with a comment.