certindex-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: Test (py${{ matrix.python }})
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python: ["3.11", "3.12", "3.13"]
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python }}
25
+ cache: pip
26
+ - name: Install
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install -e ".[dev]"
30
+ - name: Lint
31
+ run: ruff check .
32
+ - name: Test
33
+ run: pytest -v
34
+
35
+ audit:
36
+ name: Dependency audit
37
+ runs-on: ubuntu-latest
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+ - uses: actions/setup-python@v5
41
+ with:
42
+ python-version: "3.12"
43
+ cache: pip
44
+ - name: Install pip-audit
45
+ run: pip install pip-audit
46
+ - name: Audit
47
+ run: pip-audit --strict -r <(python -c "import tomllib,sys;d=tomllib.load(open('pyproject.toml','rb'));print('\n'.join(d['project']['dependencies']))")
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .coverage
7
+ dist/
8
+ build/
9
+ .venv/
10
+ *.log
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CertIndex contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: certindex-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing CertIndex's Certificate Transparency search tools to any MCP-compatible client.
5
+ Project-URL: Homepage, https://ctindex.io
6
+ Project-URL: Documentation, https://github.com/certindex/certindex-mcp#readme
7
+ Project-URL: Source, https://github.com/certindex/certindex-mcp
8
+ Project-URL: Issues, https://github.com/certindex/certindex-mcp/issues
9
+ Project-URL: Changelog, https://github.com/certindex/certindex-mcp/releases
10
+ Author-email: CertIndex contributors <support@ctindex.io>
11
+ License: MIT License
12
+
13
+ Copyright (c) 2026 CertIndex contributors
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+ License-File: LICENSE
33
+ Keywords: agent,certificate-transparency,certificates,claude,ct-logs,llm,mcp,model-context-protocol,security,tls
34
+ Classifier: Development Status :: 4 - Beta
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: Intended Audience :: System Administrators
37
+ Classifier: License :: OSI Approved :: MIT License
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Topic :: Internet :: WWW/HTTP
43
+ Classifier: Topic :: Security
44
+ Requires-Python: >=3.11
45
+ Requires-Dist: httpx>=0.27
46
+ Requires-Dist: mcp>=1.2.0
47
+ Requires-Dist: pydantic>=2.6
48
+ Provides-Extra: dev
49
+ Requires-Dist: pip-audit>=2.7; extra == 'dev'
50
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
51
+ Requires-Dist: pytest>=8; extra == 'dev'
52
+ Requires-Dist: respx>=0.21; extra == 'dev'
53
+ Requires-Dist: ruff>=0.6; extra == 'dev'
54
+ Description-Content-Type: text/markdown
55
+
56
+ # certindex-mcp
57
+
58
+ [![CI](https://github.com/certindex/certindex-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/certindex/certindex-mcp/actions/workflows/ci.yml)
59
+ [![PyPI](https://img.shields.io/pypi/v/certindex-mcp.svg)](https://pypi.org/project/certindex-mcp/)
60
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
61
+
62
+ An [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server
63
+ that exposes [CertIndex](https://ctindex.io)'s Certificate
64
+ Transparency search tools to any MCP-compatible client (Claude
65
+ Desktop, the MCP Inspector, Continue, etc.).
66
+
67
+ CertIndex indexes the full public CT corpus (~5 M certificates, growing
68
+ ~100 k/day). This server wraps the public CertIndex REST API so an LLM
69
+ can ask questions like:
70
+
71
+ - "List every TLS certificate ever issued for `example.com`."
72
+ - "What subdomains has Let's Encrypt seen for `mycompany.io`?"
73
+ - "Show me certs expiring in the next 30 days for `api.mycompany.io`."
74
+ - "Pull the full PEM and CT log metadata for SHA-256 `<fingerprint>`."
75
+
76
+ ## Why this repo exists
77
+
78
+ The CertIndex monorepo bundles an MCP server (mounted at
79
+ `https://api.ctindex.io/mcp`) that talks directly to the production
80
+ Postgres index. This standalone package is a thin **client-side**
81
+ shim: it speaks MCP to your editor / agent and forwards every tool
82
+ call to the hosted CertIndex REST API over HTTPS. Two consequences:
83
+
84
+ 1. You don't need a copy of the index — sign up for a free API key
85
+ at https://ctindex.io and you're done.
86
+ 2. The package has a tiny dependency footprint (`mcp`, `httpx`,
87
+ `pydantic`) — easy to audit, easy to vendor, no DB drivers.
88
+
89
+ ## Install
90
+
91
+ ```bash
92
+ pip install git+https://github.com/certindex/certindex-mcp
93
+ ```
94
+
95
+ Or with [`uvx`](https://docs.astral.sh/uv/) for one-shot use:
96
+
97
+ ```bash
98
+ uvx --from git+https://github.com/certindex/certindex-mcp certindex-mcp
99
+ ```
100
+
101
+ (PyPI release coming soon — after that, plain `pip install certindex-mcp` /
102
+ `uvx certindex-mcp` will work too.)
103
+
104
+ ## Quickstart — Claude Desktop
105
+
106
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`
107
+ (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "certindex": {
113
+ "command": "uvx",
114
+ "args": ["--from", "git+https://github.com/certindex/certindex-mcp", "certindex-mcp"],
115
+ "env": {
116
+ "CERTINDEX_API_KEY": "ctx_live_..."
117
+ }
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ Restart Claude Desktop. The six CertIndex tools (`search_certificates`,
124
+ `get_certificate`, `get_domain_certificates`, `get_subdomains`,
125
+ `get_latest_cert`, `get_expiring_certs`) appear in the tool tray.
126
+
127
+ ## Quickstart — MCP Inspector
128
+
129
+ ```bash
130
+ export CERTINDEX_API_KEY=ctx_live_...
131
+ npx @modelcontextprotocol/inspector uvx --from git+https://github.com/certindex/certindex-mcp certindex-mcp
132
+ ```
133
+
134
+ ## Configuration
135
+
136
+ | Env var | Default | Description |
137
+ | --- | --- | --- |
138
+ | `CERTINDEX_API_KEY` | *(required)* | Your CertIndex API key. Mint one at https://ctindex.io/app/keys |
139
+ | `CERTINDEX_BASE_URL` | `https://api.ctindex.io` | Override for self-hosted deployments / staging |
140
+ | `CERTINDEX_TIMEOUT` | `30` | Per-request HTTP timeout (seconds) |
141
+
142
+ ## Security
143
+
144
+ Input validation, rate-limit handling, and our supply-chain posture are
145
+ documented in [SECURITY.md](SECURITY.md). Please report vulnerabilities
146
+ to security@ctindex.io rather than filing public issues.
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ git clone https://github.com/certindex/certindex-mcp
152
+ cd certindex-mcp
153
+ pip install -e ".[dev]"
154
+ pytest
155
+ ```
156
+
157
+ CI runs on Python 3.11 / 3.12 / 3.13.
158
+
159
+ ## License
160
+
161
+ [MIT](LICENSE) © CertIndex contributors.
@@ -0,0 +1,106 @@
1
+ # certindex-mcp
2
+
3
+ [![CI](https://github.com/certindex/certindex-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/certindex/certindex-mcp/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/certindex-mcp.svg)](https://pypi.org/project/certindex-mcp/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
7
+ An [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server
8
+ that exposes [CertIndex](https://ctindex.io)'s Certificate
9
+ Transparency search tools to any MCP-compatible client (Claude
10
+ Desktop, the MCP Inspector, Continue, etc.).
11
+
12
+ CertIndex indexes the full public CT corpus (~5 M certificates, growing
13
+ ~100 k/day). This server wraps the public CertIndex REST API so an LLM
14
+ can ask questions like:
15
+
16
+ - "List every TLS certificate ever issued for `example.com`."
17
+ - "What subdomains has Let's Encrypt seen for `mycompany.io`?"
18
+ - "Show me certs expiring in the next 30 days for `api.mycompany.io`."
19
+ - "Pull the full PEM and CT log metadata for SHA-256 `<fingerprint>`."
20
+
21
+ ## Why this repo exists
22
+
23
+ The CertIndex monorepo bundles an MCP server (mounted at
24
+ `https://api.ctindex.io/mcp`) that talks directly to the production
25
+ Postgres index. This standalone package is a thin **client-side**
26
+ shim: it speaks MCP to your editor / agent and forwards every tool
27
+ call to the hosted CertIndex REST API over HTTPS. Two consequences:
28
+
29
+ 1. You don't need a copy of the index — sign up for a free API key
30
+ at https://ctindex.io and you're done.
31
+ 2. The package has a tiny dependency footprint (`mcp`, `httpx`,
32
+ `pydantic`) — easy to audit, easy to vendor, no DB drivers.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install git+https://github.com/certindex/certindex-mcp
38
+ ```
39
+
40
+ Or with [`uvx`](https://docs.astral.sh/uv/) for one-shot use:
41
+
42
+ ```bash
43
+ uvx --from git+https://github.com/certindex/certindex-mcp certindex-mcp
44
+ ```
45
+
46
+ (PyPI release coming soon — after that, plain `pip install certindex-mcp` /
47
+ `uvx certindex-mcp` will work too.)
48
+
49
+ ## Quickstart — Claude Desktop
50
+
51
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`
52
+ (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "certindex": {
58
+ "command": "uvx",
59
+ "args": ["--from", "git+https://github.com/certindex/certindex-mcp", "certindex-mcp"],
60
+ "env": {
61
+ "CERTINDEX_API_KEY": "ctx_live_..."
62
+ }
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ Restart Claude Desktop. The six CertIndex tools (`search_certificates`,
69
+ `get_certificate`, `get_domain_certificates`, `get_subdomains`,
70
+ `get_latest_cert`, `get_expiring_certs`) appear in the tool tray.
71
+
72
+ ## Quickstart — MCP Inspector
73
+
74
+ ```bash
75
+ export CERTINDEX_API_KEY=ctx_live_...
76
+ npx @modelcontextprotocol/inspector uvx --from git+https://github.com/certindex/certindex-mcp certindex-mcp
77
+ ```
78
+
79
+ ## Configuration
80
+
81
+ | Env var | Default | Description |
82
+ | --- | --- | --- |
83
+ | `CERTINDEX_API_KEY` | *(required)* | Your CertIndex API key. Mint one at https://ctindex.io/app/keys |
84
+ | `CERTINDEX_BASE_URL` | `https://api.ctindex.io` | Override for self-hosted deployments / staging |
85
+ | `CERTINDEX_TIMEOUT` | `30` | Per-request HTTP timeout (seconds) |
86
+
87
+ ## Security
88
+
89
+ Input validation, rate-limit handling, and our supply-chain posture are
90
+ documented in [SECURITY.md](SECURITY.md). Please report vulnerabilities
91
+ to security@ctindex.io rather than filing public issues.
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ git clone https://github.com/certindex/certindex-mcp
97
+ cd certindex-mcp
98
+ pip install -e ".[dev]"
99
+ pytest
100
+ ```
101
+
102
+ CI runs on Python 3.11 / 3.12 / 3.13.
103
+
104
+ ## License
105
+
106
+ [MIT](LICENSE) © CertIndex contributors.
@@ -0,0 +1,41 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Please email **security@ctindex.io** with details. Do **not** open a
6
+ public GitHub issue for security-sensitive reports. We aim to acknowledge
7
+ within 48 hours and to ship a fix or coordinated disclosure timeline
8
+ within 14 days for high-severity reports.
9
+
10
+ ## Threat model
11
+
12
+ `certindex-mcp` is a client-side MCP shim. The threat surface is
13
+ deliberately small:
14
+
15
+ | Surface | Posture |
16
+ | --- | --- |
17
+ | **Auth** | Single bearer-token (API key) read from `CERTINDEX_API_KEY`. Never logged. |
18
+ | **Transport** | HTTPS only; certificate verification cannot be disabled. |
19
+ | **Outbound HTTP** | Every request goes to `CERTINDEX_BASE_URL` (default `https://api.ctindex.io`). No user input is interpolated into the URL host or scheme. |
20
+ | **Input validation** | All tool parameters go through `certindex_mcp.validation` (strict hostname regex, SHA-256 hex regex, substring length caps) before reaching the wire. The same validators are used server-side. |
21
+ | **SSRF** | Not directly exposed — the package only talks to a single configured base URL. |
22
+ | **SQL injection** | N/A — package never builds SQL; the hosted API uses parameterised queries. |
23
+ | **Supply chain** | Three runtime deps (`mcp`, `httpx`, `pydantic`). CI runs `pip-audit` on every push. Releases are built and published from a GitHub Actions workflow with provenance attestations. |
24
+
25
+ ## What this package will NOT do
26
+
27
+ * Read your filesystem.
28
+ * Make outbound requests to any host other than `CERTINDEX_BASE_URL`.
29
+ * Log your API key, even at DEBUG.
30
+ * Cache certificate bodies to disk.
31
+
32
+ ## Supported versions
33
+
34
+ The latest minor release on PyPI receives security fixes. Older minors
35
+ are best-effort. Pin to the latest patch in `^x.y.z`.
36
+
37
+ ## Coordinated disclosure
38
+
39
+ If you would like credit in the release notes, include your preferred
40
+ handle and (optionally) a homepage URL in your report. Anonymous
41
+ reports are equally welcome.
@@ -0,0 +1,70 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "certindex-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server exposing CertIndex's Certificate Transparency search tools to any MCP-compatible client."
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "CertIndex contributors", email = "support@ctindex.io" },
14
+ ]
15
+ keywords = [
16
+ "mcp", "model-context-protocol",
17
+ "certificate-transparency", "ct-logs",
18
+ "tls", "certificates", "security",
19
+ "llm", "claude", "agent",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Intended Audience :: Developers",
24
+ "Intended Audience :: System Administrators",
25
+ "License :: OSI Approved :: MIT License",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Topic :: Security",
31
+ "Topic :: Internet :: WWW/HTTP",
32
+ ]
33
+ dependencies = [
34
+ "mcp>=1.2.0",
35
+ "httpx>=0.27",
36
+ "pydantic>=2.6",
37
+ ]
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "pytest>=8",
42
+ "pytest-asyncio>=0.23",
43
+ "respx>=0.21",
44
+ "ruff>=0.6",
45
+ "pip-audit>=2.7",
46
+ ]
47
+
48
+ [project.urls]
49
+ Homepage = "https://ctindex.io"
50
+ Documentation = "https://github.com/certindex/certindex-mcp#readme"
51
+ Source = "https://github.com/certindex/certindex-mcp"
52
+ Issues = "https://github.com/certindex/certindex-mcp/issues"
53
+ Changelog = "https://github.com/certindex/certindex-mcp/releases"
54
+
55
+ [project.scripts]
56
+ certindex-mcp = "certindex_mcp.server:main"
57
+
58
+ [tool.hatch.build.targets.wheel]
59
+ packages = ["src/certindex_mcp"]
60
+
61
+ [tool.pytest.ini_options]
62
+ asyncio_mode = "auto"
63
+ testpaths = ["tests"]
64
+
65
+ [tool.ruff]
66
+ line-length = 100
67
+ target-version = "py311"
68
+
69
+ [tool.ruff.lint]
70
+ select = ["E", "F", "I", "B", "UP", "SIM"]
@@ -0,0 +1,9 @@
1
+ """certindex-mcp — MCP server for CertIndex's CT search tools.
2
+
3
+ See ``server.main`` for the stdio entry point.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = ["__version__"]
@@ -0,0 +1,118 @@
1
+ """HTTP client wrapper for the CertIndex public REST API.
2
+
3
+ The MCP tool layer in :mod:`certindex_mcp.server` is intentionally a
4
+ thin shim — every tool call resolves to a single HTTPS request against
5
+ ``{base_url}/mcp-api/{tool_name}`` with the caller's API key in the
6
+ ``X-API-Key`` header.
7
+
8
+ Keeping all networking in this module makes the package easy to audit:
9
+ there is exactly one place that opens sockets, exactly one place that
10
+ reads the API key from the environment, and exactly one place that
11
+ decides what an error response looks like to the LLM.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ DEFAULT_BASE_URL = "https://api.ctindex.io"
21
+ DEFAULT_TIMEOUT_SECONDS = 30.0
22
+ USER_AGENT = "certindex-mcp/0.1.0 (+https://github.com/certindex/certindex-mcp)"
23
+
24
+
25
+ class CertIndexConfigError(RuntimeError):
26
+ """Raised on missing / malformed configuration (no API key, bad URL)."""
27
+
28
+
29
+ class CertIndexClient:
30
+ """Minimal async HTTP client over the CertIndex ``/mcp-api`` surface."""
31
+
32
+ def __init__(
33
+ self,
34
+ api_key: str | None = None,
35
+ base_url: str | None = None,
36
+ timeout: float | None = None,
37
+ ) -> None:
38
+ self.api_key = (api_key or os.environ.get("CERTINDEX_API_KEY", "")).strip()
39
+ if not self.api_key:
40
+ raise CertIndexConfigError(
41
+ "CERTINDEX_API_KEY is required. Mint a key at https://ctindex.io/app/keys "
42
+ "and set it in your MCP client config (Claude Desktop: the `env` block; "
43
+ "MCP Inspector: shell env)."
44
+ )
45
+ raw_base = (base_url or os.environ.get("CERTINDEX_BASE_URL") or DEFAULT_BASE_URL).strip()
46
+ parsed = httpx.URL(raw_base) if raw_base else None
47
+ if (
48
+ parsed is None
49
+ or parsed.scheme not in ("http", "https")
50
+ or not parsed.host
51
+ ):
52
+ raise CertIndexConfigError(
53
+ f"CERTINDEX_BASE_URL must be a fully-qualified http(s) URL (got: {raw_base!r})"
54
+ )
55
+ # Refuse plaintext HTTP except for an explicit localhost
56
+ # carve-out — the SECURITY.md posture promises HTTPS-only and
57
+ # silently accepting ``http://`` against an arbitrary host
58
+ # would downgrade traffic carrying the API key. Operators
59
+ # running a self-hosted dev instance on the loopback interface
60
+ # are the only legitimate plain-HTTP case.
61
+ if parsed.scheme == "http" and parsed.host not in ("localhost", "127.0.0.1", "::1"):
62
+ raise CertIndexConfigError(
63
+ "CERTINDEX_BASE_URL must use https:// (plaintext http:// "
64
+ "is only allowed against localhost for self-hosted dev)"
65
+ )
66
+ self.base_url = raw_base.rstrip("/")
67
+ self.timeout = (
68
+ timeout
69
+ if timeout is not None
70
+ else float(os.environ.get("CERTINDEX_TIMEOUT", DEFAULT_TIMEOUT_SECONDS))
71
+ )
72
+
73
+ def _client(self) -> httpx.AsyncClient:
74
+ return httpx.AsyncClient(
75
+ base_url=self.base_url,
76
+ timeout=self.timeout,
77
+ headers={
78
+ "X-API-Key": self.api_key,
79
+ "User-Agent": USER_AGENT,
80
+ "Accept": "application/json",
81
+ },
82
+ # No follow_redirects: ``/mcp-api`` never redirects, and an
83
+ # unexpected 30x is more interesting as a hard error than
84
+ # as a silent host change.
85
+ follow_redirects=False,
86
+ )
87
+
88
+ async def get(self, path: str, params: dict | None = None) -> dict[str, Any]:
89
+ """GET ``{base}/mcp-api{path}`` and return the parsed JSON body.
90
+
91
+ Non-2xx responses are surfaced as a structured dict the LLM can
92
+ reason about, *not* raised — raising would turn a 422 into an
93
+ opaque transport-level failure in the MCP client.
94
+ """
95
+ # Drop None-valued params so query strings stay tidy and the
96
+ # server doesn't see empty filters as "filter to empty".
97
+ clean = {k: v for k, v in (params or {}).items() if v is not None}
98
+ async with self._client() as client:
99
+ resp = await client.get(f"/mcp-api{path}", params=clean)
100
+ if resp.status_code >= 400:
101
+ return _error_body(resp)
102
+ return resp.json()
103
+
104
+
105
+ def _error_body(resp: httpx.Response) -> dict[str, Any]:
106
+ """Convert a non-2xx response into a tool-friendly error dict."""
107
+ try:
108
+ body = resp.json()
109
+ except Exception:
110
+ body = {"message": resp.text[:500]}
111
+ detail = body.get("detail") if isinstance(body, dict) else None
112
+ if isinstance(detail, dict):
113
+ return {"error": detail.get("error", "http_error"), "status": resp.status_code, **detail}
114
+ return {
115
+ "error": "http_error",
116
+ "status": resp.status_code,
117
+ "message": (detail if isinstance(detail, str) else None) or str(body),
118
+ }
@@ -0,0 +1,178 @@
1
+ """FastMCP stdio entry point for certindex-mcp.
2
+
3
+ Six tools that mirror the hosted CertIndex MCP surface
4
+ (``https://api.ctindex.io/mcp``). Each tool:
5
+
6
+ 1. Validates input client-side (rejects malformed hostnames /
7
+ SHA-256s / overlong substrings before going to the wire).
8
+ 2. Forwards to ``GET /mcp-api/<tool>`` on the configured base URL.
9
+ 3. Returns the upstream JSON body verbatim, or a structured error
10
+ dict on validation failure / HTTP error.
11
+
12
+ The entry point :func:`main` runs the server over stdio so any MCP
13
+ client (Claude Desktop, Continue, MCP Inspector, …) can spawn it.
14
+
15
+ NB: this module deliberately does NOT use ``from __future__ import
16
+ annotations``. FastMCP introspects each tool's parameter annotations
17
+ at registration time (``issubclass(param.annotation, Context)``); with
18
+ PEP 563 deferred evaluation those annotations are strings and the
19
+ introspection raises ``TypeError``. Keeping runtime annotations here
20
+ is the smallest fix that lets every tool register cleanly.
21
+ """
22
+
23
+ import asyncio
24
+ import logging
25
+ from typing import Any
26
+
27
+ from .client import CertIndexClient
28
+ from .validation import (
29
+ McpValidationError,
30
+ clamp_expiring_days,
31
+ clamp_limit,
32
+ clamp_page,
33
+ validate_domain,
34
+ validate_san,
35
+ validate_sha256_hex,
36
+ validate_substring,
37
+ )
38
+
39
+ logger = logging.getLogger("certindex_mcp")
40
+
41
+
42
+ def _build_server(client: CertIndexClient):
43
+ """Construct and return a configured FastMCP server.
44
+
45
+ Factored out of :func:`main` so unit tests can inject a stubbed
46
+ :class:`CertIndexClient` (e.g. one backed by :mod:`respx`).
47
+ """
48
+ from mcp.server.fastmcp import FastMCP # local import keeps `--help` cheap
49
+
50
+ mcp = FastMCP("certindex")
51
+
52
+ @mcp.tool()
53
+ async def search_certificates(
54
+ domain: str | None = None,
55
+ cn: str | None = None,
56
+ issuer: str | None = None,
57
+ san: str | None = None,
58
+ expired: bool | None = None,
59
+ is_wildcard: bool | None = None,
60
+ page: int = 1,
61
+ limit: int = 10,
62
+ ) -> dict[str, Any]:
63
+ """Search the CT certificate index by domain, CN, issuer, SAN,
64
+ validity, or wildcard status. Returns a ``has_more`` boolean
65
+ for paging (no unbounded COUNT)."""
66
+ try:
67
+ params = {
68
+ "domain": validate_domain(domain) if domain else None,
69
+ "cn": validate_substring(cn, parameter="cn"),
70
+ "issuer": validate_substring(issuer, parameter="issuer"),
71
+ "san": validate_san(san),
72
+ "expired": expired,
73
+ "is_wildcard": is_wildcard,
74
+ "page": clamp_page(page),
75
+ "limit": clamp_limit(limit),
76
+ }
77
+ except McpValidationError as exc:
78
+ return exc.to_tool_error()
79
+ return await client.get("/search_certificates", params=params)
80
+
81
+ @mcp.tool()
82
+ async def get_certificate(
83
+ sha256: str, include_enrichment: bool = False
84
+ ) -> dict[str, Any]:
85
+ """Fetch a single cert by its 64-char hex SHA-256 fingerprint.
86
+
87
+ Set ``include_enrichment=true`` to attach RDAP + DNS + ASN/hosting
88
+ context for the cert's primary hostname under ``enrichment``."""
89
+ try:
90
+ sha = validate_sha256_hex(sha256)
91
+ except McpValidationError as exc:
92
+ return exc.to_tool_error()
93
+ return await client.get(
94
+ f"/get_certificate/{sha}",
95
+ params={"include_enrichment": include_enrichment},
96
+ )
97
+
98
+ @mcp.tool()
99
+ async def get_domain_certificates(
100
+ domain: str,
101
+ valid_only: bool = False,
102
+ include_enrichment: bool = False,
103
+ page: int = 1,
104
+ limit: int = 10,
105
+ ) -> dict[str, Any]:
106
+ """List certificates for an exact domain name. Cold domains
107
+ return a ``backfill_status`` sentinel; retry per the hint.
108
+
109
+ Set ``include_enrichment=true`` to attach RDAP + DNS + ASN/hosting
110
+ context for the domain under ``enrichment``."""
111
+ try:
112
+ d = validate_domain(domain)
113
+ params = {
114
+ "valid_only": valid_only,
115
+ "include_enrichment": include_enrichment,
116
+ "page": clamp_page(page),
117
+ "limit": clamp_limit(limit),
118
+ }
119
+ except McpValidationError as exc:
120
+ return exc.to_tool_error()
121
+ return await client.get(f"/get_domain_certificates/{d}", params=params)
122
+
123
+ @mcp.tool()
124
+ async def get_subdomains(
125
+ domain: str, page: int = 1, limit: int = 25
126
+ ) -> dict[str, Any]:
127
+ """Enumerate unique subdomains seen in CT logs."""
128
+ try:
129
+ d = validate_domain(domain)
130
+ params = {"page": clamp_page(page), "limit": clamp_limit(limit)}
131
+ except McpValidationError as exc:
132
+ return exc.to_tool_error()
133
+ return await client.get(f"/get_subdomains/{d}", params=params)
134
+
135
+ @mcp.tool()
136
+ async def get_latest_cert(
137
+ domain: str, include_enrichment: bool = False
138
+ ) -> dict[str, Any]:
139
+ """Most recently issued cert for a domain (or ``{cert: null}``
140
+ with a backfill sentinel on cold domains).
141
+
142
+ Set ``include_enrichment=true`` to attach RDAP + DNS + ASN/hosting
143
+ context for the domain under ``cert.enrichment``."""
144
+ try:
145
+ d = validate_domain(domain)
146
+ except McpValidationError as exc:
147
+ return exc.to_tool_error()
148
+ return await client.get(
149
+ f"/get_latest_cert/{d}",
150
+ params={"include_enrichment": include_enrichment},
151
+ )
152
+
153
+ @mcp.tool()
154
+ async def get_expiring_certs(domain: str, days: int = 30) -> dict[str, Any]:
155
+ """Certificates for ``domain`` expiring within ``days`` days."""
156
+ try:
157
+ d = validate_domain(domain)
158
+ params = {"days": clamp_expiring_days(days)}
159
+ except McpValidationError as exc:
160
+ return exc.to_tool_error()
161
+ return await client.get(f"/get_expiring_certs/{d}", params=params)
162
+
163
+ return mcp
164
+
165
+
166
+ def main() -> None:
167
+ """CLI entry point — runs the MCP server over stdio."""
168
+ logging.basicConfig(
169
+ level=logging.INFO,
170
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
171
+ )
172
+ client = CertIndexClient()
173
+ server = _build_server(client)
174
+ asyncio.run(server.run_stdio_async())
175
+
176
+
177
+ if __name__ == "__main__":
178
+ main()
@@ -0,0 +1,149 @@
1
+ """Client-side input validation for certindex-mcp.
2
+
3
+ These validators are an exact mirror of the server-side validators in
4
+ the CertIndex monorepo (``api/mcp_validation.py``). We duplicate them
5
+ here so that:
6
+
7
+ 1. Invalid input is rejected **before** a network round-trip — the
8
+ LLM gets immediate feedback and the upstream service never sees
9
+ malformed traffic.
10
+ 2. The package can be audited independently without cloning the
11
+ server monorepo.
12
+
13
+ When the two implementations diverge, the server-side validators are
14
+ authoritative — the worst that can happen with a stale client copy is
15
+ a redundant 422 from the server.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+
21
+
22
+ class McpValidationError(ValueError):
23
+ """Raised when a tool parameter fails validation."""
24
+
25
+ def __init__(self, parameter: str, reason: str) -> None:
26
+ super().__init__(f"{parameter}: {reason}")
27
+ self.parameter = parameter
28
+ self.reason = reason
29
+
30
+ def to_tool_error(self) -> dict:
31
+ return {
32
+ "error": "invalid_parameter",
33
+ "parameter": self.parameter,
34
+ "message": self.reason,
35
+ }
36
+
37
+
38
+ DOMAIN_MAX_LEN = 253
39
+ LABEL_MAX_LEN = 63
40
+ SHA256_HEX_LEN = 64
41
+ SUBSTR_MAX_LEN = 255
42
+ PAGE_MAX = 10_000
43
+ LIMIT_MAX = 50
44
+ EXPIRING_DAYS_MAX = 365
45
+
46
+ _LABEL = r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
47
+ _DOMAIN_RE = re.compile(rf"^(?:{_LABEL}\.)+{_LABEL}$")
48
+ _SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
49
+ _SUBSTR_RE = re.compile(r"^[A-Za-z0-9 .,/&'()\-]+$")
50
+
51
+
52
+ def validate_domain(value: str, *, parameter: str = "domain") -> str:
53
+ if not isinstance(value, str) or not value.strip():
54
+ raise McpValidationError(parameter, "value is required")
55
+ v = value.strip().lower()
56
+ if v.startswith("*."):
57
+ v = v[2:]
58
+ if not v:
59
+ raise McpValidationError(parameter, "value is required")
60
+ if len(v) > DOMAIN_MAX_LEN:
61
+ raise McpValidationError(parameter, f"length exceeds {DOMAIN_MAX_LEN} octets")
62
+ if any(len(label) > LABEL_MAX_LEN for label in v.split(".")):
63
+ raise McpValidationError(parameter, f"label exceeds {LABEL_MAX_LEN} octets")
64
+ if not _DOMAIN_RE.match(v):
65
+ raise McpValidationError(
66
+ parameter,
67
+ "must be a well-formed registrable hostname "
68
+ "(LDH labels separated by dots; no IP literals, ports, or paths)",
69
+ )
70
+ if v.split(".")[-1].isdigit():
71
+ raise McpValidationError(
72
+ parameter,
73
+ "must be a well-formed registrable hostname "
74
+ "(IP literals are not accepted)",
75
+ )
76
+ return v
77
+
78
+
79
+ def validate_sha256_hex(value: str, *, parameter: str = "sha256") -> str:
80
+ if not isinstance(value, str):
81
+ raise McpValidationError(parameter, "value is required")
82
+ v = value.strip().lower()
83
+ if len(v) != SHA256_HEX_LEN or not _SHA256_RE.match(v):
84
+ raise McpValidationError(
85
+ parameter,
86
+ f"must be a {SHA256_HEX_LEN}-character lowercase hex SHA-256 fingerprint",
87
+ )
88
+ return v
89
+
90
+
91
+ def validate_substring(value: str | None, *, parameter: str) -> str | None:
92
+ if value is None:
93
+ return None
94
+ if not isinstance(value, str):
95
+ raise McpValidationError(parameter, "value must be a string")
96
+ v = value.strip()
97
+ if not v:
98
+ return None
99
+ if len(v) > SUBSTR_MAX_LEN:
100
+ raise McpValidationError(parameter, f"length exceeds {SUBSTR_MAX_LEN} characters")
101
+ if not _SUBSTR_RE.match(v):
102
+ raise McpValidationError(
103
+ parameter,
104
+ "contains disallowed characters "
105
+ "(allowed: letters, digits, spaces, and . , / & ' ( ) -)",
106
+ )
107
+ return v
108
+
109
+
110
+ def validate_san(value: str | None) -> str | None:
111
+ """Exact-match SAN filter. Preserves a leading ``*.`` wildcard
112
+ so wildcard SAN entries round-trip verbatim — without this, the
113
+ apex would be queried instead and wildcard SANs would silently
114
+ become unmatchable. Mirror of the server-side ``validate_san``.
115
+ """
116
+ if value is None:
117
+ return None
118
+ if not isinstance(value, str):
119
+ raise McpValidationError("san", "value must be a string")
120
+ raw = value.strip().lower()
121
+ if not raw:
122
+ return None
123
+ if raw.startswith("*."):
124
+ apex = raw[2:]
125
+ validate_domain(apex, parameter="san")
126
+ return "*." + apex
127
+ return validate_domain(raw, parameter="san")
128
+
129
+
130
+ def clamp_limit(value: int, *, parameter: str = "limit") -> int:
131
+ if not isinstance(value, int) or isinstance(value, bool):
132
+ raise McpValidationError(parameter, "must be an integer")
133
+ return max(1, min(LIMIT_MAX, value))
134
+
135
+
136
+ def clamp_page(value: int, *, parameter: str = "page") -> int:
137
+ if not isinstance(value, int) or isinstance(value, bool):
138
+ raise McpValidationError(parameter, "must be an integer")
139
+ if value < 1:
140
+ raise McpValidationError(parameter, "must be >= 1")
141
+ if value > PAGE_MAX:
142
+ raise McpValidationError(parameter, f"must be <= {PAGE_MAX}")
143
+ return value
144
+
145
+
146
+ def clamp_expiring_days(value: int, *, parameter: str = "days") -> int:
147
+ if not isinstance(value, int) or isinstance(value, bool):
148
+ raise McpValidationError(parameter, "must be an integer")
149
+ return max(1, min(EXPIRING_DAYS_MAX, value))
@@ -0,0 +1,140 @@
1
+ """Client-side wiring tests for :mod:`certindex_mcp.client`."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from certindex_mcp.client import CertIndexClient, CertIndexConfigError
7
+
8
+
9
+ def test_missing_api_key_raises(monkeypatch):
10
+ monkeypatch.delenv("CERTINDEX_API_KEY", raising=False)
11
+ with pytest.raises(CertIndexConfigError):
12
+ CertIndexClient()
13
+
14
+
15
+ def test_explicit_api_key_overrides_env(monkeypatch):
16
+ monkeypatch.setenv("CERTINDEX_API_KEY", "env-key")
17
+ c = CertIndexClient(api_key="explicit-key")
18
+ assert c.api_key == "explicit-key"
19
+
20
+
21
+ def test_base_url_requires_scheme(monkeypatch):
22
+ monkeypatch.setenv("CERTINDEX_API_KEY", "k")
23
+ with pytest.raises(CertIndexConfigError):
24
+ CertIndexClient(base_url="api.ctindex.io")
25
+
26
+
27
+ def test_base_url_rejects_plain_http_to_remote_host(monkeypatch):
28
+ """SECURITY.md promises HTTPS-only transport. Plain ``http://``
29
+ against an arbitrary host would downgrade traffic carrying the
30
+ API key — only an explicit localhost carve-out is allowed."""
31
+ monkeypatch.setenv("CERTINDEX_API_KEY", "k")
32
+ with pytest.raises(CertIndexConfigError):
33
+ CertIndexClient(base_url="http://evil.example.com")
34
+
35
+
36
+ def test_base_url_allows_http_to_localhost(monkeypatch):
37
+ """Self-hosted dev on the loopback interface is the only
38
+ legitimate plain-HTTP case."""
39
+ monkeypatch.setenv("CERTINDEX_API_KEY", "k")
40
+ c = CertIndexClient(base_url="http://localhost:8080")
41
+ assert c.base_url == "http://localhost:8080"
42
+ c2 = CertIndexClient(base_url="http://127.0.0.1:8080")
43
+ assert c2.base_url == "http://127.0.0.1:8080"
44
+
45
+
46
+ def test_explicit_empty_api_key_raises(monkeypatch):
47
+ monkeypatch.delenv("CERTINDEX_API_KEY", raising=False)
48
+ with pytest.raises(CertIndexConfigError):
49
+ CertIndexClient(api_key=" ")
50
+
51
+
52
+ def test_default_base_url(monkeypatch):
53
+ monkeypatch.setenv("CERTINDEX_API_KEY", "k")
54
+ c = CertIndexClient()
55
+ assert c.base_url == "https://api.ctindex.io"
56
+
57
+
58
+ def test_trailing_slash_stripped(monkeypatch):
59
+ monkeypatch.setenv("CERTINDEX_API_KEY", "k")
60
+ c = CertIndexClient(base_url="https://staging.ctindex.io/")
61
+ assert c.base_url == "https://staging.ctindex.io"
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_get_returns_structured_error_on_4xx(monkeypatch):
66
+ import respx
67
+ from httpx import Response
68
+
69
+ monkeypatch.setenv("CERTINDEX_API_KEY", "k")
70
+ c = CertIndexClient(base_url="https://example.test")
71
+ with respx.mock(base_url="https://example.test") as router:
72
+ router.get("/mcp-api/search_certificates").mock(
73
+ return_value=Response(
74
+ 422,
75
+ json={
76
+ "detail": {
77
+ "error": "invalid_parameter",
78
+ "parameter": "cn",
79
+ "message": "contains disallowed characters",
80
+ }
81
+ },
82
+ )
83
+ )
84
+ body = await c.get("/search_certificates", params={"cn": "x%y"})
85
+ assert body["error"] == "invalid_parameter"
86
+ assert body["parameter"] == "cn"
87
+ assert body["status"] == 422
88
+
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_search_forwards_wildcard_san_verbatim(monkeypatch):
92
+ """End-to-end parity guard: a wildcard SAN must be forwarded to
93
+ ``/mcp-api/search_certificates`` as ``*.example.com``, not the
94
+ apex. Exercises the full server tool path including validation +
95
+ request building."""
96
+ import respx
97
+ from httpx import Response
98
+
99
+ from certindex_mcp.client import CertIndexClient
100
+ from certindex_mcp.server import _build_server
101
+
102
+ monkeypatch.setenv("CERTINDEX_API_KEY", "k")
103
+ client = CertIndexClient(base_url="https://example.test")
104
+ server = _build_server(client)
105
+ # FastMCP stores registered tools on the underlying tool manager;
106
+ # call ours directly to bypass MCP transport.
107
+ tool = server._tool_manager._tools["search_certificates"]
108
+ with respx.mock(base_url="https://example.test") as router:
109
+ route = router.get("/mcp-api/search_certificates").mock(
110
+ return_value=Response(
111
+ 200, json={"results": [], "has_more": False, "page": 1, "limit": 10}
112
+ )
113
+ )
114
+ await tool.fn(san="*.example.com")
115
+ qs = str(route.calls.last.request.url)
116
+ # URL-encoded; ``*`` may or may not be encoded depending on httpx,
117
+ # so accept either form.
118
+ assert "san=%2A.example.com" in qs or "san=*.example.com" in qs
119
+
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_get_drops_none_params(monkeypatch):
123
+ import respx
124
+ from httpx import Response
125
+
126
+ monkeypatch.setenv("CERTINDEX_API_KEY", "k")
127
+ c = CertIndexClient(base_url="https://example.test")
128
+ with respx.mock(base_url="https://example.test") as router:
129
+ route = router.get("/mcp-api/search_certificates").mock(
130
+ return_value=Response(200, json={"results": []})
131
+ )
132
+ await c.get(
133
+ "/search_certificates",
134
+ params={"domain": "example.com", "cn": None, "issuer": None},
135
+ )
136
+ # URL should contain ``domain`` but not the dropped Nones.
137
+ qs = str(route.calls.last.request.url)
138
+ assert "domain=example.com" in qs
139
+ assert "cn=" not in qs
140
+ assert "issuer=" not in qs
@@ -0,0 +1,100 @@
1
+ """Mirror of the server-side validator tests, kept in-package so the
2
+ distribution can be audited without cloning the server monorepo."""
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from certindex_mcp.validation import (
8
+ LIMIT_MAX,
9
+ SHA256_HEX_LEN,
10
+ McpValidationError,
11
+ clamp_expiring_days,
12
+ clamp_limit,
13
+ clamp_page,
14
+ validate_domain,
15
+ validate_san,
16
+ validate_sha256_hex,
17
+ validate_substring,
18
+ )
19
+
20
+
21
+ @pytest.mark.parametrize(
22
+ "raw,expected",
23
+ [
24
+ ("example.com", "example.com"),
25
+ (" EXAMPLE.COM ", "example.com"),
26
+ ("*.example.com", "example.com"),
27
+ ("api.v2.example.co.uk", "api.v2.example.co.uk"),
28
+ ],
29
+ )
30
+ def test_validate_domain_accepts(raw, expected):
31
+ assert validate_domain(raw) == expected
32
+
33
+
34
+ @pytest.mark.parametrize(
35
+ "bad",
36
+ [
37
+ "", " ", "localhost", "example.com:8080", "http://example.com",
38
+ "user@example.com", "example..com", "-x.com", "x-.com",
39
+ "exa mple.com", "exa%mple.com", "exa_mple.com", "a" * 64 + ".com",
40
+ ],
41
+ )
42
+ def test_validate_domain_rejects(bad):
43
+ with pytest.raises(McpValidationError):
44
+ validate_domain(bad)
45
+
46
+
47
+ def test_validate_sha256_accepts():
48
+ h = "a" * SHA256_HEX_LEN
49
+ assert validate_sha256_hex(h.upper()) == h
50
+
51
+
52
+ @pytest.mark.parametrize("bad", ["", "abc", "g" * SHA256_HEX_LEN, "a" * 63])
53
+ def test_validate_sha256_rejects(bad):
54
+ with pytest.raises(McpValidationError):
55
+ validate_sha256_hex(bad)
56
+
57
+
58
+ def test_validate_substring_empty_is_none():
59
+ assert validate_substring("", parameter="cn") is None
60
+ assert validate_substring(None, parameter="cn") is None
61
+
62
+
63
+ @pytest.mark.parametrize("bad", ["a%b", "a_b", "x\x00y", "drop;table"])
64
+ def test_validate_substring_rejects(bad):
65
+ with pytest.raises(McpValidationError):
66
+ validate_substring(bad, parameter="cn")
67
+
68
+
69
+ def test_validate_san_preserves_wildcard_prefix():
70
+ """Parity guard with the in-tree validator: wildcard SANs are
71
+ first-class CT entries and must round-trip verbatim through the
72
+ OSS shim, otherwise ``search_certificates(san='*.example.com')``
73
+ would silently rewrite to the apex and miss wildcard SAN rows."""
74
+ assert validate_san("*.example.com") == "*.example.com"
75
+ assert validate_san(" *.EXAMPLE.com ") == "*.example.com"
76
+ assert validate_san("api.example.com") == "api.example.com"
77
+ assert validate_san(None) is None
78
+ assert validate_san("") is None
79
+ with pytest.raises(McpValidationError):
80
+ validate_san("not a domain")
81
+
82
+
83
+ def test_validate_domain_rejects_leading_dots_or_stars():
84
+ """Regression: a previous ``lstrip('*.')`` implementation silently
85
+ normalised ``.example.com`` and ``**.example.com`` to the apex."""
86
+ with pytest.raises(McpValidationError):
87
+ validate_domain(".example.com")
88
+ with pytest.raises(McpValidationError):
89
+ validate_domain("*..example.com")
90
+ with pytest.raises(McpValidationError):
91
+ validate_domain("**.example.com")
92
+
93
+
94
+ def test_numeric_clamps():
95
+ assert clamp_limit(0) == 1
96
+ assert clamp_limit(LIMIT_MAX + 99) == LIMIT_MAX
97
+ assert clamp_page(1) == 1
98
+ with pytest.raises(McpValidationError):
99
+ clamp_page(0)
100
+ assert clamp_expiring_days(99999) == 365