syncscore-companion 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
+ *.egg-info/
4
+ .pytest_cache/
5
+ dist/
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: syncscore-companion
3
+ Version: 0.1.0
4
+ Summary: Local companion MCP server for SyncScore audits — bridges filesystem access to the SyncScore remote MCP server
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: fastmcp>=3.4.0
7
+ Description-Content-Type: text/markdown
8
+
9
+ # syncscore-companion
10
+
11
+ Local companion MCP server for [SyncScore](https://sync-score.vercel.app) audits.
12
+
13
+ Bridges local filesystem access with SyncScore's remote audit tools in a single
14
+ MCP server, so a customer's AI client (Claude Desktop, Cursor, etc.) only needs
15
+ to configure one `mcpServers` entry to run a full audit — no separate
16
+ filesystem MCP server required.
17
+
18
+ ## Usage
19
+
20
+ Add to your MCP client config (e.g. `claude_desktop_config.json`):
21
+
22
+ ```json
23
+ {
24
+ "mcpServers": {
25
+ "syncscore": {
26
+ "command": "uvx",
27
+ "args": ["syncscore-companion", "--project-path", "/absolute/path/to/your/project"],
28
+ "env": { "SYNCSCORE_API_KEY": "sk_live_..." }
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ ## What it does
35
+
36
+ - Exposes read-only, scoped filesystem tools (`read_file`, `list_directory`,
37
+ `search_files`) rooted at `--project-path`. Excludes `.git`, `node_modules`,
38
+ `.venv`, dotfiles/secrets, and files over 512KB.
39
+ - Transparently proxies SyncScore's remote audit tools
40
+ (`start_audit`, `report_*`, `finalize_report`) to
41
+ `https://syncscore-api.onrender.com/mcp`, injecting your API key as the
42
+ `Authorization` header automatically.
43
+
44
+ ## Local development
45
+
46
+ ```bash
47
+ uv pip install -e ".[dev]"
48
+ pytest tests/
49
+ ```
@@ -0,0 +1,41 @@
1
+ # syncscore-companion
2
+
3
+ Local companion MCP server for [SyncScore](https://sync-score.vercel.app) audits.
4
+
5
+ Bridges local filesystem access with SyncScore's remote audit tools in a single
6
+ MCP server, so a customer's AI client (Claude Desktop, Cursor, etc.) only needs
7
+ to configure one `mcpServers` entry to run a full audit — no separate
8
+ filesystem MCP server required.
9
+
10
+ ## Usage
11
+
12
+ Add to your MCP client config (e.g. `claude_desktop_config.json`):
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "syncscore": {
18
+ "command": "uvx",
19
+ "args": ["syncscore-companion", "--project-path", "/absolute/path/to/your/project"],
20
+ "env": { "SYNCSCORE_API_KEY": "sk_live_..." }
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ ## What it does
27
+
28
+ - Exposes read-only, scoped filesystem tools (`read_file`, `list_directory`,
29
+ `search_files`) rooted at `--project-path`. Excludes `.git`, `node_modules`,
30
+ `.venv`, dotfiles/secrets, and files over 512KB.
31
+ - Transparently proxies SyncScore's remote audit tools
32
+ (`start_audit`, `report_*`, `finalize_report`) to
33
+ `https://syncscore-api.onrender.com/mcp`, injecting your API key as the
34
+ `Authorization` header automatically.
35
+
36
+ ## Local development
37
+
38
+ ```bash
39
+ uv pip install -e ".[dev]"
40
+ pytest tests/
41
+ ```
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "syncscore-companion"
3
+ version = "0.1.0"
4
+ description = "Local companion MCP server for SyncScore audits — bridges filesystem access to the SyncScore remote MCP server"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "fastmcp>=3.4.0",
9
+ ]
10
+
11
+ [project.scripts]
12
+ syncscore-companion = "syncscore_companion.cli:main"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/syncscore_companion"]
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest>=8.0.0",
24
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ from .server import DEFAULT_REMOTE_URL, build_server
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(prog="syncscore-companion")
12
+ parser.add_argument(
13
+ "--project-path",
14
+ required=True,
15
+ help="Path to the local project to audit",
16
+ )
17
+ parser.add_argument(
18
+ "--api-key",
19
+ default=None,
20
+ help="SyncScore API key (sk_live_...). Falls back to SYNCSCORE_API_KEY env var.",
21
+ )
22
+ parser.add_argument(
23
+ "--remote-url",
24
+ default=os.environ.get("SYNCSCORE_REMOTE_URL", DEFAULT_REMOTE_URL),
25
+ help=argparse.SUPPRESS,
26
+ )
27
+ args = parser.parse_args()
28
+
29
+ api_key = args.api_key or os.environ.get("SYNCSCORE_API_KEY", "")
30
+ if not api_key.startswith("sk_live_"):
31
+ sys.stderr.write(
32
+ "Error: a valid SyncScore API key (sk_live_...) is required via "
33
+ "--api-key or the SYNCSCORE_API_KEY environment variable\n"
34
+ )
35
+ sys.exit(1)
36
+
37
+ try:
38
+ mcp = build_server(args.project_path, api_key, remote_url=args.remote_url)
39
+ except ValueError as e:
40
+ sys.stderr.write(f"Error: {e}\n")
41
+ sys.exit(1)
42
+
43
+ mcp.run(transport="stdio")
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ EXCLUDED_DIRS = {
6
+ ".git",
7
+ "node_modules",
8
+ ".venv",
9
+ "venv",
10
+ "__pycache__",
11
+ ".next",
12
+ "dist",
13
+ "build",
14
+ ".idea",
15
+ ".vscode",
16
+ }
17
+ ALLOWED_DOTFILES = {".env.example", ".env.template"}
18
+ BLOCKED_EXACT_NAMES = {".env"}
19
+ MAX_FILE_SIZE_BYTES = 512 * 1024
20
+ MAX_SEARCH_RESULTS = 200
21
+
22
+
23
+ def resolve_scoped(root: Path, relative_path: str) -> Path:
24
+ """Resolve relative_path under root, rejecting traversal and symlink escapes.
25
+
26
+ Path.resolve() dereferences symlinks, so checking the resolved path's
27
+ containment covers both "../" traversal and a symlink pointing outside
28
+ root in one check.
29
+ """
30
+ candidate = (root / relative_path).resolve()
31
+ if candidate != root and not candidate.is_relative_to(root):
32
+ raise ValueError(f"Path '{relative_path}' escapes the project root")
33
+ return candidate
34
+
35
+
36
+ def is_excluded(path: Path, root: Path) -> bool:
37
+ rel_parts = path.relative_to(root).parts
38
+ if any(part in EXCLUDED_DIRS for part in rel_parts):
39
+ return True
40
+ if path.name in BLOCKED_EXACT_NAMES:
41
+ return True
42
+ if path.name.startswith(".") and path.name not in ALLOWED_DOTFILES:
43
+ return True
44
+ return False
45
+
46
+
47
+ def _looks_binary(data: bytes) -> bool:
48
+ return b"\x00" in data[:8192]
49
+
50
+
51
+ def read_file(root: Path, relative_path: str) -> str:
52
+ path = resolve_scoped(root, relative_path)
53
+ if is_excluded(path, root):
54
+ raise ValueError(f"Path '{relative_path}' is excluded from access")
55
+ if not path.is_file():
56
+ raise ValueError(f"'{relative_path}' is not a file")
57
+
58
+ size = path.stat().st_size
59
+ if size > MAX_FILE_SIZE_BYTES:
60
+ raise ValueError(
61
+ f"File '{relative_path}' exceeds the {MAX_FILE_SIZE_BYTES // 1024}KB limit"
62
+ )
63
+
64
+ raw = path.read_bytes()
65
+ if _looks_binary(raw):
66
+ raise ValueError(f"File '{relative_path}' appears to be binary — skip it")
67
+
68
+ return raw.decode("utf-8", errors="replace")
69
+
70
+
71
+ def list_directory(root: Path, relative_path: str = ".") -> list[str]:
72
+ path = resolve_scoped(root, relative_path)
73
+ if not path.is_dir():
74
+ raise ValueError(f"'{relative_path}' is not a directory")
75
+
76
+ entries = []
77
+ for entry in sorted(path.iterdir(), key=lambda e: e.name):
78
+ if is_excluded(entry, root):
79
+ continue
80
+ entries.append(entry.name + "/" if entry.is_dir() else entry.name)
81
+ return entries
82
+
83
+
84
+ def search_files(root: Path, relative_path: str, pattern: str) -> list[str]:
85
+ base = resolve_scoped(root, relative_path)
86
+ if not base.is_dir():
87
+ raise ValueError(f"'{relative_path}' is not a directory")
88
+
89
+ results: list[str] = []
90
+ for match in base.rglob(pattern):
91
+ if len(results) >= MAX_SEARCH_RESULTS:
92
+ break
93
+ # Skip anything under an excluded/symlinked directory in its path chain.
94
+ try:
95
+ rel = match.relative_to(root)
96
+ except ValueError:
97
+ continue
98
+ if any(part in EXCLUDED_DIRS for part in rel.parts):
99
+ continue
100
+ if any((root / Path(*rel.parts[:i])).is_symlink() for i in range(1, len(rel.parts))):
101
+ continue
102
+ if match.is_file() and not is_excluded(match, root):
103
+ results.append(rel.as_posix())
104
+
105
+ return results
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastmcp import FastMCP
6
+ from fastmcp.client import Client
7
+ from fastmcp.client.transports.http import StreamableHttpTransport
8
+ from fastmcp.server import create_proxy
9
+
10
+ from . import fs_tools
11
+
12
+ DEFAULT_REMOTE_URL = "https://syncscore-api.onrender.com/mcp"
13
+
14
+
15
+ def build_server(project_path: str, api_key: str, remote_url: str = DEFAULT_REMOTE_URL) -> FastMCP:
16
+ root = Path(project_path).expanduser().resolve()
17
+ if not root.is_dir():
18
+ raise ValueError(f"--project-path '{project_path}' is not a directory")
19
+
20
+ mcp = FastMCP("SyncScore Companion")
21
+
22
+ @mcp.tool
23
+ def read_file(relative_path: str) -> str:
24
+ """Read a text file within the scoped project directory (read-only, max 512KB)."""
25
+ return fs_tools.read_file(root, relative_path)
26
+
27
+ @mcp.tool
28
+ def list_directory(relative_path: str = ".") -> list[str]:
29
+ """List entries in a directory within the scoped project directory.
30
+
31
+ Excludes .git, node_modules, .venv, and dotfiles/secrets. Directory
32
+ entries are suffixed with '/'.
33
+ """
34
+ return fs_tools.list_directory(root, relative_path)
35
+
36
+ @mcp.tool
37
+ def search_files(pattern: str, relative_path: str = ".") -> list[str]:
38
+ """Glob-search for files matching a pattern within the scoped project directory."""
39
+ return fs_tools.search_files(root, relative_path, pattern)
40
+
41
+ remote = Client(
42
+ StreamableHttpTransport(
43
+ remote_url,
44
+ headers={"Authorization": f"Bearer {api_key}"},
45
+ )
46
+ )
47
+ mcp.mount(create_proxy(remote))
48
+
49
+ return mcp
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from syncscore_companion.cli import main
9
+
10
+
11
+ def test_missing_project_path_exits(monkeypatch: pytest.MonkeyPatch):
12
+ monkeypatch.setattr(sys, "argv", ["syncscore-companion", "--api-key", "sk_live_x"])
13
+ with pytest.raises(SystemExit):
14
+ main()
15
+
16
+
17
+ def test_invalid_api_key_prefix_exits(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
18
+ monkeypatch.setattr(
19
+ sys,
20
+ "argv",
21
+ ["syncscore-companion", "--project-path", str(tmp_path), "--api-key", "not-a-real-key"],
22
+ )
23
+ with pytest.raises(SystemExit) as exc_info:
24
+ main()
25
+ assert exc_info.value.code == 1
26
+
27
+
28
+ def test_api_key_falls_back_to_env_but_still_requires_prefix(
29
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
30
+ ):
31
+ monkeypatch.setattr(sys, "argv", ["syncscore-companion", "--project-path", str(tmp_path)])
32
+ monkeypatch.delenv("SYNCSCORE_API_KEY", raising=False)
33
+ with pytest.raises(SystemExit) as exc_info:
34
+ main()
35
+ assert exc_info.value.code == 1
36
+
37
+
38
+ def test_nonexistent_project_path_exits(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
39
+ missing = tmp_path / "does-not-exist"
40
+ monkeypatch.setattr(
41
+ sys,
42
+ "argv",
43
+ ["syncscore-companion", "--project-path", str(missing), "--api-key", "sk_live_x"],
44
+ )
45
+ with pytest.raises(SystemExit) as exc_info:
46
+ main()
47
+ assert exc_info.value.code == 1
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from syncscore_companion import fs_tools
9
+
10
+
11
+ @pytest.fixture
12
+ def project(tmp_path: Path) -> Path:
13
+ (tmp_path / "README.md").write_text("hello world")
14
+ (tmp_path / ".env").write_text("SECRET=1")
15
+ (tmp_path / ".env.example").write_text("SECRET=")
16
+ (tmp_path / "src").mkdir()
17
+ (tmp_path / "src" / "main.py").write_text("print('hi')")
18
+ (tmp_path / "node_modules").mkdir()
19
+ (tmp_path / "node_modules" / "pkg.js").write_text("module.exports = {}")
20
+ (tmp_path / ".git").mkdir()
21
+ (tmp_path / ".git" / "config").write_text("[core]")
22
+ return tmp_path
23
+
24
+
25
+ def test_read_file_within_root(project: Path):
26
+ assert fs_tools.read_file(project, "README.md") == "hello world"
27
+ assert fs_tools.read_file(project, "src/main.py") == "print('hi')"
28
+
29
+
30
+ def test_read_file_allows_env_example(project: Path):
31
+ assert fs_tools.read_file(project, ".env.example") == "SECRET="
32
+
33
+
34
+ def test_read_file_blocks_env(project: Path):
35
+ with pytest.raises(ValueError):
36
+ fs_tools.read_file(project, ".env")
37
+
38
+
39
+ def test_read_file_blocks_traversal(project: Path):
40
+ with pytest.raises(ValueError):
41
+ fs_tools.read_file(project, "../../../etc/passwd")
42
+
43
+
44
+ def test_read_file_blocks_excluded_dirs(project: Path):
45
+ with pytest.raises(ValueError):
46
+ fs_tools.read_file(project, "node_modules/pkg.js")
47
+ with pytest.raises(ValueError):
48
+ fs_tools.read_file(project, ".git/config")
49
+
50
+
51
+ def test_read_file_blocks_symlink_escape(project: Path, tmp_path_factory: pytest.TempPathFactory):
52
+ outside = tmp_path_factory.mktemp("outside")
53
+ secret = outside / "secret.txt"
54
+ secret.write_text("top secret")
55
+
56
+ link = project / "escape"
57
+ os.symlink(outside, link)
58
+
59
+ with pytest.raises(ValueError):
60
+ fs_tools.read_file(project, "escape/secret.txt")
61
+
62
+
63
+ def test_read_file_size_limit(project: Path):
64
+ big = project / "big.txt"
65
+ big.write_text("x" * (fs_tools.MAX_FILE_SIZE_BYTES + 1))
66
+ with pytest.raises(ValueError):
67
+ fs_tools.read_file(project, "big.txt")
68
+
69
+
70
+ def test_read_file_binary_rejected(project: Path):
71
+ binary = project / "binary.dat"
72
+ binary.write_bytes(b"\x00\x01\x02\x03")
73
+ with pytest.raises(ValueError):
74
+ fs_tools.read_file(project, "binary.dat")
75
+
76
+
77
+ def test_list_directory_excludes_dot_dirs(project: Path):
78
+ entries = fs_tools.list_directory(project, ".")
79
+ assert "node_modules/" not in entries
80
+ assert ".git/" not in entries
81
+ assert ".env" not in entries
82
+ assert ".env.example" in entries
83
+ assert "README.md" in entries
84
+ assert "src/" in entries
85
+
86
+
87
+ def test_list_directory_blocks_traversal(project: Path):
88
+ with pytest.raises(ValueError):
89
+ fs_tools.list_directory(project, "..")
90
+
91
+
92
+ def test_search_files_finds_matches(project: Path):
93
+ results = fs_tools.search_files(project, ".", "*.py")
94
+ assert "src/main.py" in results
95
+
96
+
97
+ def test_search_files_skips_excluded_dirs(project: Path):
98
+ results = fs_tools.search_files(project, ".", "*")
99
+ assert not any(r.startswith("node_modules/") for r in results)
100
+ assert not any(r.startswith(".git/") for r in results)