ciphra-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,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ *.egg-info/
5
+ .coverage
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sanjay Selvam
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,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: ciphra-mcp
3
+ Version: 0.1.0
4
+ Summary: Python MCP server for Ciphra. Lets AI coding agents call Ciphra's secret scanner via the Model Context Protocol.
5
+ Project-URL: Homepage, https://github.com/sanjayselvam31/ciphra
6
+ Project-URL: Repository, https://github.com/sanjayselvam31/ciphra
7
+ Project-URL: Issues, https://github.com/sanjayselvam31/ciphra/issues
8
+ Author: Sanjay Selvam
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,ciphra,mcp,scanner,secrets,security
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: mcp>=1.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # ciphra-mcp
28
+
29
+ Python MCP server exposing Ciphra's secret scanner to AI agents.
30
+
31
+ ## What this is
32
+
33
+ The agent-facing layer for [Ciphra](../README.md). The actual scanning is done by the TypeScript CLI at the repo root; this Python project spawns the CLI as a subprocess and translates between MCP's JSON-RPC protocol and Ciphra's JSON output. With this server registered, agents like Claude Code and Cursor can scan a codebase, validate a key, or audit git history without the user typing CLI commands.
34
+
35
+ ## Prerequisites
36
+
37
+ - **Node 20+** — needed for the underlying `ciphra` CLI, which this MCP server shells out to
38
+ - **Python 3.11+** — for the MCP server itself
39
+ - **[uv](https://github.com/astral-sh/uv)** (recommended) or `pip + venv`
40
+ - **`ciphra` on `$PATH`.** The MCP server resolves the CLI via `shutil.which("ciphra")`, so it must be installed globally:
41
+
42
+ ```bash
43
+ npm install -g ciphra
44
+ ```
45
+
46
+ If you get an `EACCES` error on `npm install -g`, your npm prefix is root-owned. The cleanest fix:
47
+
48
+ ```bash
49
+ mkdir -p ~/.npm-global
50
+ npm config set prefix ~/.npm-global
51
+ echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc
52
+ source ~/.zshrc
53
+ npm install -g ciphra
54
+ ```
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ uvx ciphra-mcp # ephemeral; runs the published wheel on demand
60
+ ```
61
+
62
+ That's it — `uvx` downloads the wheel from PyPI on first invocation, then runs the entry point. No clone required.
63
+
64
+ If you'd rather have it permanently installed in a project-local venv:
65
+
66
+ ```bash
67
+ uv pip install ciphra-mcp
68
+ ```
69
+
70
+ ### Building from source (contributors)
71
+
72
+ ```bash
73
+ cd /path/to/ciphra
74
+ npm install && npm run build # build the TS CLI
75
+
76
+ cd mcp-server
77
+ uv sync --extra dev # creates .venv, installs deps
78
+ ```
79
+
80
+ (pip alternative: `python3.11 -m venv .venv && source .venv/bin/activate && pip install -e ".[dev]"`)
81
+
82
+ ## Running the server
83
+
84
+ **Standalone (sanity check that it launches):**
85
+
86
+ ```bash
87
+ uv run ciphra-mcp
88
+ ```
89
+
90
+ The server speaks MCP over stdio and isn't useful interactively. Send EOF (`Ctrl-D`) to exit. Most of the time you won't run this directly — the MCP client launches it for you.
91
+
92
+ **As an MCP server in Claude Code:**
93
+
94
+ Add to `~/.claude.json` (create the file if it doesn't exist):
95
+
96
+ ```json
97
+ {
98
+ "mcpServers": {
99
+ "ciphra": {
100
+ "command": "uvx",
101
+ "args": ["ciphra-mcp"]
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ Restart Claude Code. The four Ciphra tools become available to the agent automatically.
108
+
109
+ **As an MCP server in Cursor:**
110
+
111
+ Add the same block to `~/.cursor/mcp.json`. Restart Cursor.
112
+
113
+ **Contributor / local-development alternative.** If you're running from a source checkout (the "Building from source" path above) instead of the published wheel, point the MCP client at your local venv:
114
+
115
+ ```json
116
+ {
117
+ "mcpServers": {
118
+ "ciphra": {
119
+ "command": "uv",
120
+ "args": ["--directory", "/absolute/path/to/ciphra/mcp-server", "run", "ciphra-mcp"]
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ ## The four MCP tools
127
+
128
+ - **`scan_directory`** — the primary tool for "is this codebase secure?" / "are there leaked keys?" prompts. Scans a directory and (by default) its git history across all 10 supported services. Validation is opt-in. Example: *"Scan this directory for leaked keys."*
129
+
130
+ - **`check_specific_service`** — use when the user has named a single service of concern. Faster than `scan_directory` because only that service's detectors run, and defaults to live validation since scope is narrow. Example: *"I'm worried about Stripe keys in this codebase."*
131
+
132
+ - **`scan_git_history`** — scans only the commit history (current files are not touched). For auditing keys that were committed and later removed but remain recoverable from history. Example: *"Did I ever commit an AWS key I later deleted?"*
133
+
134
+ - **`validate_key`** — checks whether a specific key value is currently active against its service's live API. For "has this key value been used in my repo?" questions, agents should grep first; this tool is for one-shot live-status checks. Example: *"Is `sk_test_abc123` a real Stripe key?"*
135
+
136
+ The exact descriptions live in [src/ciphra_mcp/server.py](src/ciphra_mcp/server.py). Agents read those directly — when in doubt about how a prompt routes, that file is the source of truth.
137
+
138
+ ## Running tests
139
+
140
+ ```bash
141
+ uv run pytest
142
+ ```
143
+
144
+ The suite is full integration: tests spawn the real CLI subprocess. A `pytest_sessionstart` hook checks that `dist/cli/index.js` is current relative to `src/**/*.ts`; if any TS file is newer, the session aborts before collection with the offending paths and an instruction to run `npm run build` from the repo root.
145
+
146
+ Set `CIPHRA_SKIP_STALENESS_CHECK=1` to bypass — useful in CI where the build is guaranteed to have already run.
147
+
148
+ ## Layout
149
+
150
+ ```
151
+ src/ciphra_mcp/
152
+ server.py FastMCP setup, four @mcp.tool registrations
153
+ ciphra_client.py Async subprocess wrapper around the TS CLI
154
+ types.py TypedDicts mirroring the CLI JSON schema (v1.0)
155
+ tests/
156
+ conftest.py Staleness check + EXPECTED_TOOL_NAMES constant
157
+ test_*.py Integration tests (33 in total)
158
+ scripts/
159
+ verify_handshake.py Manual MCP protocol handshake check
160
+ verify_*.py Per-tool one-shot verification scripts
161
+ ```
162
+
163
+ ## Security notes
164
+
165
+ - **Validated keys hit live third-party APIs.** Defaults: `scan_directory` and `scan_git_history` are validate=OFF; `check_specific_service` is validate=ON (the user has narrowed scope to one service, so the cost is bounded); `validate_key` always sends. Don't validate keys you don't own or haven't been explicitly asked about — the call is logged at the third-party service.
166
+ - **Key values are never logged or returned in tool output.** Findings include a redacted preview (first/last 4 chars); validation results contain only status and reason.
167
+ - **`validation: invalid` is not a safety signal.** It means the key isn't currently authenticating — but it was exposed in the codebase, so the leak still happened. Rotate regardless.
168
+
169
+ ## Troubleshooting
170
+
171
+ - **`ciphra-mcp: command not found`** — run `uv sync --extra dev` from `mcp-server/`, or activate the venv if using pip.
172
+ - **"TypeScript source is newer than dist/cli/index.js"** — run `npm run build` from the repo root.
173
+ - **Agent says no Ciphra tools are available** — the MCP client likely cached an older state of the server (e.g. from before tools were wired up). Restart the client. New tool descriptions also require a restart — the running Python process captures the description strings at import time.
174
+ - **Tools appear but calls fail with `CiphraNotFoundError`** — the MCP config points at the wrong directory, or `npm run build` hasn't been run. Verify by running `uv run ciphra-mcp` from a terminal in `mcp-server/`; it should launch silently and wait on stdin.
175
+ - **"Server has 0 tools"** in `verify_handshake.py` — almost always a stale running process. Check that `mcp-server/src/ciphra_mcp/server.py` has the four `@mcp.tool(...)` decorators and reload your client.
@@ -0,0 +1,149 @@
1
+ # ciphra-mcp
2
+
3
+ Python MCP server exposing Ciphra's secret scanner to AI agents.
4
+
5
+ ## What this is
6
+
7
+ The agent-facing layer for [Ciphra](../README.md). The actual scanning is done by the TypeScript CLI at the repo root; this Python project spawns the CLI as a subprocess and translates between MCP's JSON-RPC protocol and Ciphra's JSON output. With this server registered, agents like Claude Code and Cursor can scan a codebase, validate a key, or audit git history without the user typing CLI commands.
8
+
9
+ ## Prerequisites
10
+
11
+ - **Node 20+** — needed for the underlying `ciphra` CLI, which this MCP server shells out to
12
+ - **Python 3.11+** — for the MCP server itself
13
+ - **[uv](https://github.com/astral-sh/uv)** (recommended) or `pip + venv`
14
+ - **`ciphra` on `$PATH`.** The MCP server resolves the CLI via `shutil.which("ciphra")`, so it must be installed globally:
15
+
16
+ ```bash
17
+ npm install -g ciphra
18
+ ```
19
+
20
+ If you get an `EACCES` error on `npm install -g`, your npm prefix is root-owned. The cleanest fix:
21
+
22
+ ```bash
23
+ mkdir -p ~/.npm-global
24
+ npm config set prefix ~/.npm-global
25
+ echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc
26
+ source ~/.zshrc
27
+ npm install -g ciphra
28
+ ```
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ uvx ciphra-mcp # ephemeral; runs the published wheel on demand
34
+ ```
35
+
36
+ That's it — `uvx` downloads the wheel from PyPI on first invocation, then runs the entry point. No clone required.
37
+
38
+ If you'd rather have it permanently installed in a project-local venv:
39
+
40
+ ```bash
41
+ uv pip install ciphra-mcp
42
+ ```
43
+
44
+ ### Building from source (contributors)
45
+
46
+ ```bash
47
+ cd /path/to/ciphra
48
+ npm install && npm run build # build the TS CLI
49
+
50
+ cd mcp-server
51
+ uv sync --extra dev # creates .venv, installs deps
52
+ ```
53
+
54
+ (pip alternative: `python3.11 -m venv .venv && source .venv/bin/activate && pip install -e ".[dev]"`)
55
+
56
+ ## Running the server
57
+
58
+ **Standalone (sanity check that it launches):**
59
+
60
+ ```bash
61
+ uv run ciphra-mcp
62
+ ```
63
+
64
+ The server speaks MCP over stdio and isn't useful interactively. Send EOF (`Ctrl-D`) to exit. Most of the time you won't run this directly — the MCP client launches it for you.
65
+
66
+ **As an MCP server in Claude Code:**
67
+
68
+ Add to `~/.claude.json` (create the file if it doesn't exist):
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "ciphra": {
74
+ "command": "uvx",
75
+ "args": ["ciphra-mcp"]
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ Restart Claude Code. The four Ciphra tools become available to the agent automatically.
82
+
83
+ **As an MCP server in Cursor:**
84
+
85
+ Add the same block to `~/.cursor/mcp.json`. Restart Cursor.
86
+
87
+ **Contributor / local-development alternative.** If you're running from a source checkout (the "Building from source" path above) instead of the published wheel, point the MCP client at your local venv:
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "ciphra": {
93
+ "command": "uv",
94
+ "args": ["--directory", "/absolute/path/to/ciphra/mcp-server", "run", "ciphra-mcp"]
95
+ }
96
+ }
97
+ }
98
+ ```
99
+
100
+ ## The four MCP tools
101
+
102
+ - **`scan_directory`** — the primary tool for "is this codebase secure?" / "are there leaked keys?" prompts. Scans a directory and (by default) its git history across all 10 supported services. Validation is opt-in. Example: *"Scan this directory for leaked keys."*
103
+
104
+ - **`check_specific_service`** — use when the user has named a single service of concern. Faster than `scan_directory` because only that service's detectors run, and defaults to live validation since scope is narrow. Example: *"I'm worried about Stripe keys in this codebase."*
105
+
106
+ - **`scan_git_history`** — scans only the commit history (current files are not touched). For auditing keys that were committed and later removed but remain recoverable from history. Example: *"Did I ever commit an AWS key I later deleted?"*
107
+
108
+ - **`validate_key`** — checks whether a specific key value is currently active against its service's live API. For "has this key value been used in my repo?" questions, agents should grep first; this tool is for one-shot live-status checks. Example: *"Is `sk_test_abc123` a real Stripe key?"*
109
+
110
+ The exact descriptions live in [src/ciphra_mcp/server.py](src/ciphra_mcp/server.py). Agents read those directly — when in doubt about how a prompt routes, that file is the source of truth.
111
+
112
+ ## Running tests
113
+
114
+ ```bash
115
+ uv run pytest
116
+ ```
117
+
118
+ The suite is full integration: tests spawn the real CLI subprocess. A `pytest_sessionstart` hook checks that `dist/cli/index.js` is current relative to `src/**/*.ts`; if any TS file is newer, the session aborts before collection with the offending paths and an instruction to run `npm run build` from the repo root.
119
+
120
+ Set `CIPHRA_SKIP_STALENESS_CHECK=1` to bypass — useful in CI where the build is guaranteed to have already run.
121
+
122
+ ## Layout
123
+
124
+ ```
125
+ src/ciphra_mcp/
126
+ server.py FastMCP setup, four @mcp.tool registrations
127
+ ciphra_client.py Async subprocess wrapper around the TS CLI
128
+ types.py TypedDicts mirroring the CLI JSON schema (v1.0)
129
+ tests/
130
+ conftest.py Staleness check + EXPECTED_TOOL_NAMES constant
131
+ test_*.py Integration tests (33 in total)
132
+ scripts/
133
+ verify_handshake.py Manual MCP protocol handshake check
134
+ verify_*.py Per-tool one-shot verification scripts
135
+ ```
136
+
137
+ ## Security notes
138
+
139
+ - **Validated keys hit live third-party APIs.** Defaults: `scan_directory` and `scan_git_history` are validate=OFF; `check_specific_service` is validate=ON (the user has narrowed scope to one service, so the cost is bounded); `validate_key` always sends. Don't validate keys you don't own or haven't been explicitly asked about — the call is logged at the third-party service.
140
+ - **Key values are never logged or returned in tool output.** Findings include a redacted preview (first/last 4 chars); validation results contain only status and reason.
141
+ - **`validation: invalid` is not a safety signal.** It means the key isn't currently authenticating — but it was exposed in the codebase, so the leak still happened. Rotate regardless.
142
+
143
+ ## Troubleshooting
144
+
145
+ - **`ciphra-mcp: command not found`** — run `uv sync --extra dev` from `mcp-server/`, or activate the venv if using pip.
146
+ - **"TypeScript source is newer than dist/cli/index.js"** — run `npm run build` from the repo root.
147
+ - **Agent says no Ciphra tools are available** — the MCP client likely cached an older state of the server (e.g. from before tools were wired up). Restart the client. New tool descriptions also require a restart — the running Python process captures the description strings at import time.
148
+ - **Tools appear but calls fail with `CiphraNotFoundError`** — the MCP config points at the wrong directory, or `npm run build` hasn't been run. Verify by running `uv run ciphra-mcp` from a terminal in `mcp-server/`; it should launch silently and wait on stdin.
149
+ - **"Server has 0 tools"** in `verify_handshake.py` — almost always a stale running process. Check that `mcp-server/src/ciphra_mcp/server.py` has the four `@mcp.tool(...)` decorators and reload your client.
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "ciphra-mcp"
3
+ version = "0.1.0"
4
+ description = "Python MCP server for Ciphra. Lets AI coding agents call Ciphra's secret scanner via the Model Context Protocol."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ authors = [
10
+ { name = "Sanjay Selvam" },
11
+ ]
12
+ keywords = [
13
+ "mcp",
14
+ "security",
15
+ "scanner",
16
+ "secrets",
17
+ "ai-agents",
18
+ "ciphra",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 3 - Alpha",
22
+ "Intended Audience :: Developers",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Security",
29
+ ]
30
+ dependencies = [
31
+ "mcp>=1.0.0",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=8.0",
37
+ "pytest-asyncio>=0.23",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/sanjayselvam31/ciphra"
42
+ Repository = "https://github.com/sanjayselvam31/ciphra"
43
+ Issues = "https://github.com/sanjayselvam31/ciphra/issues"
44
+
45
+ [project.scripts]
46
+ ciphra-mcp = "ciphra_mcp.server:main"
47
+
48
+ [build-system]
49
+ requires = ["hatchling"]
50
+ build-backend = "hatchling.build"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/ciphra_mcp"]
54
+
55
+ # Sdist allowlist: explicitly include only what should ship. tests/,
56
+ # scripts/, .venv/, uv.lock, and __pycache__/ stay out.
57
+ [tool.hatch.build.targets.sdist]
58
+ include = [
59
+ "src/",
60
+ "pyproject.toml",
61
+ "README.md",
62
+ "LICENSE",
63
+ ]
64
+
65
+ [tool.pytest.ini_options]
66
+ asyncio_mode = "auto"
67
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ """Python MCP server that wraps the Ciphra TypeScript CLI."""
@@ -0,0 +1,4 @@
1
+ from ciphra_mcp.server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,457 @@
1
+ """Subprocess wrapper around the Ciphra TypeScript CLI.
2
+
3
+ The MCP server never talks to detector/scanner code directly. It shells out
4
+ to `ciphra scan --json <path>` and parses stdout. This keeps the TS scanner
5
+ authoritative and avoids a Python re-implementation.
6
+
7
+ CLI exit codes (documented contract):
8
+ 0 scan completed; no critical/high findings above threshold
9
+ 1 scan completed; critical findings present
10
+ 2 scan completed; high findings present (no critical)
11
+ 3 scan errored (path missing, invalid arg, etc.)
12
+
13
+ Exit 1 and 2 are NOT failures — they are "findings present" signals. Only
14
+ exit 3 raises CiphraScanError. Default subprocess handling would conflate
15
+ the three; that conflation would silently break the MCP tool's behavior.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import os
24
+ import shutil
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import cast
28
+
29
+ from ciphra_mcp.types import (
30
+ EXPECTED_SCHEMA_VERSION,
31
+ KNOWN_SERVICE_IDS,
32
+ ScanResult,
33
+ ServiceId,
34
+ ValidateResult,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ class CiphraError(Exception):
41
+ """Base class for all errors raised by the Ciphra subprocess wrapper."""
42
+
43
+
44
+ class CiphraNotFoundError(CiphraError):
45
+ """The Ciphra TS CLI could not be located on disk or on PATH."""
46
+
47
+
48
+ class CiphraScanError(CiphraError):
49
+ """The Ciphra CLI returned a real error (exit code 3)."""
50
+
51
+
52
+ class CiphraTimeoutError(CiphraError):
53
+ """The Ciphra subprocess did not finish before the timeout elapsed."""
54
+
55
+
56
+ class CiphraSchemaVersionError(CiphraError):
57
+ """The CLI returned JSON with an unexpected schemaVersion."""
58
+
59
+
60
+ class CiphraNotAGitRepoError(CiphraError):
61
+ """The given path is not the root of a git repository."""
62
+
63
+
64
+ def _resolve_cli() -> list[str]:
65
+ """Resolve the command (argv prefix) used to invoke the Ciphra CLI.
66
+
67
+ Lookup order:
68
+ 1. $CIPHRA_CLI_PATH if set (path to a node script or executable)
69
+ 2. `ciphra` on $PATH
70
+ 3. ../../../dist/cli/index.js relative to this file (via `node`)
71
+ """
72
+ env_path = os.environ.get("CIPHRA_CLI_PATH")
73
+ if env_path:
74
+ p = Path(env_path)
75
+ if not p.exists():
76
+ raise CiphraNotFoundError(
77
+ f"CIPHRA_CLI_PATH points to a path that does not exist: {env_path}"
78
+ )
79
+ if p.suffix == ".js":
80
+ return ["node", str(p)]
81
+ return [str(p)]
82
+
83
+ on_path = shutil.which("ciphra")
84
+ if on_path:
85
+ return [on_path]
86
+
87
+ built = Path(__file__).resolve().parents[3] / "dist" / "cli" / "index.js"
88
+ if built.exists():
89
+ return ["node", str(built)]
90
+
91
+ raise CiphraNotFoundError(
92
+ "Could not locate the Ciphra CLI. Set CIPHRA_CLI_PATH, install ciphra "
93
+ f"globally so it is on $PATH, or run `npm run build` at the repo root "
94
+ f"to produce {built}."
95
+ )
96
+
97
+
98
+ async def _run_scan_subprocess(
99
+ cli_args: list[str],
100
+ *,
101
+ timeout_seconds: float,
102
+ ) -> ScanResult:
103
+ """Spawn `ciphra scan --json <cli_args...>` and return the parsed result.
104
+
105
+ `cli_args` is everything that follows `scan --json` on the command
106
+ line — typically a path plus flags like `["--severity", "medium",
107
+ "<path>", "--no-validate"]`. The helper prepends `scan` and `--json`
108
+ automatically; do not include those in cli_args.
109
+
110
+ Exit code handling:
111
+ 0, 1, 2 -> success (CLI completed; 1/2 mean "findings present")
112
+ 3 -> CiphraScanError (real CLI error)
113
+ other -> CiphraScanError (defensive)
114
+
115
+ Used by scan_directory, scan_git_history, and check_specific_service.
116
+ A single helper keeps timeout-kill discipline, schema-version checking,
117
+ and exit-code interpretation in one place.
118
+ """
119
+ argv = _resolve_cli() + ["scan", "--json"] + cli_args
120
+
121
+ logger.debug("invoking ciphra: %s", " ".join(argv))
122
+
123
+ proc = await asyncio.create_subprocess_exec(
124
+ *argv,
125
+ stdout=asyncio.subprocess.PIPE,
126
+ stderr=asyncio.subprocess.PIPE,
127
+ env={**os.environ, "NO_COLOR": "1"},
128
+ )
129
+
130
+ try:
131
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
132
+ proc.communicate(), timeout=timeout_seconds
133
+ )
134
+ except asyncio.TimeoutError as exc:
135
+ proc.kill()
136
+ try:
137
+ await asyncio.wait_for(proc.wait(), timeout=2.0)
138
+ except asyncio.TimeoutError:
139
+ pass
140
+ raise CiphraTimeoutError(
141
+ f"ciphra scan did not finish within {timeout_seconds}s "
142
+ f"(args={cli_args})"
143
+ ) from exc
144
+
145
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
146
+ exit_code = proc.returncode
147
+
148
+ if exit_code == 3:
149
+ raise CiphraScanError(
150
+ f"ciphra scan failed (exit 3) for args={cli_args}\n"
151
+ f"stderr:\n{stderr.strip()}"
152
+ )
153
+ if exit_code not in (0, 1, 2):
154
+ raise CiphraScanError(
155
+ f"ciphra scan exited with unexpected code {exit_code} for "
156
+ f"args={cli_args}\nstderr:\n{stderr.strip()}"
157
+ )
158
+
159
+ stdout = stdout_bytes.decode("utf-8", errors="replace")
160
+ try:
161
+ parsed = json.loads(stdout)
162
+ except json.JSONDecodeError as exc:
163
+ raise CiphraScanError(
164
+ f"ciphra scan stdout was not valid JSON (exit {exit_code}):\n"
165
+ f"{stdout[:500]}"
166
+ ) from exc
167
+
168
+ if stderr.strip():
169
+ logger.debug("ciphra stderr: %s", stderr.strip())
170
+
171
+ actual_schema = parsed.get("schemaVersion")
172
+ if actual_schema != EXPECTED_SCHEMA_VERSION:
173
+ raise CiphraSchemaVersionError(
174
+ f"Ciphra CLI returned schemaVersion={actual_schema!r}, "
175
+ f"this client expects {EXPECTED_SCHEMA_VERSION!r}. Either upgrade "
176
+ f"ciphra-mcp or downgrade the Ciphra CLI."
177
+ )
178
+
179
+ return cast(ScanResult, parsed)
180
+
181
+
182
+ async def scan_directory(
183
+ path: str,
184
+ *,
185
+ validate: bool = True,
186
+ history: bool = True,
187
+ severity: str = "medium",
188
+ timeout_seconds: float = 60.0,
189
+ ) -> ScanResult:
190
+ """Run `ciphra scan --json <path>` and return the parsed result.
191
+
192
+ Exit codes 0, 1, 2 are all treated as success (scan completed); only
193
+ exit 3 raises CiphraScanError. Non-zero stderr is logged at debug level.
194
+ """
195
+ cli_args: list[str] = ["--severity", severity, path]
196
+ if not validate:
197
+ cli_args.append("--no-validate")
198
+ if not history:
199
+ cli_args.append("--no-history")
200
+ return await _run_scan_subprocess(cli_args, timeout_seconds=timeout_seconds)
201
+
202
+
203
+ async def validate_key(
204
+ service_id: ServiceId,
205
+ key: str,
206
+ *,
207
+ timeout_seconds: float = 15.0,
208
+ ) -> ValidateResult:
209
+ """Run `ciphra validate --json <service_id> --key-stdin` and return the result.
210
+
211
+ The key is written to the subprocess's stdin and never appears on argv,
212
+ in environment variables, or in any log/error output produced by this
213
+ function. The TS side enforces the same discipline (see
214
+ src/cli/validate.ts at the repo root and tests/cli-validate-contract).
215
+
216
+ SECURITY: do not add logging that references the `key` parameter. Do
217
+ not include `key` in any exception message. The Python-side
218
+ marker-key test in test_tools.test_validate_key_does_not_leak_key is
219
+ the regression guard for this rule.
220
+ """
221
+ argv = _resolve_cli() + ["validate", service_id, "--json", "--key-stdin"]
222
+
223
+ logger.debug("invoking ciphra validate for service=%s", service_id)
224
+
225
+ proc = await asyncio.create_subprocess_exec(
226
+ *argv,
227
+ stdin=asyncio.subprocess.PIPE,
228
+ stdout=asyncio.subprocess.PIPE,
229
+ stderr=asyncio.subprocess.PIPE,
230
+ env={**os.environ, "NO_COLOR": "1"},
231
+ )
232
+
233
+ # communicate(input=...) writes input to stdin, closes stdin, then
234
+ # concurrently reads stdout+stderr to EOF and waits for the process.
235
+ # We use it (rather than manual write+drain+close) because it handles
236
+ # cross-version asyncio quirks and avoids deadlocking on large stderr.
237
+ payload = (key + "\n").encode("utf-8")
238
+ try:
239
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
240
+ proc.communicate(input=payload),
241
+ timeout=timeout_seconds,
242
+ )
243
+ except asyncio.TimeoutError as exc:
244
+ proc.kill()
245
+ try:
246
+ await asyncio.wait_for(proc.wait(), timeout=2.0)
247
+ except asyncio.TimeoutError:
248
+ pass
249
+ # Do NOT include the key in this message.
250
+ raise CiphraTimeoutError(
251
+ f"ciphra validate did not finish within {timeout_seconds}s "
252
+ f"(service_id={service_id!r})"
253
+ ) from exc
254
+
255
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
256
+ exit_code = proc.returncode
257
+
258
+ if exit_code == 3:
259
+ # The TS side guarantees its stderr never contains the key, so
260
+ # passing stderr through is safe. The `key` parameter itself is
261
+ # NEVER included in this message.
262
+ raise CiphraScanError(
263
+ f"ciphra validate failed (exit 3) for service_id={service_id!r}\n"
264
+ f"stderr:\n{stderr.strip()}"
265
+ )
266
+ if exit_code != 0:
267
+ raise CiphraScanError(
268
+ f"ciphra validate exited with unexpected code {exit_code} for "
269
+ f"service_id={service_id!r}\nstderr:\n{stderr.strip()}"
270
+ )
271
+
272
+ stdout = stdout_bytes.decode("utf-8", errors="replace")
273
+ try:
274
+ parsed = json.loads(stdout)
275
+ except json.JSONDecodeError as exc:
276
+ raise CiphraScanError(
277
+ f"ciphra validate stdout was not valid JSON (exit {exit_code})"
278
+ ) from exc
279
+
280
+ if stderr.strip():
281
+ logger.debug("ciphra stderr: %s", stderr.strip())
282
+
283
+ actual_schema = parsed.get("schemaVersion")
284
+ if actual_schema != EXPECTED_SCHEMA_VERSION:
285
+ raise CiphraSchemaVersionError(
286
+ f"Ciphra CLI returned schemaVersion={actual_schema!r}, "
287
+ f"this client expects {EXPECTED_SCHEMA_VERSION!r}."
288
+ )
289
+
290
+ logger.debug(
291
+ "validated service=%s status=%s",
292
+ service_id,
293
+ parsed.get("validation", {}).get("status"),
294
+ )
295
+
296
+ return cast(ValidateResult, parsed)
297
+
298
+
299
+ async def scan_git_history(
300
+ path: str,
301
+ *,
302
+ validate: bool = False,
303
+ severity: str = "medium",
304
+ timeout_seconds: float = 120.0,
305
+ ) -> ScanResult:
306
+ """Scan only the git commit history of a repository for exposed secrets.
307
+
308
+ The TS CLI has no `--history-only` flag — instead, this function runs
309
+ a normal `ciphra scan --json` (history enabled by default) and then
310
+ filters the findings array to only those with source == "git-history".
311
+ Summary counts are recomputed to match the filtered set. This is the
312
+ ONLY place in the codebase where Python mutates a ScanResult after
313
+ receiving it from the CLI; if you add new mutation points elsewhere,
314
+ add a comment so future readers can grep for them.
315
+
316
+ Why filter on the Python side: keeps the TS CLI surface area minimal
317
+ (no extra flag, no new code path to test) and the filter is O(n) on
318
+ findings. The full git walk happens on the TS side regardless.
319
+
320
+ Git-repo detection: we check `<path>/.git` exists (matches the TS
321
+ side's check at src/scanner/git-history.ts). This is strict — passing
322
+ a subdirectory of a repo raises CiphraNotAGitRepoError even though
323
+ the subdir is technically inside the repo. Symmetric with the CLI
324
+ behavior, simpler than walking parents, no subprocess needed.
325
+ """
326
+ git_marker = Path(path) / ".git"
327
+ if not git_marker.exists():
328
+ raise CiphraNotAGitRepoError(
329
+ f"Path is not inside a git repository: {path} "
330
+ f"(no .git marker at {git_marker})"
331
+ )
332
+
333
+ argv = _resolve_cli() + ["scan", path, "--json", "--severity", severity]
334
+ if not validate:
335
+ argv.append("--no-validate")
336
+
337
+ logger.debug("invoking ciphra scan (history-only filter) for %s", path)
338
+
339
+ proc = await asyncio.create_subprocess_exec(
340
+ *argv,
341
+ stdout=asyncio.subprocess.PIPE,
342
+ stderr=asyncio.subprocess.PIPE,
343
+ env={**os.environ, "NO_COLOR": "1"},
344
+ )
345
+
346
+ try:
347
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
348
+ proc.communicate(), timeout=timeout_seconds
349
+ )
350
+ except asyncio.TimeoutError as exc:
351
+ proc.kill()
352
+ try:
353
+ await asyncio.wait_for(proc.wait(), timeout=2.0)
354
+ except asyncio.TimeoutError:
355
+ pass
356
+ raise CiphraTimeoutError(
357
+ f"ciphra scan_git_history did not finish within {timeout_seconds}s "
358
+ f"(path={path!r})"
359
+ ) from exc
360
+
361
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
362
+ exit_code = proc.returncode
363
+
364
+ if exit_code == 3:
365
+ raise CiphraScanError(
366
+ f"ciphra scan failed (exit 3) for path={path!r}\nstderr:\n{stderr.strip()}"
367
+ )
368
+ if exit_code not in (0, 1, 2):
369
+ raise CiphraScanError(
370
+ f"ciphra scan exited with unexpected code {exit_code} for path={path!r}\n"
371
+ f"stderr:\n{stderr.strip()}"
372
+ )
373
+
374
+ stdout = stdout_bytes.decode("utf-8", errors="replace")
375
+ try:
376
+ parsed = json.loads(stdout)
377
+ except json.JSONDecodeError as exc:
378
+ raise CiphraScanError(
379
+ f"ciphra scan stdout was not valid JSON (exit {exit_code}):\n"
380
+ f"{stdout[:500]}"
381
+ ) from exc
382
+
383
+ if stderr.strip():
384
+ logger.debug("ciphra stderr: %s", stderr.strip())
385
+
386
+ actual_schema = parsed.get("schemaVersion")
387
+ if actual_schema != EXPECTED_SCHEMA_VERSION:
388
+ raise CiphraSchemaVersionError(
389
+ f"Ciphra CLI returned schemaVersion={actual_schema!r}, "
390
+ f"this client expects {EXPECTED_SCHEMA_VERSION!r}."
391
+ )
392
+
393
+ # MUTATION POINT (1 of 1 in this codebase): filter to history-only +
394
+ # recompute summary counts. Everything else in the ScanResult is
395
+ # passed through as-is.
396
+ history_findings = [
397
+ f for f in parsed["findings"] if f.get("source") == "git-history"
398
+ ]
399
+ severities = {"critical": 0, "high": 0, "medium": 0, "info": 0}
400
+ by_service: dict[str, int] = {}
401
+ for f in history_findings:
402
+ sev = f.get("severity")
403
+ if sev in severities:
404
+ severities[sev] += 1
405
+ name = f.get("serviceName")
406
+ if name:
407
+ by_service[name] = by_service.get(name, 0) + 1
408
+
409
+ parsed["findings"] = history_findings
410
+ parsed["summary"] = {
411
+ "total": len(history_findings),
412
+ **severities,
413
+ "byService": by_service,
414
+ }
415
+
416
+ return cast(ScanResult, parsed)
417
+
418
+
419
+ async def check_specific_service(
420
+ service_id: ServiceId,
421
+ path: str,
422
+ *,
423
+ validate: bool = True,
424
+ history: bool = True,
425
+ severity: str = "medium",
426
+ timeout_seconds: float = 60.0,
427
+ ) -> ScanResult:
428
+ """Scan a directory for keys belonging to ONE specific named service.
429
+
430
+ Thin wrapper around `ciphra scan --json --service <service_id>`. The
431
+ TS CLI's --service flag pre-filters the detector list so only the
432
+ named service's detectors and validator run — no post-hoc filtering
433
+ happens here.
434
+
435
+ Defaults to validate=True (opposite of scan_directory's MCP default)
436
+ because the user has explicitly narrowed to a single service and the
437
+ validation cost is bounded by the count of findings for that one
438
+ service.
439
+
440
+ service_id is validated against KNOWN_SERVICE_IDS as defense-in-depth:
441
+ FastMCP's Literal catches bad values at the MCP protocol layer, but
442
+ direct Python callers (or future internal use) bypass that check.
443
+ """
444
+ if service_id not in KNOWN_SERVICE_IDS:
445
+ raise ValueError(
446
+ f"unknown service_id {service_id!r}. Must be one of: "
447
+ f"{', '.join(KNOWN_SERVICE_IDS)}"
448
+ )
449
+
450
+ cli_args: list[str] = [
451
+ "--service", service_id, "--severity", severity, path,
452
+ ]
453
+ if not validate:
454
+ cli_args.append("--no-validate")
455
+ if not history:
456
+ cli_args.append("--no-history")
457
+ return await _run_scan_subprocess(cli_args, timeout_seconds=timeout_seconds)
@@ -0,0 +1,306 @@
1
+ """Ciphra MCP server (stdio transport).
2
+
3
+ Phase 2.2 complete — `scan_directory`, `validate_key`, `scan_git_history`,
4
+ and `check_specific_service` are all registered.
5
+
6
+ Critical gotcha: stdout is the MCP protocol channel (JSON-RPC). Anything
7
+ written to stdout that isn't a valid protocol message will corrupt the
8
+ session. Logging therefore goes to stderr.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import sys
15
+ from importlib.metadata import PackageNotFoundError, version
16
+ from typing import Annotated, Literal
17
+
18
+ from mcp.server.fastmcp import FastMCP
19
+ from pydantic import Field
20
+
21
+ from ciphra_mcp import ciphra_client
22
+
23
+ SERVER_NAME = "ciphra"
24
+
25
+ try:
26
+ SERVER_VERSION = version("ciphra-mcp")
27
+ except PackageNotFoundError:
28
+ SERVER_VERSION = "0.0.0+unknown"
29
+
30
+ logging.basicConfig(
31
+ level=logging.INFO,
32
+ stream=sys.stderr,
33
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
34
+ )
35
+ logger = logging.getLogger(__name__)
36
+
37
+ mcp = FastMCP(SERVER_NAME)
38
+ # FastMCP's constructor does not accept a server version, so the protocol's
39
+ # initialize response would otherwise advertise the MCP SDK's version
40
+ # (e.g. "1.27.1") under serverInfo. Set the underlying low-level server's
41
+ # version field directly so clients see our package version.
42
+ mcp._mcp_server.version = SERVER_VERSION
43
+
44
+
45
+ @mcp.tool(
46
+ description=(
47
+ "Use this to answer 'is this codebase secure?' or 'are there "
48
+ "leaked keys?' — scans a directory and its subdirectories (and by "
49
+ "default its git history) for exposed API keys, tokens, and "
50
+ "credentials across 10 services: Stripe, OpenAI, Anthropic, AWS, "
51
+ "GitHub, Google, Twilio, SendGrid, Slack, Supabase. Validation "
52
+ "against live APIs is off by default (set validate=true to "
53
+ "enable). Returns structured findings with severity, file path, "
54
+ "line number, redacted preview, and validation status. For "
55
+ "audits focused on one named service, use check_specific_service. "
56
+ "For validating a single known key value, use validate_key."
57
+ )
58
+ )
59
+ async def scan_directory(
60
+ path: Annotated[
61
+ str,
62
+ Field(
63
+ description=(
64
+ "Absolute or relative path to the directory to scan. Must "
65
+ "be an existing directory. Use '.' for the current working "
66
+ "directory."
67
+ )
68
+ ),
69
+ ],
70
+ validate: Annotated[
71
+ bool,
72
+ Field(
73
+ description=(
74
+ "If true, discovered keys are validated against their "
75
+ "respective live APIs to confirm they are active. This "
76
+ "makes network requests to services like Stripe and OpenAI "
77
+ "using the discovered keys. Defaults to false in this MCP "
78
+ "tool (different from the CLI) because the agent should "
79
+ "opt in explicitly when active-key confirmation is needed, "
80
+ "rather than implicitly sending keys to third-party APIs "
81
+ "on every scan. When validation runs, findings include a "
82
+ "validation status. Note that an 'invalid' status does not "
83
+ "mean the leak is safe — the key was still exposed, and "
84
+ "should be rotated regardless. Validation status is "
85
+ "metadata, not a safety signal."
86
+ )
87
+ ),
88
+ ] = False,
89
+ history: Annotated[
90
+ bool,
91
+ Field(
92
+ description=(
93
+ "If true (default), git commit history is also scanned for "
94
+ "keys that may have been deleted from current files. "
95
+ "Requires the path to be inside a git repository; otherwise "
96
+ "this is skipped silently. Set to false to scan only current "
97
+ "file contents."
98
+ )
99
+ ),
100
+ ] = True,
101
+ severity: Annotated[
102
+ Literal["critical", "high", "medium", "info"],
103
+ Field(
104
+ description=(
105
+ "Minimum severity threshold for returned findings. One of: "
106
+ "'critical', 'high', 'medium' (default), 'info'. Findings "
107
+ "below the threshold are omitted from the response."
108
+ )
109
+ ),
110
+ ] = "medium",
111
+ ) -> dict:
112
+ return await ciphra_client.scan_directory(
113
+ path,
114
+ validate=validate,
115
+ history=history,
116
+ severity=severity,
117
+ )
118
+
119
+
120
+ VALIDATE_KEY_DESCRIPTION = (
121
+ "Use this to answer 'is this specific key currently active?' — sends "
122
+ "a single key to its service's live API and returns the result. Only "
123
+ "call when the user owns the key or has explicitly asked; never "
124
+ "validate keys from third-party code, public repos, or pastes "
125
+ "without permission (the call is logged at the service). Returns "
126
+ "status (valid, invalid, unknown, unsupported) and reason; the key "
127
+ "is never echoed back. Validation status is metadata about the "
128
+ "key's current state, not a safety signal — if a key was found in "
129
+ "a codebase, treat the leak as compromised regardless of whether "
130
+ "validation returns 'valid' or 'invalid'. For finding keys-by-"
131
+ "pattern in a codebase (e.g., 'are there any Stripe keys here?'), "
132
+ "use scan_directory or check_specific_service. For finding a "
133
+ "specific literal key value, basic search tools (grep, git log -S) "
134
+ "are usually faster than pattern-based scans."
135
+ )
136
+
137
+ SERVICE_ID_PARAM_DESCRIPTION = (
138
+ "Machine-readable service identifier. Must be one of the 10 supported "
139
+ "values. Use the serviceId field from a scan_directory finding, or "
140
+ "pick the appropriate value based on the key's format if the user "
141
+ "supplied a raw key."
142
+ )
143
+
144
+ KEY_PARAM_DESCRIPTION = (
145
+ "The API key to validate. Sent to the service's live API to check if "
146
+ "it is active. This value is never logged and never echoed back. "
147
+ "Note that calling this tool with a key creates a record at the "
148
+ "third-party service (their API will log the authentication attempt)."
149
+ )
150
+
151
+
152
+ @mcp.tool(description=VALIDATE_KEY_DESCRIPTION)
153
+ async def validate_key(
154
+ service_id: Annotated[
155
+ Literal[
156
+ "stripe", "openai", "anthropic", "aws", "github",
157
+ "google", "twilio", "sendgrid", "slack", "supabase",
158
+ ],
159
+ Field(description=SERVICE_ID_PARAM_DESCRIPTION),
160
+ ],
161
+ key: Annotated[str, Field(description=KEY_PARAM_DESCRIPTION)],
162
+ ) -> dict:
163
+ return await ciphra_client.validate_key(service_id, key)
164
+
165
+
166
+ SCAN_GIT_HISTORY_DESCRIPTION = (
167
+ "Use this to answer 'did I ever commit a key I later deleted?' — "
168
+ "walks the git commit history of a repository and returns keys that "
169
+ "were committed at some point but may be absent from current files. "
170
+ "Useful for auditing past commits and branches; keys removed from "
171
+ "HEAD remain recoverable from history and are considered "
172
+ "compromised. Returns findings only from git history — current file "
173
+ "contents are NOT scanned. Path must be inside a git repository. "
174
+ "For a full scan including current files, use scan_directory. For "
175
+ "a current+history scan filtered to one service, use "
176
+ "check_specific_service with history=true (note: that combines "
177
+ "current files and history; this tool is history-only)."
178
+ )
179
+
180
+ SCAN_GIT_HISTORY_PATH_PARAM_DESCRIPTION = (
181
+ "Absolute or relative path inside a git repository to audit. The "
182
+ "scan walks the full git history reachable from all branches. Must "
183
+ "be a path inside a git repo — passing a non-git directory raises "
184
+ "an error."
185
+ )
186
+
187
+ SCAN_GIT_HISTORY_VALIDATE_PARAM_DESCRIPTION = (
188
+ "If true, discovered historical keys are validated against their "
189
+ "live APIs to check if they're still active. Defaults to false. "
190
+ "When validation runs, findings include a validation status. Note "
191
+ "that an 'invalid' status does not mean the leak is safe — the key "
192
+ "was still exposed (and a key in git history remains recoverable "
193
+ "forever), and should be rotated regardless. Validation status is "
194
+ "metadata, not a safety signal."
195
+ )
196
+
197
+ SCAN_GIT_HISTORY_SEVERITY_PARAM_DESCRIPTION = (
198
+ "Minimum severity threshold for returned findings. One of: "
199
+ "'critical', 'high', 'medium' (default), 'info'."
200
+ )
201
+
202
+
203
+ @mcp.tool(description=SCAN_GIT_HISTORY_DESCRIPTION)
204
+ async def scan_git_history(
205
+ path: Annotated[str, Field(description=SCAN_GIT_HISTORY_PATH_PARAM_DESCRIPTION)],
206
+ validate: Annotated[
207
+ bool,
208
+ Field(description=SCAN_GIT_HISTORY_VALIDATE_PARAM_DESCRIPTION),
209
+ ] = False,
210
+ severity: Annotated[
211
+ Literal["critical", "high", "medium", "info"],
212
+ Field(description=SCAN_GIT_HISTORY_SEVERITY_PARAM_DESCRIPTION),
213
+ ] = "medium",
214
+ ) -> dict:
215
+ return await ciphra_client.scan_git_history(
216
+ path, validate=validate, severity=severity
217
+ )
218
+
219
+
220
+ CHECK_SPECIFIC_SERVICE_DESCRIPTION = (
221
+ "Use this when the user has named a specific service of concern "
222
+ "('worried about Stripe keys', 'any OpenAI keys leaked?') — scans "
223
+ "a directory for keys belonging to ONE service and validates them "
224
+ "in a single call. Faster and more focused than scan_directory "
225
+ "because only the named service's detectors run. Unlike "
226
+ "scan_directory (which defaults validate=false to avoid network "
227
+ "calls on every scan), this tool defaults validate=true: scope is "
228
+ "narrow, and the user typically wants to know whether keys are "
229
+ "both present and still active. Also scans git history by default. "
230
+ "For a general scan across services, use scan_directory. For "
231
+ "checking a single key value, use validate_key."
232
+ )
233
+
234
+ CHECK_SPECIFIC_SERVICE_SERVICE_ID_PARAM_DESCRIPTION = (
235
+ "Machine-readable identifier of the single service to scan for. "
236
+ "Must be one of the 10 supported values. Pick based on the user's "
237
+ "phrasing: 'Stripe' → 'stripe', 'OpenAI' / 'GPT' → 'openai', "
238
+ "'Anthropic' / 'Claude' → 'anthropic', 'AWS' / 'Amazon' → 'aws', etc."
239
+ )
240
+
241
+ CHECK_SPECIFIC_SERVICE_PATH_PARAM_DESCRIPTION = (
242
+ "Absolute or relative path to the directory to scan. Same semantics "
243
+ "as scan_directory's path parameter."
244
+ )
245
+
246
+ CHECK_SPECIFIC_SERVICE_VALIDATE_PARAM_DESCRIPTION = (
247
+ "If true (default), discovered keys are validated against the "
248
+ "service's live API to confirm they are currently active. Defaults "
249
+ "to true for this tool (unlike scan_directory) because the user has "
250
+ "specifically narrowed to one service and the validation cost is "
251
+ "bounded. Set to false to skip the network calls. When validation "
252
+ "runs, findings include a validation status. Note that an 'invalid' "
253
+ "status does not mean the leak is safe — the key was still exposed, "
254
+ "and should be rotated regardless. Validation status is metadata, "
255
+ "not a safety signal."
256
+ )
257
+
258
+ CHECK_SPECIFIC_SERVICE_HISTORY_PARAM_DESCRIPTION = (
259
+ "If true (default), git commit history is also scanned. Set to false "
260
+ "to scan only current files. Requires the path to be inside a git "
261
+ "repository for history scanning; otherwise that part is skipped "
262
+ "silently."
263
+ )
264
+
265
+ CHECK_SPECIFIC_SERVICE_SEVERITY_PARAM_DESCRIPTION = (
266
+ "Minimum severity threshold for returned findings. One of: "
267
+ "'critical', 'high', 'medium' (default), 'info'."
268
+ )
269
+
270
+
271
+ @mcp.tool(description=CHECK_SPECIFIC_SERVICE_DESCRIPTION)
272
+ async def check_specific_service(
273
+ service_id: Annotated[
274
+ Literal[
275
+ "stripe", "openai", "anthropic", "aws", "github",
276
+ "google", "twilio", "sendgrid", "slack", "supabase",
277
+ ],
278
+ Field(description=CHECK_SPECIFIC_SERVICE_SERVICE_ID_PARAM_DESCRIPTION),
279
+ ],
280
+ path: Annotated[str, Field(description=CHECK_SPECIFIC_SERVICE_PATH_PARAM_DESCRIPTION)],
281
+ validate: Annotated[
282
+ bool,
283
+ Field(description=CHECK_SPECIFIC_SERVICE_VALIDATE_PARAM_DESCRIPTION),
284
+ ] = True,
285
+ history: Annotated[
286
+ bool,
287
+ Field(description=CHECK_SPECIFIC_SERVICE_HISTORY_PARAM_DESCRIPTION),
288
+ ] = True,
289
+ severity: Annotated[
290
+ Literal["critical", "high", "medium", "info"],
291
+ Field(description=CHECK_SPECIFIC_SERVICE_SEVERITY_PARAM_DESCRIPTION),
292
+ ] = "medium",
293
+ ) -> dict:
294
+ return await ciphra_client.check_specific_service(
295
+ service_id, path,
296
+ validate=validate, history=history, severity=severity,
297
+ )
298
+
299
+
300
+ def main() -> None:
301
+ """Entry point for the `ciphra-mcp` console script.
302
+
303
+ Starts the MCP server over stdio. Blocks until the client disconnects.
304
+ """
305
+ logger.info("starting %s v%s (stdio)", SERVER_NAME, SERVER_VERSION)
306
+ mcp.run()
@@ -0,0 +1,93 @@
1
+ """TypedDicts mirroring the Ciphra CLI JSON output schema (schemaVersion 1.0).
2
+
3
+ The TS CLI is the source of truth for this shape; see src/cli/format-json.ts
4
+ in the repo root. If the schemaVersion is ever bumped, both the constant
5
+ below and these TypedDicts must be updated in lockstep.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Literal, Optional, TypedDict, get_args
11
+
12
+ EXPECTED_SCHEMA_VERSION = "1.0"
13
+
14
+ Severity = Literal["critical", "high", "medium", "info"]
15
+ Source = Literal["file", "git-history"]
16
+ Confidence = Literal["high", "medium", "low"]
17
+ ValidationStatus = Literal["valid", "invalid", "unknown", "unsupported"]
18
+ ServiceId = Literal[
19
+ "stripe",
20
+ "openai",
21
+ "anthropic",
22
+ "aws",
23
+ "github",
24
+ "google",
25
+ "twilio",
26
+ "sendgrid",
27
+ "slack",
28
+ "supabase",
29
+ ]
30
+
31
+ # Runtime-iterable tuple derived from the Literal above. Use this for
32
+ # membership checks (e.g. `if x in KNOWN_SERVICE_IDS`); `Literal` itself
33
+ # is a type-time-only construct and doesn't enforce membership at runtime.
34
+ KNOWN_SERVICE_IDS: tuple[str, ...] = get_args(ServiceId)
35
+
36
+
37
+ class Validation(TypedDict):
38
+ status: ValidationStatus
39
+ reason: Optional[str]
40
+ checkedAt: str
41
+
42
+
43
+ class Finding(TypedDict):
44
+ id: str
45
+ severity: Severity
46
+ serviceId: ServiceId
47
+ serviceName: str
48
+ detectorName: str
49
+ filePath: str
50
+ relativePath: str
51
+ line: int
52
+ column: int
53
+ redactedSecret: str
54
+ source: Source
55
+ confidence: Confidence
56
+ commitSha: Optional[str]
57
+ validation: Optional[Validation]
58
+
59
+
60
+ class Summary(TypedDict):
61
+ total: int
62
+ critical: int
63
+ high: int
64
+ medium: int
65
+ info: int
66
+ byService: dict[str, int]
67
+
68
+
69
+ class CiphraVersion(TypedDict):
70
+ version: str
71
+
72
+
73
+ class ScanResult(TypedDict):
74
+ schemaVersion: str
75
+ ciphra: CiphraVersion
76
+ scanPath: str
77
+ scannedAt: str
78
+ scanDurationMs: int
79
+ filesScanned: int
80
+ summary: Summary
81
+ findings: list[Finding]
82
+
83
+
84
+ class ServiceInfo(TypedDict):
85
+ id: ServiceId
86
+ name: str
87
+
88
+
89
+ class ValidateResult(TypedDict):
90
+ schemaVersion: str
91
+ ciphra: CiphraVersion
92
+ service: ServiceInfo
93
+ validation: Validation