aevrin 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,45 @@
1
+ # Secrets — never commit
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ env.txt
6
+ infra/defectdojo/secrets.generated.md
7
+ *.pem
8
+ *.key
9
+
10
+ # Node
11
+ node_modules/
12
+ .next/
13
+ out/
14
+ dist/
15
+ build/
16
+ .turbo/
17
+ npm-debug.log*
18
+ yarn-error.log*
19
+
20
+ # Python
21
+ __pycache__/
22
+ *.pyc
23
+ .venv/
24
+ venv/
25
+ *.egg-info/
26
+ .pytest_cache/
27
+ .mypy_cache/
28
+ .ruff_cache/
29
+ .uv-cache/
30
+
31
+ # OS / editor
32
+ .DS_Store
33
+ .vscode/*
34
+ !.vscode/extensions.json
35
+ *.swp
36
+
37
+ # Playwright
38
+ test-results/
39
+ playwright-report/
40
+ blob-report/
41
+ .playwright-mcp/
42
+
43
+ # Misc
44
+ *.log
45
+ .cache/
@@ -0,0 +1 @@
1
+ 3.11
aevrin-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: aevrin
3
+ Version: 0.1.0
4
+ Summary: Aevrin MCP Security Scanner CLI — scan a GitHub repo, local path, or live MCP server.
5
+ Project-URL: Homepage, https://github.com/aevrin-projects/aevrin-mcp-scanner
6
+ Project-URL: Repository, https://github.com/aevrin-projects/aevrin-mcp-scanner
7
+ Author: Aevrin
8
+ License-Expression: MIT
9
+ Keywords: mcp,model-context-protocol,scanner,security
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: aevrin-scanner-core>=0.1.0
12
+ Requires-Dist: httpx>=0.27
13
+ Requires-Dist: rich>=13.9
14
+ Requires-Dist: typer>=0.15
15
+ Description-Content-Type: text/markdown
16
+
17
+ # aevrin
18
+
19
+ Aevrin MCP Security Scanner CLI. Wraps the same open-source scanner binaries and normalization logic (`aevrin-scanner-core`) that the Aevrin backend uses, run locally against your own machine — no network call required unless you pass `--upload`.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install aevrin
25
+ ```
26
+
27
+ Requires Docker (each scanner runs in its own disposable container — see the main repo README for why).
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ aevrin scan ./my-mcp-server
33
+ aevrin scan github.com/owner/repo
34
+ aevrin scan https://my-live-server.example.com --json
35
+ aevrin scan ./my-mcp-server --fail-on high
36
+ aevrin scan ./my-mcp-server --upload # requires AEVRIN_API_KEY env var
37
+ ```
38
+
39
+ Target type is auto-detected: a `github.com` URL scans the full pipeline (static analysis, secrets, dependencies, tool-description checks); any other `http(s)://` URL is treated as a live MCP server (manifest-level checks only); anything that exists on disk is scanned as a local path (full pipeline, no cloning).
40
+
41
+ ### Flags
42
+
43
+ | Flag | Behavior |
44
+ |---|---|
45
+ | `--json` | Machine-readable JSON on stdout instead of a formatted table. |
46
+ | `--upload` | Pushes the result to your Aevrin account. Requires `AEVRIN_API_KEY`, set from your account's API keys settings page — never required for a local-only scan. |
47
+ | `--fail-on <severity>` | Minimum severity that causes a non-zero exit code. One of `critical`, `high`, `medium`, `low`, `info`. Defaults to `high` (both `critical` and `high` findings fail the build). |
48
+
49
+ ### Exit codes
50
+
51
+ | Code | Meaning |
52
+ |---|---|
53
+ | `0` | Clean — no findings at or above the `--fail-on` threshold. |
54
+ | `1` | Findings at or above the `--fail-on` threshold were found. |
55
+ | `2` | Misuse — bad arguments, a target that couldn't be resolved, every scan stage failed to run, or `--upload` failed. |
56
+
57
+ Results go to stdout; stage progress and diagnostics go to stderr — safe to pipe `--json` output without stage-progress noise mixed in.
58
+
59
+ ### Example output
60
+
61
+ ```
62
+ [✓] static analysis
63
+ [✓] secrets
64
+ [✓] dependencies
65
+ [✓] tool description check
66
+ [✓] aggregating
67
+
68
+ Target: ./my-mcp-server
69
+ Score: 62/100 Significant risk — do not deploy as-is
70
+
71
+ ┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
72
+ ┃ Severity ┃ Title ┃ OWASP category ┃ Tool ┃
73
+ ┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
74
+ │ CRITICAL │ Hardcoded secret │ MCP01: Token Mismanagement │ trivy │
75
+ │ HIGH │ subprocess shell true│ MCP05: Command Injection, ... │ semgrep │
76
+ └──────────┴──────────────────────┴──────────────────────────────────────┴─────────┘
77
+ ```
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ uv sync
83
+ uv run pytest tests -v
84
+ uv run ruff check .
85
+ uv run mypy src
86
+ ```
aevrin-0.1.0/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # aevrin
2
+
3
+ Aevrin MCP Security Scanner CLI. Wraps the same open-source scanner binaries and normalization logic (`aevrin-scanner-core`) that the Aevrin backend uses, run locally against your own machine — no network call required unless you pass `--upload`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install aevrin
9
+ ```
10
+
11
+ Requires Docker (each scanner runs in its own disposable container — see the main repo README for why).
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ aevrin scan ./my-mcp-server
17
+ aevrin scan github.com/owner/repo
18
+ aevrin scan https://my-live-server.example.com --json
19
+ aevrin scan ./my-mcp-server --fail-on high
20
+ aevrin scan ./my-mcp-server --upload # requires AEVRIN_API_KEY env var
21
+ ```
22
+
23
+ Target type is auto-detected: a `github.com` URL scans the full pipeline (static analysis, secrets, dependencies, tool-description checks); any other `http(s)://` URL is treated as a live MCP server (manifest-level checks only); anything that exists on disk is scanned as a local path (full pipeline, no cloning).
24
+
25
+ ### Flags
26
+
27
+ | Flag | Behavior |
28
+ |---|---|
29
+ | `--json` | Machine-readable JSON on stdout instead of a formatted table. |
30
+ | `--upload` | Pushes the result to your Aevrin account. Requires `AEVRIN_API_KEY`, set from your account's API keys settings page — never required for a local-only scan. |
31
+ | `--fail-on <severity>` | Minimum severity that causes a non-zero exit code. One of `critical`, `high`, `medium`, `low`, `info`. Defaults to `high` (both `critical` and `high` findings fail the build). |
32
+
33
+ ### Exit codes
34
+
35
+ | Code | Meaning |
36
+ |---|---|
37
+ | `0` | Clean — no findings at or above the `--fail-on` threshold. |
38
+ | `1` | Findings at or above the `--fail-on` threshold were found. |
39
+ | `2` | Misuse — bad arguments, a target that couldn't be resolved, every scan stage failed to run, or `--upload` failed. |
40
+
41
+ Results go to stdout; stage progress and diagnostics go to stderr — safe to pipe `--json` output without stage-progress noise mixed in.
42
+
43
+ ### Example output
44
+
45
+ ```
46
+ [✓] static analysis
47
+ [✓] secrets
48
+ [✓] dependencies
49
+ [✓] tool description check
50
+ [✓] aggregating
51
+
52
+ Target: ./my-mcp-server
53
+ Score: 62/100 Significant risk — do not deploy as-is
54
+
55
+ ┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
56
+ ┃ Severity ┃ Title ┃ OWASP category ┃ Tool ┃
57
+ ┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
58
+ │ CRITICAL │ Hardcoded secret │ MCP01: Token Mismanagement │ trivy │
59
+ │ HIGH │ subprocess shell true│ MCP05: Command Injection, ... │ semgrep │
60
+ └──────────┴──────────────────────┴──────────────────────────────────────┴─────────┘
61
+ ```
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ uv sync
67
+ uv run pytest tests -v
68
+ uv run ruff check .
69
+ uv run mypy src
70
+ ```
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "aevrin"
3
+ version = "0.1.0"
4
+ description = "Aevrin MCP Security Scanner CLI — scan a GitHub repo, local path, or live MCP server."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ authors = [{ name = "Aevrin" }]
9
+ keywords = ["mcp", "security", "scanner", "model-context-protocol"]
10
+ dependencies = [
11
+ "aevrin-scanner-core>=0.1.0",
12
+ "typer>=0.15",
13
+ "rich>=13.9",
14
+ "httpx>=0.27",
15
+ ]
16
+
17
+ [project.scripts]
18
+ aevrin = "aevrin_cli.main:app"
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/aevrin-projects/aevrin-mcp-scanner"
22
+ Repository = "https://github.com/aevrin-projects/aevrin-mcp-scanner"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "pytest>=8.3",
27
+ "ruff>=0.7",
28
+ "mypy>=1.13",
29
+ ]
30
+
31
+ [tool.uv.sources]
32
+ aevrin-scanner-core = { path = "../scanner-core", editable = true }
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/aevrin_cli"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py311"
44
+
45
+ [tool.mypy]
46
+ python_version = "3.11"
47
+ strict = true
File without changes
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Annotated
5
+ from uuid import uuid4
6
+
7
+ import typer
8
+ from aevrin_scanner_core import Finding, ScanStage, Severity, StageStatus
9
+ from aevrin_scanner_core.pipeline import PipelineConfig, run_pipeline
10
+
11
+ from . import output
12
+ from .target_detection import TargetDetectionError, detect_target
13
+ from .upload import UploadError, upload_scan
14
+
15
+ app = typer.Typer(
16
+ name="aevrin",
17
+ help="Scan MCP servers for vulnerabilities using established open-source security tools.",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+ _SEVERITY_RANK: dict[Severity, int] = {
22
+ Severity.INFO: 0,
23
+ Severity.LOW: 1,
24
+ Severity.MEDIUM: 2,
25
+ Severity.HIGH: 3,
26
+ Severity.CRITICAL: 4,
27
+ }
28
+
29
+
30
+ @app.command()
31
+ def scan(
32
+ target: Annotated[str, typer.Argument(help="GitHub URL, local path, or live MCP server URL.")],
33
+ json_output: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output.")] = False,
34
+ upload: Annotated[
35
+ bool, typer.Option("--upload", help="Upload results to your Aevrin account (requires AEVRIN_API_KEY).")
36
+ ] = False,
37
+ fail_on: Annotated[
38
+ str, typer.Option("--fail-on", help="Minimum severity that causes a non-zero exit code.")
39
+ ] = "high",
40
+ ) -> None:
41
+ """Run the full Aevrin scan pipeline against TARGET."""
42
+ try:
43
+ fail_on_severity = Severity(fail_on.lower())
44
+ except ValueError:
45
+ output.print_error(
46
+ f"Invalid --fail-on value '{fail_on}'. Expected one of: "
47
+ f"{', '.join(s.value for s in Severity)}."
48
+ )
49
+ raise typer.Exit(code=2) from None
50
+
51
+ try:
52
+ target_type, normalized_target = detect_target(target)
53
+ except TargetDetectionError as exc:
54
+ output.print_error(str(exc))
55
+ raise typer.Exit(code=2) from None
56
+
57
+ config = PipelineConfig(github_token=os.environ.get("GITHUB_TOKEN"))
58
+
59
+ def on_stage(stage: ScanStage) -> None:
60
+ if not json_output:
61
+ output.print_stage_update(stage.name.value, stage.status.value, stage.error)
62
+
63
+ def on_findings(findings: list[Finding]) -> None:
64
+ pass # collected on the returned Scan object; nothing to stream for the CLI
65
+
66
+ result = run_pipeline(
67
+ target_type=target_type,
68
+ target=normalized_target,
69
+ config=config,
70
+ on_stage=on_stage,
71
+ on_findings=on_findings,
72
+ scan_id=uuid4(),
73
+ )
74
+
75
+ if json_output:
76
+ output.print_json_report(result)
77
+ else:
78
+ output.print_terminal_report(result)
79
+
80
+ if upload:
81
+ try:
82
+ upload_scan(result)
83
+ if not json_output:
84
+ output.stderr_console.print("[green]Uploaded to your Aevrin account.[/green]")
85
+ except UploadError as exc:
86
+ output.print_error(str(exc))
87
+ raise typer.Exit(code=2) from None
88
+
89
+ all_stages_failed = result.stages and all(s.status == StageStatus.FAILED for s in result.stages)
90
+ if all_stages_failed:
91
+ raise typer.Exit(code=2)
92
+
93
+ worst = max(
94
+ (f.severity for f in result.findings if not f.not_tested),
95
+ key=lambda s: _SEVERITY_RANK[s],
96
+ default=None,
97
+ )
98
+ if worst is not None and _SEVERITY_RANK[worst] >= _SEVERITY_RANK[fail_on_severity]:
99
+ raise typer.Exit(code=1)
100
+ raise typer.Exit(code=0)
101
+
102
+
103
+ @app.command()
104
+ def version() -> None:
105
+ """Print the installed aevrin CLI version."""
106
+ from importlib.metadata import version as pkg_version
107
+
108
+ typer.echo(pkg_version("aevrin"))
109
+
110
+
111
+ def main() -> None:
112
+ app()
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
@@ -0,0 +1,100 @@
1
+ """Terminal + JSON rendering. Severity colors match the website's dedicated
2
+ severity tokens (critical/high/medium/low get distinct, consistent colors
3
+ used nowhere else), approximated in the 256-color terminal palette.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Any
10
+
11
+ from aevrin_scanner_core import Scan, Severity, category_label, verdict
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ stdout_console = Console()
16
+ stderr_console = Console(stderr=True)
17
+
18
+ _SEVERITY_STYLE: dict[Severity, str] = {
19
+ Severity.CRITICAL: "bold red",
20
+ Severity.HIGH: "bold dark_orange",
21
+ Severity.MEDIUM: "bold yellow",
22
+ Severity.LOW: "bold blue",
23
+ Severity.INFO: "dim",
24
+ }
25
+
26
+
27
+ def print_stage_update(name: str, status: str, error: str | None = None) -> None:
28
+ icon = {"running": "…", "done": "✓", "failed": "✗", "skipped": "–"}.get(status, "?")
29
+ line = f"[dim]\\[{icon}][/dim] {name.replace('_', ' ')}"
30
+ if error:
31
+ line += f" [red]({error})[/red]"
32
+ stderr_console.print(line)
33
+
34
+
35
+ def print_terminal_report(scan: Scan) -> None:
36
+ stdout_console.print()
37
+ stdout_console.print(f"[bold]Target:[/bold] {scan.target}")
38
+ score = scan.score if scan.score is not None else 0
39
+ score_style = "bold green" if score >= 90 else "bold yellow" if score >= 40 else "bold red"
40
+ stdout_console.print(f"[bold]Score:[/bold] [{score_style}]{score}/100[/{score_style}] {verdict(score)}")
41
+ stdout_console.print()
42
+
43
+ real_findings = [f for f in scan.findings if not f.not_tested]
44
+ not_tested = [f for f in scan.findings if f.not_tested]
45
+
46
+ if not real_findings:
47
+ stdout_console.print("[green]No findings — clean scan.[/green]")
48
+ else:
49
+ table = Table(show_lines=False)
50
+ table.add_column("Severity")
51
+ table.add_column("Title")
52
+ table.add_column("OWASP category")
53
+ table.add_column("Tool")
54
+ for f in sorted(real_findings, key=lambda f: list(Severity).index(f.severity)):
55
+ style = _SEVERITY_STYLE[f.severity]
56
+ table.add_row(
57
+ f"[{style}]{f.severity.value.upper()}[/{style}]",
58
+ f.title,
59
+ category_label(f.owasp_category),
60
+ f.tool.value,
61
+ )
62
+ stdout_console.print(table)
63
+
64
+ for f in not_tested:
65
+ stdout_console.print()
66
+ stdout_console.print(f"[dim]Note: {f.description}[/dim]")
67
+
68
+
69
+ def print_json_report(scan: Scan) -> None:
70
+ payload: dict[str, Any] = {
71
+ "target": scan.target,
72
+ "target_type": scan.target_type.value,
73
+ "status": scan.status.value,
74
+ "score": scan.score,
75
+ "verdict": verdict(scan.score) if scan.score is not None else None,
76
+ "findings": [
77
+ {
78
+ "id": str(f.id),
79
+ "tool": f.tool.value,
80
+ "owasp_category": f.owasp_category.value,
81
+ "owasp_category_label": category_label(f.owasp_category),
82
+ "severity": f.severity.value,
83
+ "title": f.title,
84
+ "description": f.description,
85
+ "file_path": f.location.file_path,
86
+ "line_start": f.location.line_start,
87
+ "line_end": f.location.line_end,
88
+ "manifest_field": f.location.manifest_field,
89
+ "remediation": f.remediation,
90
+ "verified": f.verified,
91
+ "not_tested": f.not_tested,
92
+ }
93
+ for f in scan.findings
94
+ ],
95
+ }
96
+ print(json.dumps(payload, indent=2))
97
+
98
+
99
+ def print_error(message: str) -> None:
100
+ stderr_console.print(f"[bold red]Error:[/bold red] {message}")
@@ -0,0 +1,46 @@
1
+ """Auto-detects whether a CLI target is a GitHub URL, a local path, or a
2
+ live MCP server URL — the three modes Section 7 of the spec lists for
3
+ `aevrin scan <target>`. Website Screen 1's fourth mode (paste config) is a
4
+ browser-only affordance, not something the CLI needs to detect from a
5
+ single string argument.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from aevrin_scanner_core import TargetType
13
+
14
+
15
+ class TargetDetectionError(Exception):
16
+ pass
17
+
18
+
19
+ def detect_target(raw: str) -> tuple[TargetType, str]:
20
+ """Returns (target_type, normalized_target)."""
21
+ target = raw.strip()
22
+ if not target:
23
+ raise TargetDetectionError("Target must not be empty.")
24
+
25
+ if target.startswith(("http://", "https://")):
26
+ host = target.split("://", 1)[1].split("/", 1)[0]
27
+ if host in ("github.com", "www.github.com"):
28
+ return TargetType.GITHUB_REPO, _normalize_github_url(target)
29
+ return TargetType.LIVE_MCP_SERVER, target
30
+
31
+ if target.startswith("github.com/"):
32
+ return TargetType.GITHUB_REPO, _normalize_github_url(f"https://{target}")
33
+
34
+ if os.path.exists(target):
35
+ return TargetType.LOCAL_PATH, os.path.abspath(target)
36
+
37
+ raise TargetDetectionError(
38
+ f"Could not determine target type for '{raw}'. Expected a GitHub URL "
39
+ "(https://github.com/owner/repo), a live server URL (https://...), "
40
+ "or a local path that exists on disk."
41
+ )
42
+
43
+
44
+ def _normalize_github_url(url: str) -> str:
45
+ normalized = url.replace("://www.github.com", "://github.com")
46
+ return normalized.removesuffix("/").removesuffix(".git")
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import httpx
6
+ from aevrin_scanner_core import Scan
7
+
8
+ DEFAULT_API_URL = "https://api.aevrin.dev"
9
+
10
+
11
+ class UploadError(Exception):
12
+ pass
13
+
14
+
15
+ def upload_scan(scan: Scan) -> None:
16
+ api_key = os.environ.get("AEVRIN_API_KEY")
17
+ if not api_key:
18
+ raise UploadError(
19
+ "--upload requires AEVRIN_API_KEY. Get one from your account settings "
20
+ "(Aevrin dashboard → API keys) and set it in your environment."
21
+ )
22
+ api_url = os.environ.get("AEVRIN_API_URL", DEFAULT_API_URL)
23
+
24
+ body = {
25
+ "target_type": scan.target_type.value,
26
+ "target": scan.target,
27
+ "score": scan.score if scan.score is not None else 0,
28
+ "findings": [
29
+ {
30
+ "id": str(f.id),
31
+ "tool": f.tool.value,
32
+ "owasp_category": f.owasp_category.value,
33
+ "severity": f.severity.value,
34
+ "title": f.title,
35
+ "description": f.description,
36
+ "file_path": f.location.file_path,
37
+ "line_start": f.location.line_start,
38
+ "line_end": f.location.line_end,
39
+ "manifest_field": f.location.manifest_field,
40
+ "tool_name_in_manifest": f.location.tool_name_in_manifest,
41
+ "remediation": f.remediation,
42
+ "verified": f.verified,
43
+ "not_tested": f.not_tested,
44
+ "raw": None, # don't upload raw tool output — keep the payload small and predictable
45
+ }
46
+ for f in scan.findings
47
+ ],
48
+ }
49
+
50
+ try:
51
+ resp = httpx.post(
52
+ f"{api_url}/cli/upload",
53
+ json=body,
54
+ headers={"X-API-Key": api_key},
55
+ timeout=30,
56
+ )
57
+ except httpx.HTTPError as exc:
58
+ raise UploadError(f"Could not reach {api_url}: {exc}") from exc
59
+
60
+ if resp.status_code >= 400:
61
+ detail = resp.text
62
+ try:
63
+ detail = resp.json().get("detail", detail)
64
+ except ValueError:
65
+ pass
66
+ raise UploadError(f"Upload failed ({resp.status_code}): {detail}")
@@ -0,0 +1,49 @@
1
+ import pytest
2
+ from aevrin_scanner_core import TargetType
3
+
4
+ from aevrin_cli.target_detection import TargetDetectionError, detect_target
5
+
6
+
7
+ def test_detects_github_https_url():
8
+ target_type, normalized = detect_target("https://github.com/owner/repo")
9
+ assert target_type == TargetType.GITHUB_REPO
10
+ assert normalized == "https://github.com/owner/repo"
11
+
12
+
13
+ def test_detects_github_shorthand():
14
+ target_type, normalized = detect_target("github.com/owner/repo")
15
+ assert target_type == TargetType.GITHUB_REPO
16
+ assert normalized == "https://github.com/owner/repo"
17
+
18
+
19
+ def test_normalizes_trailing_slash_and_git_suffix():
20
+ _, normalized = detect_target("https://github.com/owner/repo.git/")
21
+ assert normalized == "https://github.com/owner/repo"
22
+
23
+
24
+ def test_normalizes_www_github():
25
+ target_type, normalized = detect_target("https://www.github.com/owner/repo")
26
+ assert target_type == TargetType.GITHUB_REPO
27
+ assert normalized == "https://github.com/owner/repo"
28
+
29
+
30
+ def test_detects_live_server_url():
31
+ target_type, normalized = detect_target("https://my-mcp-server.example.com")
32
+ assert target_type == TargetType.LIVE_MCP_SERVER
33
+ assert normalized == "https://my-mcp-server.example.com"
34
+
35
+
36
+ def test_detects_local_path(tmp_path):
37
+ target_type, normalized = detect_target(str(tmp_path))
38
+ assert target_type == TargetType.LOCAL_PATH
39
+ assert normalized == str(tmp_path)
40
+
41
+
42
+ def test_rejects_empty_target():
43
+ with pytest.raises(TargetDetectionError):
44
+ detect_target(" ")
45
+
46
+
47
+ def test_rejects_nonexistent_path_and_non_url():
48
+ with pytest.raises(TargetDetectionError):
49
+ detect_target("./definitely-does-not-exist-xyz")