tessen-cli 0.1.0__py3-none-any.whl
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.
- tessen/__init__.py +1 -0
- tessen/cli.py +206 -0
- tessen/models.py +57 -0
- tessen/output/__init__.py +0 -0
- tessen/output/human.py +98 -0
- tessen/output/json_output.py +68 -0
- tessen/parsers/__init__.py +0 -0
- tessen/parsers/auto.py +77 -0
- tessen/parsers/claude_code.py +42 -0
- tessen/parsers/claude_desktop.py +22 -0
- tessen/parsers/common.py +66 -0
- tessen/parsers/cursor.py +38 -0
- tessen/rules/__init__.py +0 -0
- tessen/rules/base.py +29 -0
- tessen/rules/builtin/__init__.py +30 -0
- tessen/rules/builtin/auto_approve.py +98 -0
- tessen/rules/builtin/dangerous_command.py +89 -0
- tessen/rules/builtin/hook_injection.py +153 -0
- tessen/rules/builtin/http_no_auth.py +99 -0
- tessen/rules/builtin/known_vulnerable.py +159 -0
- tessen/rules/builtin/secrets_in_env.py +112 -0
- tessen/rules/builtin/suspicious_args.py +91 -0
- tessen/rules/engine.py +36 -0
- tessen/rules/models.py +52 -0
- tessen_cli-0.1.0.dist-info/METADATA +301 -0
- tessen_cli-0.1.0.dist-info/RECORD +28 -0
- tessen_cli-0.1.0.dist-info/WHEEL +4 -0
- tessen_cli-0.1.0.dist-info/entry_points.txt +3 -0
tessen/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
tessen/cli.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Tessen CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from tessen import __version__
|
|
12
|
+
from tessen.output.human import render_findings, render_scan_summary
|
|
13
|
+
from tessen.output.json_output import render_json
|
|
14
|
+
from tessen.parsers.auto import auto_detect_and_parse
|
|
15
|
+
from tessen.parsers.common import ParseError
|
|
16
|
+
from tessen.parsers.claude_desktop import parse_claude_desktop
|
|
17
|
+
from tessen.parsers.claude_code import parse_claude_code
|
|
18
|
+
from tessen.parsers.cursor import parse_cursor
|
|
19
|
+
from tessen.rules.builtin import BUILTIN_RULES
|
|
20
|
+
from tessen.rules.engine import RuleEngine
|
|
21
|
+
from tessen.rules.models import Finding, Severity
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="tessen",
|
|
25
|
+
help="Security scanner for AI agent configurations.",
|
|
26
|
+
add_completion=False,
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
console = Console()
|
|
30
|
+
error_console = Console(stderr=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
CLIENT_LABELS = {
|
|
34
|
+
"claude_desktop": "Claude Desktop",
|
|
35
|
+
"claude_code": "Claude Code",
|
|
36
|
+
"cursor": "Cursor",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_parser(path: Path, client: str | None):
|
|
41
|
+
if client is not None:
|
|
42
|
+
parsers = {
|
|
43
|
+
"claude_desktop": parse_claude_desktop,
|
|
44
|
+
"claude_code": parse_claude_code,
|
|
45
|
+
"cursor": parse_cursor,
|
|
46
|
+
}
|
|
47
|
+
if client not in parsers:
|
|
48
|
+
raise ParseError(
|
|
49
|
+
f"Unknown client: {client!r}. Valid options: {', '.join(parsers)}"
|
|
50
|
+
)
|
|
51
|
+
return parsers[client]
|
|
52
|
+
|
|
53
|
+
name = path.name
|
|
54
|
+
if name == ".mcp.json":
|
|
55
|
+
return parse_claude_code
|
|
56
|
+
if name == "mcp.json" and path.parent.name == ".cursor":
|
|
57
|
+
return parse_cursor
|
|
58
|
+
if name == "claude_desktop_config.json":
|
|
59
|
+
return parse_claude_desktop
|
|
60
|
+
return parse_claude_desktop
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def scan(
|
|
65
|
+
path: Path | None = typer.Argument(
|
|
66
|
+
None,
|
|
67
|
+
help="Path to MCP config file. If omitted, auto-detects configs.",
|
|
68
|
+
resolve_path=True,
|
|
69
|
+
),
|
|
70
|
+
client: str | None = typer.Option(
|
|
71
|
+
None,
|
|
72
|
+
"--client",
|
|
73
|
+
"-c",
|
|
74
|
+
help="Force a specific parser: claude_desktop, claude_code, or cursor.",
|
|
75
|
+
),
|
|
76
|
+
output: str = typer.Option(
|
|
77
|
+
"human",
|
|
78
|
+
"--output",
|
|
79
|
+
"-o",
|
|
80
|
+
help="Output format: human (default) or json.",
|
|
81
|
+
),
|
|
82
|
+
fail_on: str = typer.Option(
|
|
83
|
+
"high",
|
|
84
|
+
"--fail-on",
|
|
85
|
+
help="Exit non-zero if any finding at or above this severity. "
|
|
86
|
+
"One of: critical, high, medium, low, never.",
|
|
87
|
+
),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Scan MCP configurations for vulnerabilities."""
|
|
90
|
+
if output not in ("human", "json"):
|
|
91
|
+
error_console.print(
|
|
92
|
+
f"[bold red]error:[/bold red] Unknown output format: {output!r}. "
|
|
93
|
+
f"Use 'human' or 'json'."
|
|
94
|
+
)
|
|
95
|
+
raise typer.Exit(code=2)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
if path is None:
|
|
99
|
+
if output == "human":
|
|
100
|
+
console.print(
|
|
101
|
+
"[bold cyan]tessen[/bold cyan] auto-detecting MCP configs..."
|
|
102
|
+
)
|
|
103
|
+
results = auto_detect_and_parse()
|
|
104
|
+
if not results:
|
|
105
|
+
if output == "human":
|
|
106
|
+
console.print(
|
|
107
|
+
"[yellow]No MCP configs found.[/yellow] "
|
|
108
|
+
"Try passing a path explicitly."
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
print(render_json({}))
|
|
112
|
+
raise typer.Exit(code=0)
|
|
113
|
+
else:
|
|
114
|
+
if output == "human":
|
|
115
|
+
console.print(
|
|
116
|
+
f"[bold cyan]tessen[/bold cyan] scanning [dim]{path}[/dim]"
|
|
117
|
+
)
|
|
118
|
+
parser_fn = _resolve_parser(path, client)
|
|
119
|
+
results = [parser_fn(path)]
|
|
120
|
+
except ParseError as e:
|
|
121
|
+
error_console.print(f"[bold red]error:[/bold red] {e}")
|
|
122
|
+
raise typer.Exit(code=1)
|
|
123
|
+
|
|
124
|
+
engine = RuleEngine(BUILTIN_RULES)
|
|
125
|
+
all_findings: list[Finding] = []
|
|
126
|
+
findings_by_source: dict[str, list[Finding]] = {}
|
|
127
|
+
|
|
128
|
+
for result in results:
|
|
129
|
+
label = CLIENT_LABELS.get(result.client_type, result.client_type)
|
|
130
|
+
source = f"{label} ({result.source_path})"
|
|
131
|
+
findings = engine.run(result)
|
|
132
|
+
findings_by_source[source] = findings
|
|
133
|
+
all_findings.extend(findings)
|
|
134
|
+
|
|
135
|
+
if output == "json":
|
|
136
|
+
print(render_json(findings_by_source))
|
|
137
|
+
else:
|
|
138
|
+
for source, findings in findings_by_source.items():
|
|
139
|
+
render_findings(console, findings, source)
|
|
140
|
+
render_scan_summary(console, all_findings)
|
|
141
|
+
|
|
142
|
+
_exit_with_severity(all_findings, fail_on)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command(name="rules")
|
|
146
|
+
def list_rules() -> None:
|
|
147
|
+
"""List all available detection rules."""
|
|
148
|
+
table = Table(
|
|
149
|
+
title=f"{len(BUILTIN_RULES)} detection rules",
|
|
150
|
+
title_style="bold",
|
|
151
|
+
show_lines=False,
|
|
152
|
+
)
|
|
153
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
154
|
+
table.add_column("Severity", no_wrap=True)
|
|
155
|
+
table.add_column("Description")
|
|
156
|
+
|
|
157
|
+
severity_styles = {
|
|
158
|
+
Severity.CRITICAL: "bold red",
|
|
159
|
+
Severity.HIGH: "bold yellow",
|
|
160
|
+
Severity.MEDIUM: "cyan",
|
|
161
|
+
Severity.LOW: "dim",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for rule in BUILTIN_RULES:
|
|
165
|
+
meta = rule.METADATA
|
|
166
|
+
style = severity_styles.get(meta.severity, "")
|
|
167
|
+
table.add_row(
|
|
168
|
+
meta.id,
|
|
169
|
+
f"[{style}]{meta.severity.value}[/{style}]",
|
|
170
|
+
meta.description[:80] + ("..." if len(meta.description) > 80 else ""),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
console.print(table)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _exit_with_severity(findings: list[Finding], fail_on: str) -> None:
|
|
177
|
+
if fail_on == "never":
|
|
178
|
+
raise typer.Exit(code=0)
|
|
179
|
+
|
|
180
|
+
threshold_map = {
|
|
181
|
+
"critical": Severity.CRITICAL,
|
|
182
|
+
"high": Severity.HIGH,
|
|
183
|
+
"medium": Severity.MEDIUM,
|
|
184
|
+
"low": Severity.LOW,
|
|
185
|
+
}
|
|
186
|
+
if fail_on not in threshold_map:
|
|
187
|
+
error_console.print(
|
|
188
|
+
f"[bold red]error:[/bold red] Unknown --fail-on value: {fail_on!r}. "
|
|
189
|
+
f"Use one of: {', '.join(threshold_map)}, never."
|
|
190
|
+
)
|
|
191
|
+
raise typer.Exit(code=2)
|
|
192
|
+
|
|
193
|
+
threshold = threshold_map[fail_on]
|
|
194
|
+
if any(f.severity.rank >= threshold.rank for f in findings):
|
|
195
|
+
raise typer.Exit(code=1)
|
|
196
|
+
raise typer.Exit(code=0)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command()
|
|
200
|
+
def version() -> None:
|
|
201
|
+
"""Show Tessen version."""
|
|
202
|
+
console.print(f"tessen {__version__}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
app()
|
tessen/models.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Data models for MCP configurations.
|
|
2
|
+
|
|
3
|
+
These models represent the parsed shape of MCP config files.
|
|
4
|
+
Security-relevant fields are annotated with `# SECURITY:` comments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
ClientType = Literal["claude_desktop", "claude_code", "cursor"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MCPServerEntry(BaseModel):
|
|
18
|
+
"""A single MCP server definition within a client config."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(extra="allow")
|
|
21
|
+
|
|
22
|
+
# SECURITY: command itself can be malicious (typosquatted packages).
|
|
23
|
+
command: str
|
|
24
|
+
|
|
25
|
+
# SECURITY: arg injection, attacker-controlled paths, dangerous flags.
|
|
26
|
+
args: list[str] = Field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
# SECURITY: secrets in cleartext (API keys, tokens, passwords).
|
|
29
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
# SECURITY: HTTP transport without auth = remote access risk.
|
|
32
|
+
transport: Literal["stdio", "http", "sse"] | None = None
|
|
33
|
+
|
|
34
|
+
url: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MCPConfig(BaseModel):
|
|
38
|
+
"""Generic MCP configuration shared across Claude Desktop, Claude Code, Cursor.
|
|
39
|
+
|
|
40
|
+
All three clients use the same `mcpServers` shape, so one model fits all.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(extra="allow")
|
|
44
|
+
|
|
45
|
+
mcpServers: dict[str, MCPServerEntry] = Field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ParseResult(BaseModel):
|
|
49
|
+
"""The output of parsing a config file."""
|
|
50
|
+
|
|
51
|
+
source_path: Path
|
|
52
|
+
client_type: ClientType
|
|
53
|
+
config: MCPConfig
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def server_count(self) -> int:
|
|
57
|
+
return len(self.config.mcpServers)
|
|
File without changes
|
tessen/output/human.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Human-readable Rich output for findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from tessen.rules.models import Finding, Severity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SEVERITY_STYLES = {
|
|
13
|
+
Severity.CRITICAL: "bold red",
|
|
14
|
+
Severity.HIGH: "bold yellow",
|
|
15
|
+
Severity.MEDIUM: "cyan",
|
|
16
|
+
Severity.LOW: "dim",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
SEVERITY_ICONS = {
|
|
20
|
+
Severity.CRITICAL: "●",
|
|
21
|
+
Severity.HIGH: "●",
|
|
22
|
+
Severity.MEDIUM: "○",
|
|
23
|
+
Severity.LOW: "○",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_findings(console: Console, findings: list[Finding], source: str) -> None:
|
|
28
|
+
"""Render a list of findings for a single config file."""
|
|
29
|
+
if not findings:
|
|
30
|
+
console.print(f"[green]✓[/green] No findings for [dim]{source}[/dim]\n")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
counts = _count_by_severity(findings)
|
|
34
|
+
header = _severity_summary(counts)
|
|
35
|
+
console.print(f"\n[bold]Findings for[/bold] [dim]{source}[/dim] {header}\n")
|
|
36
|
+
|
|
37
|
+
for i, finding in enumerate(findings, 1):
|
|
38
|
+
_render_finding(console, i, finding)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_scan_summary(console: Console, all_findings: list[Finding]) -> None:
|
|
42
|
+
"""Render a summary across all scanned configs."""
|
|
43
|
+
if not all_findings:
|
|
44
|
+
console.print("\n[bold green]All configs clean — no findings.[/bold green]")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
counts = _count_by_severity(all_findings)
|
|
48
|
+
console.print("\n[bold]Scan summary[/bold]")
|
|
49
|
+
console.print(f" Total findings: {len(all_findings)}")
|
|
50
|
+
for sev in [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW]:
|
|
51
|
+
if counts[sev] > 0:
|
|
52
|
+
style = SEVERITY_STYLES[sev]
|
|
53
|
+
console.print(f" [{style}]{sev.value.title():<9}[/{style}] {counts[sev]}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _render_finding(console: Console, index: int, finding: Finding) -> None:
|
|
57
|
+
style = SEVERITY_STYLES[finding.severity]
|
|
58
|
+
icon = SEVERITY_ICONS[finding.severity]
|
|
59
|
+
|
|
60
|
+
title = Text()
|
|
61
|
+
title.append(f"{icon} ", style=style)
|
|
62
|
+
title.append(f"[{finding.severity.value.upper()}] ", style=style)
|
|
63
|
+
title.append(finding.title, style="bold")
|
|
64
|
+
|
|
65
|
+
body = Text()
|
|
66
|
+
body.append(finding.message + "\n\n")
|
|
67
|
+
body.append("Location: ", style="dim")
|
|
68
|
+
body.append(finding.location + "\n", style="cyan")
|
|
69
|
+
body.append("Rule: ", style="dim")
|
|
70
|
+
body.append(finding.rule_id + "\n", style="magenta")
|
|
71
|
+
if finding.remediation:
|
|
72
|
+
body.append("Remediation: ", style="dim")
|
|
73
|
+
body.append(finding.remediation + "\n", style="green")
|
|
74
|
+
|
|
75
|
+
panel = Panel(
|
|
76
|
+
body,
|
|
77
|
+
title=title,
|
|
78
|
+
title_align="left",
|
|
79
|
+
border_style=style,
|
|
80
|
+
padding=(0, 1),
|
|
81
|
+
)
|
|
82
|
+
console.print(panel)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _count_by_severity(findings: list[Finding]) -> dict[Severity, int]:
|
|
86
|
+
counts = {sev: 0 for sev in Severity}
|
|
87
|
+
for f in findings:
|
|
88
|
+
counts[f.severity] += 1
|
|
89
|
+
return counts
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _severity_summary(counts: dict[Severity, int]) -> str:
|
|
93
|
+
parts = []
|
|
94
|
+
for sev in [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW]:
|
|
95
|
+
if counts[sev] > 0:
|
|
96
|
+
style = SEVERITY_STYLES[sev]
|
|
97
|
+
parts.append(f"[{style}]{counts[sev]} {sev.value}[/{style}]")
|
|
98
|
+
return " · ".join(parts)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Machine-readable JSON output for findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from tessen import __version__
|
|
8
|
+
from tessen.rules.models import Finding
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_json(
|
|
12
|
+
findings_by_source: dict[str, list[Finding]],
|
|
13
|
+
file=None,
|
|
14
|
+
) -> str:
|
|
15
|
+
"""Render all findings as a JSON document.
|
|
16
|
+
|
|
17
|
+
Returns the JSON string and optionally writes it to a file handle.
|
|
18
|
+
"""
|
|
19
|
+
output = {
|
|
20
|
+
"version": __version__,
|
|
21
|
+
"scanner": "tessen",
|
|
22
|
+
"summary": {
|
|
23
|
+
"total": sum(len(f) for f in findings_by_source.values()),
|
|
24
|
+
"by_severity": _count_severities(findings_by_source),
|
|
25
|
+
"configs_scanned": len(findings_by_source),
|
|
26
|
+
},
|
|
27
|
+
"results": [
|
|
28
|
+
{
|
|
29
|
+
"source": source,
|
|
30
|
+
"findings": [_finding_to_dict(f) for f in findings],
|
|
31
|
+
}
|
|
32
|
+
for source, findings in findings_by_source.items()
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
json_str = json.dumps(output, indent=2, default=str)
|
|
37
|
+
|
|
38
|
+
if file is not None:
|
|
39
|
+
file.write(json_str)
|
|
40
|
+
|
|
41
|
+
return json_str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _finding_to_dict(finding: Finding) -> dict:
|
|
45
|
+
return {
|
|
46
|
+
"rule_id": finding.rule_id,
|
|
47
|
+
"severity": finding.severity.value,
|
|
48
|
+
"title": finding.title,
|
|
49
|
+
"message": finding.message,
|
|
50
|
+
"location": finding.location,
|
|
51
|
+
"remediation": finding.remediation,
|
|
52
|
+
"references": finding.references,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _count_severities(
|
|
57
|
+
findings_by_source: dict[str, list[Finding]],
|
|
58
|
+
) -> dict[str, int]:
|
|
59
|
+
counts: dict[str, int] = {
|
|
60
|
+
"critical": 0,
|
|
61
|
+
"high": 0,
|
|
62
|
+
"medium": 0,
|
|
63
|
+
"low": 0,
|
|
64
|
+
}
|
|
65
|
+
for findings in findings_by_source.values():
|
|
66
|
+
for f in findings:
|
|
67
|
+
counts[f.severity.value] += 1
|
|
68
|
+
return counts
|
|
File without changes
|
tessen/parsers/auto.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Auto-detect which MCP client config to scan.
|
|
2
|
+
|
|
3
|
+
Walks up from the current directory looking for project-scoped configs,
|
|
4
|
+
then falls back to user-scoped configs in standard locations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from tessen.parsers.claude_code import find_claude_code_config, parse_claude_code
|
|
13
|
+
from tessen.parsers.claude_desktop import parse_claude_desktop
|
|
14
|
+
from tessen.parsers.cursor import find_cursor_config, parse_cursor
|
|
15
|
+
from tessen.models import ParseResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_claude_desktop_config() -> Path | None:
|
|
19
|
+
"""Find the user-scoped Claude Desktop config based on the OS."""
|
|
20
|
+
home = Path.home()
|
|
21
|
+
if sys.platform == "darwin":
|
|
22
|
+
candidate = (
|
|
23
|
+
home
|
|
24
|
+
/ "Library"
|
|
25
|
+
/ "Application Support"
|
|
26
|
+
/ "Claude"
|
|
27
|
+
/ "claude_desktop_config.json"
|
|
28
|
+
)
|
|
29
|
+
elif sys.platform == "win32":
|
|
30
|
+
import os
|
|
31
|
+
|
|
32
|
+
appdata = os.environ.get("APPDATA")
|
|
33
|
+
if not appdata:
|
|
34
|
+
return None
|
|
35
|
+
candidate = Path(appdata) / "Claude" / "claude_desktop_config.json"
|
|
36
|
+
else: # linux and friends
|
|
37
|
+
candidate = home / ".config" / "Claude" / "claude_desktop_config.json"
|
|
38
|
+
return candidate if candidate.is_file() else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def auto_detect_and_parse(start: Path | None = None) -> list[ParseResult]:
|
|
42
|
+
"""Find all MCP configs reachable from `start` and parse them.
|
|
43
|
+
|
|
44
|
+
Searches in this order:
|
|
45
|
+
1. Project-scoped Claude Code (.mcp.json, walking up from start)
|
|
46
|
+
2. Project-scoped Cursor (.cursor/mcp.json, walking up from start)
|
|
47
|
+
3. User-scoped Claude Desktop (OS-specific location)
|
|
48
|
+
|
|
49
|
+
Returns a list of ParseResults for everything found. Empty list if
|
|
50
|
+
nothing was found. Individual parse errors propagate (we don't silently
|
|
51
|
+
skip malformed configs).
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
start: Directory to begin the project-scoped search from.
|
|
55
|
+
Defaults to current working directory.
|
|
56
|
+
"""
|
|
57
|
+
if start is None:
|
|
58
|
+
start = Path.cwd()
|
|
59
|
+
|
|
60
|
+
results: list[ParseResult] = []
|
|
61
|
+
|
|
62
|
+
claude_code_path = find_claude_code_config(start)
|
|
63
|
+
if claude_code_path is not None:
|
|
64
|
+
results.append(parse_claude_code(claude_code_path))
|
|
65
|
+
|
|
66
|
+
cursor_path = find_cursor_config(start)
|
|
67
|
+
if cursor_path is not None:
|
|
68
|
+
results.append(parse_cursor(cursor_path))
|
|
69
|
+
|
|
70
|
+
desktop_path = find_claude_desktop_config()
|
|
71
|
+
if desktop_path is not None:
|
|
72
|
+
results.append(parse_claude_desktop(desktop_path))
|
|
73
|
+
|
|
74
|
+
return results
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["auto_detect_and_parse", "find_claude_desktop_config"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Parser for Claude Code MCP configurations.
|
|
2
|
+
|
|
3
|
+
Config locations:
|
|
4
|
+
- Project-scoped: <project_root>/.mcp.json
|
|
5
|
+
- User-scoped: ~/.claude.json (contains both user settings and MCP servers)
|
|
6
|
+
|
|
7
|
+
For V1 we focus on .mcp.json (project-scoped) — the most common case
|
|
8
|
+
for security audits.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from tessen.parsers.common import ParseError, parse_mcp_json_file
|
|
16
|
+
from tessen.models import ParseResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
PROJECT_CONFIG_NAME = ".mcp.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_claude_code(path: Path) -> ParseResult:
|
|
23
|
+
"""Parse a Claude Code .mcp.json file."""
|
|
24
|
+
return parse_mcp_json_file(path, client_type="claude_code")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_claude_code_config(start: Path) -> Path | None:
|
|
28
|
+
"""Walk up from `start` looking for a .mcp.json file.
|
|
29
|
+
|
|
30
|
+
Returns the path if found, None otherwise. Used by auto-detection.
|
|
31
|
+
"""
|
|
32
|
+
current = start.resolve()
|
|
33
|
+
while True:
|
|
34
|
+
candidate = current / PROJECT_CONFIG_NAME
|
|
35
|
+
if candidate.is_file():
|
|
36
|
+
return candidate
|
|
37
|
+
if current.parent == current: # reached filesystem root
|
|
38
|
+
return None
|
|
39
|
+
current = current.parent
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["parse_claude_code", "find_claude_code_config", "ParseError"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Parser for Claude Desktop MCP configurations.
|
|
2
|
+
|
|
3
|
+
Config locations:
|
|
4
|
+
- macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
|
|
5
|
+
- Linux: ~/.config/Claude/claude_desktop_config.json
|
|
6
|
+
- Windows: %APPDATA%\\Claude\\claude_desktop_config.json
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from tessen.parsers.common import ParseError, parse_mcp_json_file
|
|
14
|
+
from tessen.models import ParseResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_claude_desktop(path: Path) -> ParseResult:
|
|
18
|
+
"""Parse a Claude Desktop config file."""
|
|
19
|
+
return parse_mcp_json_file(path, client_type="claude_desktop")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["parse_claude_desktop", "ParseError"]
|
tessen/parsers/common.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Shared parsing logic across MCP-format clients.
|
|
2
|
+
|
|
3
|
+
Claude Desktop, Claude Code, and Cursor all use the same JSON shape
|
|
4
|
+
under different filenames. This module holds the actual parsing work.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import get_args
|
|
12
|
+
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from tessen.models import ClientType, MCPConfig, ParseResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ParseError(Exception):
|
|
19
|
+
"""Raised when a config file cannot be parsed."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_mcp_json_file(path: Path, *, client_type: ClientType) -> ParseResult:
|
|
23
|
+
"""Parse a JSON file using the standard MCP config schema.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
path: Path to the config file.
|
|
27
|
+
client_type: Which client the file came from. Affects rules later.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
ParseResult containing the parsed config and metadata.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ParseError: If the file doesn't exist, isn't valid JSON, or doesn't
|
|
34
|
+
match the expected schema.
|
|
35
|
+
"""
|
|
36
|
+
if client_type not in get_args(ClientType):
|
|
37
|
+
raise ValueError(f"Unknown client_type: {client_type!r}")
|
|
38
|
+
|
|
39
|
+
if not path.exists():
|
|
40
|
+
raise ParseError(f"Config file not found: {path}")
|
|
41
|
+
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
raise ParseError(f"Path is not a file: {path}")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
raw_text = path.read_text(encoding="utf-8")
|
|
47
|
+
except OSError as e:
|
|
48
|
+
raise ParseError(f"Could not read {path}: {e}") from e
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
raw_json = json.loads(raw_text)
|
|
52
|
+
except json.JSONDecodeError as e:
|
|
53
|
+
raise ParseError(
|
|
54
|
+
f"Invalid JSON in {path} at line {e.lineno}, col {e.colno}: {e.msg}"
|
|
55
|
+
) from e
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
config = MCPConfig.model_validate(raw_json)
|
|
59
|
+
except ValidationError as e:
|
|
60
|
+
raise ParseError(f"Config schema validation failed for {path}:\n{e}") from e
|
|
61
|
+
|
|
62
|
+
return ParseResult(
|
|
63
|
+
source_path=path,
|
|
64
|
+
client_type=client_type,
|
|
65
|
+
config=config,
|
|
66
|
+
)
|
tessen/parsers/cursor.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Parser for Cursor MCP configurations.
|
|
2
|
+
|
|
3
|
+
Config locations:
|
|
4
|
+
- Project-scoped: <project_root>/.cursor/mcp.json
|
|
5
|
+
- Global: ~/.cursor/mcp.json
|
|
6
|
+
|
|
7
|
+
Cursor uses the same schema as Claude Desktop, just at a different path.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from tessen.parsers.common import ParseError, parse_mcp_json_file
|
|
15
|
+
from tessen.models import ParseResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PROJECT_CONFIG_PATH = Path(".cursor") / "mcp.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_cursor(path: Path) -> ParseResult:
|
|
22
|
+
"""Parse a Cursor MCP config file."""
|
|
23
|
+
return parse_mcp_json_file(path, client_type="cursor")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_cursor_config(start: Path) -> Path | None:
|
|
27
|
+
"""Walk up from `start` looking for a .cursor/mcp.json file."""
|
|
28
|
+
current = start.resolve()
|
|
29
|
+
while True:
|
|
30
|
+
candidate = current / PROJECT_CONFIG_PATH
|
|
31
|
+
if candidate.is_file():
|
|
32
|
+
return candidate
|
|
33
|
+
if current.parent == current:
|
|
34
|
+
return None
|
|
35
|
+
current = current.parent
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["parse_cursor", "find_cursor_config", "ParseError"]
|
tessen/rules/__init__.py
ADDED
|
File without changes
|