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.
- certindex_mcp-0.1.0/.github/workflows/ci.yml +47 -0
- certindex_mcp-0.1.0/.gitignore +10 -0
- certindex_mcp-0.1.0/LICENSE +21 -0
- certindex_mcp-0.1.0/PKG-INFO +161 -0
- certindex_mcp-0.1.0/README.md +106 -0
- certindex_mcp-0.1.0/SECURITY.md +41 -0
- certindex_mcp-0.1.0/pyproject.toml +70 -0
- certindex_mcp-0.1.0/src/certindex_mcp/__init__.py +9 -0
- certindex_mcp-0.1.0/src/certindex_mcp/client.py +118 -0
- certindex_mcp-0.1.0/src/certindex_mcp/server.py +178 -0
- certindex_mcp-0.1.0/src/certindex_mcp/validation.py +149 -0
- certindex_mcp-0.1.0/tests/test_client.py +140 -0
- certindex_mcp-0.1.0/tests/test_validation.py +100 -0
|
@@ -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,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
|
+
[](https://github.com/certindex/certindex-mcp/actions/workflows/ci.yml)
|
|
59
|
+
[](https://pypi.org/project/certindex-mcp/)
|
|
60
|
+
[](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
|
+
[](https://github.com/certindex/certindex-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/certindex-mcp/)
|
|
5
|
+
[](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,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
|