project-mcp 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.
@@ -0,0 +1,5 @@
1
+ """Project MCP package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
project_mcp/app.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ import hmac
5
+ from typing import Any, AsyncIterator
6
+
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
9
+
10
+ from project_mcp.config import RuntimeConfig, load_runtime_config
11
+ from project_mcp.mcp_server import create_mcp_server
12
+ from project_mcp.profiles import load_profile, profile_as_public_dict
13
+ from project_mcp.security import origin_allowed
14
+
15
+
16
+ def _token_from_request(request: Request) -> str | None:
17
+ authorization = request.headers.get("authorization", "")
18
+ if authorization.startswith("Bearer "):
19
+ return authorization.removeprefix("Bearer ").strip()
20
+ query_token = request.query_params.get("project_mcp_token") or request.query_params.get("token")
21
+ return query_token
22
+
23
+
24
+ def _token_matches(expected: str | None, actual: str | None) -> bool:
25
+ if not expected:
26
+ return True
27
+ if not actual:
28
+ return False
29
+ return hmac.compare_digest(expected, actual)
30
+
31
+
32
+ def _cors_headers(origin: str | None) -> dict[str, str]:
33
+ headers = {
34
+ "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS",
35
+ "Access-Control-Allow-Headers": "authorization,content-type,mcp-session-id",
36
+ "Access-Control-Expose-Headers": "Mcp-Session-Id,MCP-Session-Id",
37
+ }
38
+ if origin:
39
+ headers["Access-Control-Allow-Origin"] = origin
40
+ headers["Vary"] = "Origin"
41
+ return headers
42
+
43
+
44
+ def create_app(config: RuntimeConfig | None = None) -> FastAPI:
45
+ config = config or load_runtime_config()
46
+ mcp_app = create_mcp_server(config).streamable_http_app()
47
+
48
+ @asynccontextmanager
49
+ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
50
+ async with mcp_app.router.lifespan_context(mcp_app):
51
+ yield
52
+
53
+ app = FastAPI(
54
+ title="Project MCP",
55
+ docs_url=None,
56
+ redoc_url=None,
57
+ openapi_url=None,
58
+ lifespan=lifespan,
59
+ )
60
+
61
+ @app.middleware("http")
62
+ async def security_middleware(request: Request, call_next: Any) -> Response:
63
+ origin = request.headers.get("origin")
64
+ if not origin_allowed(origin, config.origin_allowlist):
65
+ return PlainTextResponse("Forbidden origin", status_code=403)
66
+ if request.method == "OPTIONS":
67
+ return Response(status_code=204, headers=_cors_headers(origin))
68
+ if not _token_matches(config.token, _token_from_request(request)):
69
+ return PlainTextResponse("Unauthorized", status_code=401, headers=_cors_headers(origin))
70
+ response = await call_next(request)
71
+ for key, value in _cors_headers(origin).items():
72
+ response.headers[key] = value
73
+ return response
74
+
75
+ @app.get("/healthz")
76
+ async def healthz() -> dict[str, object]:
77
+ return {
78
+ "ok": True,
79
+ "name": "Project MCP",
80
+ "root": str(config.real_root),
81
+ "port": config.port,
82
+ "auth_enabled": bool(config.token),
83
+ "read_only": config.read_only,
84
+ "bash_enabled": not config.no_bash,
85
+ "tunnel_mode": config.tunnel_mode,
86
+ }
87
+
88
+ @app.get("/setup", response_class=HTMLResponse)
89
+ async def setup_page() -> str:
90
+ return f"""<!doctype html>
91
+ <html lang="en">
92
+ <head>
93
+ <meta charset="utf-8">
94
+ <meta name="viewport" content="width=device-width, initial-scale=1">
95
+ <title>Project MCP</title>
96
+ <style>
97
+ body {{ margin: 0; font: 14px/1.5 system-ui, sans-serif; color: #202123; background: #fff; }}
98
+ main {{ max-width: 760px; margin: 40px auto; padding: 0 20px; }}
99
+ code {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
100
+ .panel {{ border: 1px solid #e5e5e5; border-radius: 8px; padding: 16px; }}
101
+ </style>
102
+ </head>
103
+ <body>
104
+ <main>
105
+ <h1>Project MCP</h1>
106
+ <div class="panel">
107
+ <p><strong>Root:</strong> <code>{config.real_root}</code></p>
108
+ <p><strong>MCP endpoint:</strong> <code>/mcp</code></p>
109
+ <p><strong>Auth:</strong> {"token protected" if config.token else "disabled"}</p>
110
+ <p><strong>Mode:</strong> read_only={config.read_only}, bash_enabled={not config.no_bash}</p>
111
+ </div>
112
+ </main>
113
+ </body>
114
+ </html>"""
115
+
116
+ @app.get("/admin/profile")
117
+ async def admin_profile() -> JSONResponse:
118
+ profile = load_profile(config.real_root)
119
+ return JSONResponse(
120
+ {
121
+ "ok": True,
122
+ "profile": profile_as_public_dict(profile) if profile else None,
123
+ "runtime": {
124
+ "root": str(config.real_root),
125
+ "port": config.port,
126
+ "read_only": config.read_only,
127
+ "bash_enabled": not config.no_bash,
128
+ },
129
+ }
130
+ )
131
+
132
+ app.mount("/", mcp_app)
133
+ return app
134
+
135
+
136
+ app = create_app()
project_mcp/cli.py ADDED
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import queue
5
+ import re
6
+ import shutil
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ from pathlib import Path
15
+
16
+ import typer
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from project_mcp.profiles import (
21
+ ensure_profile,
22
+ load_profile,
23
+ profile_as_public_dict,
24
+ profile_path,
25
+ rotate_profile_token,
26
+ )
27
+
28
+
29
+ app = typer.Typer(help="Expose one local project as a token-protected MCP server.")
30
+ token_app = typer.Typer(help="Manage project tokens.")
31
+ settings_app = typer.Typer(help="Show project settings.")
32
+ app.add_typer(token_app, name="token")
33
+ app.add_typer(settings_app, name="settings")
34
+ console = Console()
35
+
36
+
37
+ def _root_option(value: Path | None) -> Path:
38
+ return (value or Path.cwd()).expanduser().resolve()
39
+
40
+
41
+ def _assert_port_available(host: str, port: int) -> None:
42
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
43
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
44
+ try:
45
+ sock.bind((host, port))
46
+ except OSError as exc:
47
+ raise typer.BadParameter(f"{host}:{port} is not available: {exc}") from exc
48
+
49
+
50
+ def _health_url(port: int, token: str) -> str:
51
+ return f"http://127.0.0.1:{port}/healthz?project_mcp_token={token}"
52
+
53
+
54
+ def _wait_for_health(port: int, token: str, timeout: float = 20.0) -> None:
55
+ deadline = time.monotonic() + timeout
56
+ url = _health_url(port, token)
57
+ while time.monotonic() < deadline:
58
+ try:
59
+ request = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
60
+ with urllib.request.urlopen(request, timeout=1.5) as response:
61
+ if response.status == 200:
62
+ return
63
+ except (urllib.error.URLError, TimeoutError):
64
+ time.sleep(0.25)
65
+ raise RuntimeError(f"Timed out waiting for local server at {url}")
66
+
67
+
68
+ def _reader_thread(name: str, stream, output: queue.Queue[tuple[str, str]]) -> threading.Thread:
69
+ def run() -> None:
70
+ for line in iter(stream.readline, ""):
71
+ output.put((name, line.rstrip()))
72
+
73
+ thread = threading.Thread(target=run, daemon=True)
74
+ thread.start()
75
+ return thread
76
+
77
+
78
+ def _wait_for_cloudflare_url(process: subprocess.Popen[str], timeout: float = 60.0) -> str:
79
+ output: queue.Queue[tuple[str, str]] = queue.Queue()
80
+ if process.stdout:
81
+ _reader_thread("stdout", process.stdout, output)
82
+ if process.stderr:
83
+ _reader_thread("stderr", process.stderr, output)
84
+ pattern = re.compile(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com")
85
+ deadline = time.monotonic() + timeout
86
+ recent: list[str] = []
87
+ while time.monotonic() < deadline:
88
+ if process.poll() is not None:
89
+ raise RuntimeError("cloudflared exited before publishing a tunnel URL.")
90
+ try:
91
+ _, line = output.get(timeout=0.5)
92
+ except queue.Empty:
93
+ continue
94
+ if line:
95
+ recent.append(line)
96
+ recent[:] = recent[-20:]
97
+ match = pattern.search(line)
98
+ if match:
99
+ return match.group(0)
100
+ tail = "\n".join(recent)
101
+ raise RuntimeError(f"Timed out waiting for Cloudflare tunnel URL.\n{tail}")
102
+
103
+
104
+ def _terminate(process: subprocess.Popen[str] | None) -> None:
105
+ if not process or process.poll() is not None:
106
+ return
107
+ process.terminate()
108
+ try:
109
+ process.wait(timeout=5)
110
+ except subprocess.TimeoutExpired:
111
+ process.kill()
112
+
113
+
114
+ @app.command()
115
+ def setup(
116
+ root: Path = typer.Option(Path("."), "--root", help="Project root."),
117
+ port: int = typer.Option(8080, "--port", min=1, max=65535, help="Local HTTP port."),
118
+ ) -> None:
119
+ """Create or update the project profile while keeping its token stable."""
120
+ real_root = _root_option(root)
121
+ profile = ensure_profile(real_root, port=port)
122
+ path = profile_path(real_root)
123
+ console.print("[bold green]Project MCP profile ready[/bold green]")
124
+ console.print(f"Root: [cyan]{profile.root}[/cyan]")
125
+ console.print(f"Port: [cyan]{profile.port}[/cyan]")
126
+ console.print(f"Profile: [cyan]{path}[/cyan]")
127
+ console.print("Token: [dim]<fixed project token saved locally>[/dim]")
128
+
129
+
130
+ @app.command()
131
+ def start(
132
+ root: Path = typer.Option(Path("."), "--root", help="Project root."),
133
+ port: int | None = typer.Option(None, "--port", min=1, max=65535, help="Local HTTP port."),
134
+ no_bash: bool = typer.Option(False, "--no-bash", help="Hide run_check from MCP clients."),
135
+ read_only: bool = typer.Option(False, "--read-only", help="Hide write_file and edit_file."),
136
+ ) -> None:
137
+ """Start the local MCP server and expose it through a Cloudflare quick tunnel."""
138
+ real_root = _root_option(root)
139
+ profile = ensure_profile(
140
+ real_root,
141
+ port=port or (load_profile(real_root).port if load_profile(real_root) else 8080),
142
+ read_only=read_only,
143
+ no_bash=no_bash,
144
+ )
145
+ token = profile.project_token
146
+ if not token:
147
+ raise typer.BadParameter("Public tunnel mode requires a project token.")
148
+ cloudflared = shutil.which("cloudflared")
149
+ if not cloudflared:
150
+ console.print("[bold red]cloudflared was not found on PATH.[/bold red]")
151
+ console.print("Install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/")
152
+ raise typer.Exit(1)
153
+
154
+ _assert_port_available("127.0.0.1", profile.port)
155
+ env = {
156
+ **os.environ,
157
+ "PROJECT_MCP_ROOT": profile.root,
158
+ "PROJECT_MCP_HOST": "127.0.0.1",
159
+ "PROJECT_MCP_PORT": str(profile.port),
160
+ "PROJECT_MCP_TOKEN": token,
161
+ "PROJECT_MCP_READ_ONLY": "1" if profile.read_only else "0",
162
+ "PROJECT_MCP_NO_BASH": "1" if profile.no_bash else "0",
163
+ "PROJECT_MCP_TUNNEL_MODE": "cloudflare",
164
+ }
165
+ server: subprocess.Popen[str] | None = None
166
+ tunnel: subprocess.Popen[str] | None = None
167
+ try:
168
+ console.print("[cyan]Starting local MCP server...[/cyan]")
169
+ server = subprocess.Popen(
170
+ [
171
+ sys.executable,
172
+ "-m",
173
+ "uvicorn",
174
+ "project_mcp.app:app",
175
+ "--host",
176
+ "127.0.0.1",
177
+ "--port",
178
+ str(profile.port),
179
+ ],
180
+ env=env,
181
+ text=True,
182
+ )
183
+ _wait_for_health(profile.port, token)
184
+ local_base = f"http://127.0.0.1:{profile.port}"
185
+ console.print(f"[green]Local MCP ready:[/green] {local_base}/mcp")
186
+
187
+ console.print("[cyan]Opening Cloudflare quick tunnel...[/cyan]")
188
+ tunnel = subprocess.Popen(
189
+ [cloudflared, "tunnel", "--url", local_base],
190
+ stdout=subprocess.PIPE,
191
+ stderr=subprocess.PIPE,
192
+ text=True,
193
+ )
194
+ public_base = _wait_for_cloudflare_url(tunnel)
195
+ server_url = f"{public_base}/mcp?project_mcp_token={token}"
196
+ console.print("\n[bold green]Project MCP ready[/bold green]")
197
+ console.print(f"Workspace: [cyan]{profile.root}[/cyan]")
198
+ console.print(f"Server URL: [bold]{server_url}[/bold]")
199
+ console.print("ChatGPT App authentication: [bold]None / No Authentication[/bold]")
200
+ console.print("Press Ctrl-C to stop.")
201
+ while True:
202
+ if server.poll() is not None:
203
+ raise RuntimeError(f"Local MCP server exited with code {server.returncode}.")
204
+ if tunnel.poll() is not None:
205
+ raise RuntimeError(f"cloudflared exited with code {tunnel.returncode}.")
206
+ time.sleep(1)
207
+ except KeyboardInterrupt:
208
+ console.print("\nStopping Project MCP...")
209
+ except Exception as exc:
210
+ console.print(f"[bold red]Project MCP start failed:[/bold red] {exc}")
211
+ raise typer.Exit(1) from exc
212
+ finally:
213
+ _terminate(tunnel)
214
+ _terminate(server)
215
+
216
+
217
+ @app.command()
218
+ def doctor(root: Path = typer.Option(Path("."), "--root", help="Project root.")) -> None:
219
+ """Check local prerequisites and project profile state."""
220
+ real_root = _root_option(root)
221
+ profile = load_profile(real_root)
222
+ table = Table(title="Project MCP doctor")
223
+ table.add_column("Check")
224
+ table.add_column("Status")
225
+ table.add_column("Detail")
226
+ table.add_row("Python", "ok", sys.version.split()[0])
227
+ table.add_row("cloudflared", "ok" if shutil.which("cloudflared") else "missing", shutil.which("cloudflared") or "not found")
228
+ table.add_row("profile", "ok" if profile else "missing", str(profile_path(real_root)))
229
+ table.add_row("root", "ok" if real_root.is_dir() else "fail", str(real_root))
230
+ console.print(table)
231
+
232
+
233
+ @token_app.command("rotate")
234
+ def token_rotate(root: Path = typer.Option(Path("."), "--root", help="Project root.")) -> None:
235
+ """Rotate the fixed project token for this root."""
236
+ real_root = _root_option(root)
237
+ rotate_profile_token(real_root)
238
+ console.print("[bold green]Project token rotated.[/bold green]")
239
+ console.print("Update the ChatGPT Server URL before reconnecting.")
240
+
241
+
242
+ @settings_app.command("show")
243
+ def settings_show(root: Path = typer.Option(Path("."), "--root", help="Project root.")) -> None:
244
+ """Show the saved profile with the token redacted."""
245
+ real_root = _root_option(root)
246
+ profile = load_profile(real_root)
247
+ if not profile:
248
+ console.print("[yellow]No profile found. Run project-mcp setup first.[/yellow]")
249
+ raise typer.Exit(1)
250
+ console.print_json(data=profile_as_public_dict(profile))
251
+
252
+
253
+ def main() -> None:
254
+ app()
255
+
256
+
257
+ if __name__ == "__main__":
258
+ main()
project_mcp/config.py ADDED
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from pydantic import Field
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ DEFAULT_BLOCKED_GLOBS = (
11
+ ".git",
12
+ ".git/*",
13
+ "**/.git/*",
14
+ ".env",
15
+ ".env.*",
16
+ "**/.env",
17
+ "**/.env.*",
18
+ "*.pem",
19
+ "*.key",
20
+ "**/*.pem",
21
+ "**/*.key",
22
+ ".ssh",
23
+ ".ssh/*",
24
+ "**/.ssh/*",
25
+ "id_rsa",
26
+ "id_rsa.*",
27
+ "id_ed25519",
28
+ "id_ed25519.*",
29
+ "node_modules",
30
+ "node_modules/*",
31
+ "**/node_modules/*",
32
+ "dist",
33
+ "dist/*",
34
+ "**/dist/*",
35
+ "build",
36
+ "build/*",
37
+ "**/build/*",
38
+ ".next",
39
+ ".next/*",
40
+ "**/.next/*",
41
+ "coverage",
42
+ "coverage/*",
43
+ "**/coverage/*",
44
+ ".cache",
45
+ ".cache/*",
46
+ "**/.cache/*",
47
+ "__pycache__",
48
+ "__pycache__/*",
49
+ "**/__pycache__/*",
50
+ )
51
+
52
+
53
+ def project_home() -> Path:
54
+ raw = os.environ.get("PROJECT_MCP_HOME")
55
+ return Path(raw).expanduser() if raw else Path.home() / ".project-mcp"
56
+
57
+
58
+ class RuntimeConfig(BaseSettings):
59
+ model_config = SettingsConfigDict(env_prefix="PROJECT_MCP_", extra="ignore")
60
+
61
+ root: Path = Field(default_factory=Path.cwd)
62
+ host: str = "127.0.0.1"
63
+ port: int = 8080
64
+ token: str | None = None
65
+ read_only: bool = False
66
+ no_bash: bool = False
67
+ tunnel_mode: str = "cloudflare"
68
+ max_read_bytes: int = 180_000
69
+ max_write_bytes: int = 1_000_000
70
+ max_output_bytes: int = 120_000
71
+ max_search_results: int = 200
72
+ blocked_globs: str = ""
73
+ allowed_origins: str = "https://chatgpt.com,https://chat.openai.com"
74
+
75
+ @property
76
+ def real_root(self) -> Path:
77
+ root = self.root.expanduser().resolve()
78
+ if not root.exists():
79
+ raise ValueError(f"Project root does not exist: {root}")
80
+ if not root.is_dir():
81
+ raise ValueError(f"Project root is not a directory: {root}")
82
+ return root
83
+
84
+ @property
85
+ def all_blocked_globs(self) -> tuple[str, ...]:
86
+ extra = tuple(part.strip() for part in self.blocked_globs.split(",") if part.strip())
87
+ return (*DEFAULT_BLOCKED_GLOBS, *extra)
88
+
89
+ @property
90
+ def origin_allowlist(self) -> tuple[str, ...]:
91
+ return tuple(part.strip().rstrip("/") for part in self.allowed_origins.split(",") if part.strip())
92
+
93
+
94
+ def load_runtime_config() -> RuntimeConfig:
95
+ return RuntimeConfig()
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from project_mcp.config import RuntimeConfig
8
+ from project_mcp import workspace as ops
9
+
10
+
11
+ def create_mcp_server(config: RuntimeConfig) -> FastMCP:
12
+ instructions = "\n".join(
13
+ [
14
+ "Project MCP exposes one local project as token-protected MCP tools.",
15
+ "Start with workspace_info, then use tree/search/read_file for inspection.",
16
+ "Use write_file/edit_file only when the user asks for edits.",
17
+ "Use run_check only for test, lint, typecheck, build, or similar verification.",
18
+ ]
19
+ )
20
+ server = FastMCP(
21
+ "Project MCP",
22
+ instructions=instructions,
23
+ streamable_http_path="/mcp",
24
+ json_response=True,
25
+ )
26
+
27
+ @server.tool(structured_output=True)
28
+ def server_config() -> dict[str, Any]:
29
+ """Show safe Project MCP configuration without revealing the project token."""
30
+ return {
31
+ "root": str(config.real_root),
32
+ "host": config.host,
33
+ "port": config.port,
34
+ "auth_enabled": bool(config.token),
35
+ "read_only": config.read_only,
36
+ "bash_enabled": not config.no_bash,
37
+ "tunnel_mode": config.tunnel_mode,
38
+ "max_read_bytes": config.max_read_bytes,
39
+ "max_write_bytes": config.max_write_bytes,
40
+ "max_output_bytes": config.max_output_bytes,
41
+ "max_search_results": config.max_search_results,
42
+ "blocked_globs": list(config.all_blocked_globs),
43
+ }
44
+
45
+ @server.tool(structured_output=True)
46
+ def workspace_info() -> dict[str, Any]:
47
+ """Return the active project root and runtime modes."""
48
+ return ops.workspace_info(config)
49
+
50
+ @server.tool(structured_output=True)
51
+ def tree(
52
+ path: str = ".",
53
+ max_depth: int = 3,
54
+ include_hidden: bool = False,
55
+ max_entries: int = 800,
56
+ ) -> dict[str, Any]:
57
+ """List files and directories inside the project root."""
58
+ return ops.tree(config, path, max_depth, include_hidden, max_entries)
59
+
60
+ @server.tool(structured_output=True)
61
+ def search(
62
+ query: str,
63
+ path: str = ".",
64
+ regex: bool = False,
65
+ include_hidden: bool = False,
66
+ max_results: int | None = None,
67
+ ) -> dict[str, Any]:
68
+ """Search text files inside the project root."""
69
+ return ops.search(config, query, path, regex, include_hidden, max_results)
70
+
71
+ @server.tool(structured_output=True)
72
+ def read_file(
73
+ path: str,
74
+ start_line: int = 1,
75
+ end_line: int | None = None,
76
+ max_bytes: int | None = None,
77
+ ) -> dict[str, Any]:
78
+ """Read a UTF-8 text file with line numbers."""
79
+ return ops.read_file(config, path, start_line, end_line, max_bytes)
80
+
81
+ if not config.read_only:
82
+
83
+ @server.tool(structured_output=True)
84
+ def write_file(
85
+ path: str,
86
+ content: str,
87
+ create_dirs: bool = True,
88
+ overwrite: bool = True,
89
+ ) -> dict[str, Any]:
90
+ """Create or overwrite a UTF-8 text file and return a unified diff."""
91
+ return ops.write_file(config, path, content, create_dirs, overwrite)
92
+
93
+ @server.tool(structured_output=True)
94
+ def edit_file(
95
+ path: str,
96
+ old_text: str,
97
+ new_text: str,
98
+ replace_all: bool = False,
99
+ expected_replacements: int | None = None,
100
+ ) -> dict[str, Any]:
101
+ """Perform exact text replacement in a file and return a unified diff."""
102
+ return ops.edit_file(
103
+ config, path, old_text, new_text, replace_all, expected_replacements
104
+ )
105
+
106
+ @server.tool(structured_output=True)
107
+ def show_changes(include_diff: bool = True) -> dict[str, Any]:
108
+ """Show git status, diff stats, and optionally the working-tree diff."""
109
+ return ops.show_changes(config, include_diff)
110
+
111
+ if not config.no_bash:
112
+
113
+ @server.tool(structured_output=True)
114
+ def run_check(
115
+ command: str,
116
+ cwd: str = ".",
117
+ timeout_ms: int = 30_000,
118
+ ) -> dict[str, Any]:
119
+ """Run one safe allowlisted verification command in the project."""
120
+ return ops.run_check(config, command, cwd, timeout_ms)
121
+
122
+ return server
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import secrets
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from project_mcp.config import project_home
11
+
12
+
13
+ class WorkspaceProfile(BaseModel):
14
+ root: str
15
+ port: int = 8080
16
+ project_token: str = Field(min_length=24)
17
+ tunnel: str = "cloudflare"
18
+ read_only: bool = False
19
+ no_bash: bool = False
20
+
21
+
22
+ def real_project_root(root: str | Path) -> Path:
23
+ resolved = Path(root).expanduser().resolve()
24
+ if not resolved.exists():
25
+ raise FileNotFoundError(f"Project root does not exist: {resolved}")
26
+ if not resolved.is_dir():
27
+ raise NotADirectoryError(f"Project root is not a directory: {resolved}")
28
+ return resolved
29
+
30
+
31
+ def generate_project_token() -> str:
32
+ return "pmcp_" + secrets.token_urlsafe(32)
33
+
34
+
35
+ def profile_id(root: str | Path) -> str:
36
+ return hashlib.sha256(str(real_project_root(root)).encode("utf-8")).hexdigest()[:32]
37
+
38
+
39
+ def workspaces_dir() -> Path:
40
+ return project_home() / "workspaces"
41
+
42
+
43
+ def profile_path(root: str | Path) -> Path:
44
+ return workspaces_dir() / f"{profile_id(root)}.json"
45
+
46
+
47
+ def load_profile(root: str | Path) -> WorkspaceProfile | None:
48
+ path = profile_path(root)
49
+ if not path.exists():
50
+ return None
51
+ return WorkspaceProfile.model_validate_json(path.read_text("utf-8"))
52
+
53
+
54
+ def save_profile(profile: WorkspaceProfile) -> Path:
55
+ path = profile_path(profile.root)
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ path.write_text(profile.model_dump_json(indent=2) + "\n", "utf-8")
58
+ return path
59
+
60
+
61
+ def ensure_profile(
62
+ root: str | Path,
63
+ *,
64
+ port: int = 8080,
65
+ read_only: bool | None = None,
66
+ no_bash: bool | None = None,
67
+ ) -> WorkspaceProfile:
68
+ real_root = real_project_root(root)
69
+ existing = load_profile(real_root)
70
+ if existing:
71
+ changed = False
72
+ if port and existing.port != port:
73
+ existing.port = port
74
+ changed = True
75
+ if read_only is not None and existing.read_only != read_only:
76
+ existing.read_only = read_only
77
+ changed = True
78
+ if no_bash is not None and existing.no_bash != no_bash:
79
+ existing.no_bash = no_bash
80
+ changed = True
81
+ if changed:
82
+ save_profile(existing)
83
+ return existing
84
+
85
+ profile = WorkspaceProfile(
86
+ root=str(real_root),
87
+ port=port,
88
+ project_token=generate_project_token(),
89
+ read_only=bool(read_only) if read_only is not None else False,
90
+ no_bash=bool(no_bash) if no_bash is not None else False,
91
+ )
92
+ save_profile(profile)
93
+ return profile
94
+
95
+
96
+ def rotate_profile_token(root: str | Path) -> WorkspaceProfile:
97
+ profile = ensure_profile(root)
98
+ profile.project_token = generate_project_token()
99
+ save_profile(profile)
100
+ return profile
101
+
102
+
103
+ def profile_as_public_dict(profile: WorkspaceProfile) -> dict[str, object]:
104
+ data = json.loads(profile.model_dump_json())
105
+ data["project_token"] = "<redacted>"
106
+ return data
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ from pathlib import Path
5
+
6
+
7
+ class ProjectMcpError(Exception):
8
+ """User-safe error surfaced through MCP tool results."""
9
+
10
+
11
+ def posix_rel(path: Path) -> str:
12
+ return path.as_posix() or "."
13
+
14
+
15
+ def is_subpath(child: Path, parent: Path) -> bool:
16
+ try:
17
+ child.relative_to(parent)
18
+ return True
19
+ except ValueError:
20
+ return False
21
+
22
+
23
+ class PathGuard:
24
+ def __init__(self, root: Path, blocked_globs: tuple[str, ...]):
25
+ self.root = root.expanduser().resolve()
26
+ self.blocked_globs = blocked_globs
27
+
28
+ def display_path(self, path: Path) -> str:
29
+ try:
30
+ rel = path.relative_to(self.root)
31
+ except ValueError:
32
+ rel = path
33
+ text = rel.as_posix()
34
+ return text or "."
35
+
36
+ def is_blocked(self, rel_path: str) -> bool:
37
+ rel = rel_path.replace("\\", "/").lstrip("./")
38
+ if not rel or rel == ".":
39
+ return False
40
+ name = rel.rsplit("/", 1)[-1]
41
+ return any(
42
+ fnmatch.fnmatchcase(rel, pattern) or fnmatch.fnmatchcase(name, pattern)
43
+ for pattern in self.blocked_globs
44
+ )
45
+
46
+ def assert_not_blocked(self, rel_path: str) -> None:
47
+ if self.is_blocked(rel_path):
48
+ raise ProjectMcpError(f"Path is blocked by safety rules: {rel_path}")
49
+
50
+ def resolve(self, input_path: str | Path = ".", *, for_write: bool = False) -> tuple[Path, str]:
51
+ raw = Path(input_path).expanduser()
52
+ candidate = raw if raw.is_absolute() else self.root / raw
53
+ abs_path = candidate.resolve(strict=False)
54
+
55
+ if not is_subpath(abs_path, self.root):
56
+ raise ProjectMcpError(f"Path escapes project root: {input_path}")
57
+
58
+ rel_path = self.display_path(abs_path)
59
+ self.assert_not_blocked(rel_path)
60
+
61
+ if abs_path.exists():
62
+ real_target = abs_path.resolve()
63
+ if not is_subpath(real_target, self.root):
64
+ raise ProjectMcpError(f"Path resolves outside project root: {input_path}")
65
+ self.assert_not_blocked(self.display_path(real_target))
66
+
67
+ if for_write:
68
+ parent = abs_path.parent
69
+ while not parent.exists() and parent != parent.parent:
70
+ parent = parent.parent
71
+ real_parent = parent.resolve()
72
+ if not is_subpath(real_parent, self.root):
73
+ raise ProjectMcpError(f"Write path resolves outside project root: {input_path}")
74
+ self.assert_not_blocked(self.display_path(real_parent))
75
+
76
+ return abs_path, rel_path
77
+
78
+ def assert_text_file(self, path: Path, max_bytes: int) -> None:
79
+ if not path.is_file():
80
+ raise ProjectMcpError(f"Not a file: {self.display_path(path)}")
81
+ size = path.stat().st_size
82
+ if size > max_bytes:
83
+ raise ProjectMcpError(f"File is too large ({size} bytes). Limit: {max_bytes} bytes.")
84
+ with path.open("rb") as handle:
85
+ sample = handle.read(min(4096, size))
86
+ if b"\0" in sample:
87
+ raise ProjectMcpError("Refusing to read binary file.")
88
+
89
+
90
+ def origin_allowed(origin: str | None, allowlist: tuple[str, ...]) -> bool:
91
+ if not origin:
92
+ return True
93
+ normalized = origin.rstrip("/")
94
+ if normalized.startswith("http://127.0.0.1") or normalized.startswith("http://localhost"):
95
+ return True
96
+ return normalized in allowlist
@@ -0,0 +1,392 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Iterable
11
+
12
+ from project_mcp.config import RuntimeConfig
13
+ from project_mcp.security import PathGuard, ProjectMcpError
14
+
15
+
16
+ def make_guard(config: RuntimeConfig) -> PathGuard:
17
+ return PathGuard(config.real_root, config.all_blocked_globs)
18
+
19
+
20
+ def trim_text(text: str, max_bytes: int) -> tuple[str, bool]:
21
+ data = text.encode("utf-8", errors="replace")
22
+ if len(data) <= max_bytes:
23
+ return text, False
24
+ return data[:max_bytes].decode("utf-8", errors="replace") + "\n...[truncated]", True
25
+
26
+
27
+ def unified_diff(old: str, new: str, rel_path: str, max_bytes: int = 80_000) -> dict[str, object]:
28
+ if old == new:
29
+ return {"changed": False, "additions": 0, "deletions": 0, "diff": f"No changes in {rel_path}."}
30
+ lines = list(
31
+ difflib.unified_diff(
32
+ old.splitlines(),
33
+ new.splitlines(),
34
+ fromfile=f"a/{rel_path}",
35
+ tofile=f"b/{rel_path}",
36
+ lineterm="",
37
+ )
38
+ )
39
+ additions = sum(1 for line in lines if line.startswith("+") and not line.startswith("+++"))
40
+ deletions = sum(1 for line in lines if line.startswith("-") and not line.startswith("---"))
41
+ diff, truncated = trim_text("\n".join(lines), max_bytes)
42
+ if truncated:
43
+ diff += f"\n...[diff truncated to {max_bytes} bytes]"
44
+ return {"changed": True, "additions": additions, "deletions": deletions, "diff": diff}
45
+
46
+
47
+ def workspace_info(config: RuntimeConfig) -> dict[str, object]:
48
+ root = config.real_root
49
+ return {
50
+ "root": str(root),
51
+ "name": root.name,
52
+ "read_only": config.read_only,
53
+ "bash_enabled": not config.no_bash,
54
+ }
55
+
56
+
57
+ def tree(
58
+ config: RuntimeConfig,
59
+ path: str = ".",
60
+ max_depth: int = 3,
61
+ include_hidden: bool = False,
62
+ max_entries: int = 800,
63
+ ) -> dict[str, object]:
64
+ guard = make_guard(config)
65
+ target, rel = guard.resolve(path)
66
+ if not target.is_dir():
67
+ raise ProjectMcpError(f"Not a directory: {rel}")
68
+
69
+ lines = [rel if rel == "." else f"{rel}/"]
70
+ entries = 0
71
+ truncated = False
72
+
73
+ def visible(entry: Path) -> bool:
74
+ name = entry.name
75
+ if not include_hidden and name.startswith("."):
76
+ return False
77
+ return not guard.is_blocked(guard.display_path(entry.resolve(strict=False)))
78
+
79
+ def walk(directory: Path, prefix: str, depth: int) -> None:
80
+ nonlocal entries, truncated
81
+ if depth >= max_depth or truncated:
82
+ return
83
+ children = sorted(
84
+ (child for child in directory.iterdir() if visible(child)),
85
+ key=lambda item: (not item.is_dir(), item.name.lower()),
86
+ )
87
+ for index, child in enumerate(children):
88
+ if entries >= max_entries:
89
+ truncated = True
90
+ return
91
+ last = index == len(children) - 1
92
+ branch = "`-- " if last else "|-- "
93
+ child_prefix = " " if last else "| "
94
+ suffix = "/" if child.is_dir() else ""
95
+ lines.append(f"{prefix}{branch}{child.name}{suffix}")
96
+ entries += 1
97
+ if child.is_dir():
98
+ walk(child, prefix + child_prefix, depth + 1)
99
+
100
+ walk(target, "", 0)
101
+ if truncated:
102
+ lines.append(f"...[tree truncated after {entries} entries]")
103
+ return {"path": rel, "entries": entries, "truncated": truncated, "tree": "\n".join(lines)}
104
+
105
+
106
+ def iter_files(guard: PathGuard, root: Path, include_hidden: bool = False) -> Iterable[Path]:
107
+ for current, dirs, files in os.walk(root):
108
+ current_path = Path(current)
109
+ filtered_dirs = []
110
+ for dirname in dirs:
111
+ candidate = current_path / dirname
112
+ rel = guard.display_path(candidate.resolve(strict=False))
113
+ if (not include_hidden and dirname.startswith(".")) or guard.is_blocked(rel):
114
+ continue
115
+ filtered_dirs.append(dirname)
116
+ dirs[:] = filtered_dirs
117
+ for filename in files:
118
+ candidate = current_path / filename
119
+ rel = guard.display_path(candidate.resolve(strict=False))
120
+ if (not include_hidden and filename.startswith(".")) or guard.is_blocked(rel):
121
+ continue
122
+ yield candidate
123
+
124
+
125
+ def search(
126
+ config: RuntimeConfig,
127
+ query: str,
128
+ path: str = ".",
129
+ regex: bool = False,
130
+ include_hidden: bool = False,
131
+ max_results: int | None = None,
132
+ ) -> dict[str, object]:
133
+ if not query:
134
+ raise ProjectMcpError("query is required.")
135
+ guard = make_guard(config)
136
+ target, rel = guard.resolve(path)
137
+ max_results = max(1, min(max_results or config.max_search_results, config.max_search_results))
138
+ pattern = re.compile(query) if regex else None
139
+ results: list[dict[str, object]] = []
140
+
141
+ files = [target] if target.is_file() else iter_files(guard, target, include_hidden)
142
+ for file_path in files:
143
+ if len(results) >= max_results:
144
+ break
145
+ try:
146
+ guard.assert_text_file(file_path, config.max_read_bytes)
147
+ lines = file_path.read_text("utf-8", errors="replace").splitlines()
148
+ except (OSError, UnicodeError, ProjectMcpError):
149
+ continue
150
+ for line_no, line in enumerate(lines, start=1):
151
+ matched = bool(pattern.search(line)) if pattern else query in line
152
+ if matched:
153
+ results.append(
154
+ {"path": guard.display_path(file_path), "line": line_no, "text": line[:500]}
155
+ )
156
+ if len(results) >= max_results:
157
+ break
158
+
159
+ return {"path": rel, "query": query, "regex": regex, "count": len(results), "results": results}
160
+
161
+
162
+ def read_file(
163
+ config: RuntimeConfig,
164
+ path: str,
165
+ start_line: int = 1,
166
+ end_line: int | None = None,
167
+ max_bytes: int | None = None,
168
+ ) -> dict[str, object]:
169
+ guard = make_guard(config)
170
+ target, rel = guard.resolve(path)
171
+ limit = min(max_bytes or config.max_read_bytes, config.max_read_bytes)
172
+ guard.assert_text_file(target, limit)
173
+ text = target.read_text("utf-8", errors="replace")
174
+ lines = text.splitlines()
175
+ total = len(lines)
176
+ start = max(1, start_line)
177
+ end = min(total, end_line or total)
178
+ if end < start:
179
+ raise ProjectMcpError("end_line must be >= start_line.")
180
+ selected = lines[start - 1 : end]
181
+ width = len(str(end))
182
+ numbered = "\n".join(f"{idx:>{width}} | {line}" for idx, line in enumerate(selected, start))
183
+ return {
184
+ "path": rel,
185
+ "start_line": start,
186
+ "end_line": end,
187
+ "total_lines": total,
188
+ "bytes": target.stat().st_size,
189
+ "truncated": start > 1 or end < total,
190
+ "text": numbered,
191
+ }
192
+
193
+
194
+ def write_file(
195
+ config: RuntimeConfig,
196
+ path: str,
197
+ content: str,
198
+ create_dirs: bool = True,
199
+ overwrite: bool = True,
200
+ ) -> dict[str, object]:
201
+ if config.read_only:
202
+ raise ProjectMcpError("write_file is disabled because PROJECT_MCP_READ_ONLY=1.")
203
+ guard = make_guard(config)
204
+ target, rel = guard.resolve(path, for_write=True)
205
+ data = content.encode("utf-8")
206
+ if len(data) > config.max_write_bytes:
207
+ raise ProjectMcpError(
208
+ f"Write content is too large ({len(data)} bytes). Limit: {config.max_write_bytes} bytes."
209
+ )
210
+ existed = target.exists()
211
+ old = ""
212
+ if existed:
213
+ guard.assert_text_file(target, max(config.max_read_bytes, config.max_write_bytes))
214
+ if not overwrite:
215
+ raise ProjectMcpError(f"File already exists and overwrite=false: {rel}")
216
+ old = target.read_text("utf-8", errors="replace")
217
+ if create_dirs:
218
+ target.parent.mkdir(parents=True, exist_ok=True)
219
+ target.write_text(content, "utf-8")
220
+ diff = unified_diff(old, content, rel)
221
+ return {"path": rel, "bytes": len(data), "existed": existed, **diff}
222
+
223
+
224
+ def edit_file(
225
+ config: RuntimeConfig,
226
+ path: str,
227
+ old_text: str,
228
+ new_text: str,
229
+ replace_all: bool = False,
230
+ expected_replacements: int | None = None,
231
+ ) -> dict[str, object]:
232
+ if config.read_only:
233
+ raise ProjectMcpError("edit_file is disabled because PROJECT_MCP_READ_ONLY=1.")
234
+ if not old_text:
235
+ raise ProjectMcpError("old_text must not be empty.")
236
+ guard = make_guard(config)
237
+ target, rel = guard.resolve(path, for_write=True)
238
+ guard.assert_text_file(target, max(config.max_read_bytes, config.max_write_bytes))
239
+ before = target.read_text("utf-8", errors="replace")
240
+ occurrences = before.count(old_text)
241
+ if occurrences == 0:
242
+ raise ProjectMcpError(f"old_text was not found in {rel}.")
243
+ if not replace_all and occurrences != 1:
244
+ raise ProjectMcpError(
245
+ f"old_text matched {occurrences} times. Use a more specific snippet or replace_all=true."
246
+ )
247
+ replacements = occurrences if replace_all else 1
248
+ if expected_replacements is not None and expected_replacements != replacements:
249
+ raise ProjectMcpError(
250
+ f"Expected {expected_replacements} replacements but would perform {replacements}."
251
+ )
252
+ after = before.replace(old_text, new_text, -1 if replace_all else 1)
253
+ after_bytes = len(after.encode("utf-8"))
254
+ if after_bytes > config.max_write_bytes:
255
+ raise ProjectMcpError(
256
+ f"Edited file would be too large ({after_bytes} bytes). Limit: {config.max_write_bytes} bytes."
257
+ )
258
+ target.write_text(after, "utf-8")
259
+ diff = unified_diff(before, after, rel)
260
+ return {"path": rel, "replacements": replacements, "bytes": after_bytes, **diff}
261
+
262
+
263
+ def run_git(config: RuntimeConfig, args: list[str], timeout: int = 15) -> str:
264
+ result = subprocess.run(
265
+ ["git", *args],
266
+ cwd=config.real_root,
267
+ text=True,
268
+ stdout=subprocess.PIPE,
269
+ stderr=subprocess.STDOUT,
270
+ timeout=timeout,
271
+ check=False,
272
+ )
273
+ output, _ = trim_text(result.stdout.strip() or "(no output)", config.max_output_bytes)
274
+ return output
275
+
276
+
277
+ def show_changes(config: RuntimeConfig, include_diff: bool = True) -> dict[str, object]:
278
+ if not shutil.which("git"):
279
+ return {"git_available": False, "status": "git not found", "diff": ""}
280
+ status = run_git(config, ["status", "--short"])
281
+ stat = run_git(config, ["diff", "--stat"])
282
+ diff = run_git(config, ["diff", "--"], timeout=30) if include_diff else ""
283
+ return {"git_available": True, "status": status, "stat": stat, "diff": diff}
284
+
285
+
286
+ SAFE_ALLOWED_PREFIXES = (
287
+ "pytest",
288
+ "python -m pytest",
289
+ "python3 -m pytest",
290
+ "uv run pytest",
291
+ "npm test",
292
+ "npm run test",
293
+ "npm run lint",
294
+ "npm run typecheck",
295
+ "npm run build",
296
+ "npm run check",
297
+ "pnpm test",
298
+ "pnpm run test",
299
+ "pnpm run lint",
300
+ "pnpm run typecheck",
301
+ "pnpm run build",
302
+ "pnpm run check",
303
+ "yarn test",
304
+ "yarn run test",
305
+ "yarn run lint",
306
+ "yarn run typecheck",
307
+ "yarn run build",
308
+ "yarn run check",
309
+ "bun test",
310
+ "bun run test",
311
+ "bun run lint",
312
+ "bun run typecheck",
313
+ "bun run build",
314
+ "go test",
315
+ "cargo test",
316
+ "cargo check",
317
+ "cargo clippy",
318
+ "ruff check",
319
+ "uv run ruff check",
320
+ )
321
+
322
+ SAFE_BLOCKED_PATTERNS = (
323
+ r"(^|\s)(rm|mv|cp|dd|sudo|chmod|chown|kill|pkill|curl|wget|ssh|scp|rsync|docker|podman)\s+",
324
+ r"(^|\s)git\s+(push|reset|clean|checkout|switch|restore)\b",
325
+ r"(^|\s)(npm|pnpm|yarn)\s+publish\b",
326
+ r"(^|\s)(cat|grep|rg|head|tail|sed|perl)\s+",
327
+ r"[;&|<>`]",
328
+ r"\$\(",
329
+ r"\n",
330
+ r"(^|\s)(/|~(?:/|\s|$))",
331
+ r"(^|\s)\.\.(?:/|\s|$)",
332
+ r"(^|[\s:])(?:\.env|\.git|node_modules|\.ssh)(?:[/\s:]|$)",
333
+ )
334
+
335
+
336
+ def compact_command(command: str) -> str:
337
+ return " ".join(command.strip().split())
338
+
339
+
340
+ def assert_safe_command(command: str) -> str:
341
+ normalized = compact_command(command)
342
+ if not normalized:
343
+ raise ProjectMcpError("command is required.")
344
+ for pattern in SAFE_BLOCKED_PATTERNS:
345
+ if re.search(pattern, normalized):
346
+ raise ProjectMcpError(f"Command is blocked in safe mode: {normalized}")
347
+ if not any(normalized == prefix or normalized.startswith(prefix + " ") for prefix in SAFE_ALLOWED_PREFIXES):
348
+ raise ProjectMcpError(f"Command is not in the safe allowlist: {normalized}")
349
+ return normalized
350
+
351
+
352
+ def run_check(
353
+ config: RuntimeConfig,
354
+ command: str,
355
+ cwd: str = ".",
356
+ timeout_ms: int = 30_000,
357
+ ) -> dict[str, object]:
358
+ if config.no_bash:
359
+ raise ProjectMcpError("run_check is disabled because PROJECT_MCP_NO_BASH=1.")
360
+ normalized = assert_safe_command(command)
361
+ guard = make_guard(config)
362
+ cwd_path, cwd_rel = guard.resolve(cwd)
363
+ if not cwd_path.is_dir():
364
+ raise ProjectMcpError(f"cwd is not a directory: {cwd_rel}")
365
+ started = time.time()
366
+ result = subprocess.run(
367
+ ["/bin/bash", "-lc", normalized],
368
+ cwd=cwd_path,
369
+ env={
370
+ "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
371
+ "HOME": os.environ.get("HOME", ""),
372
+ "TERM": "dumb",
373
+ "NO_COLOR": "1",
374
+ "CI": "1",
375
+ },
376
+ text=True,
377
+ stdout=subprocess.PIPE,
378
+ stderr=subprocess.PIPE,
379
+ timeout=max(1, min(timeout_ms // 1000, 180)),
380
+ check=False,
381
+ )
382
+ stdout, stdout_truncated = trim_text(result.stdout, config.max_output_bytes)
383
+ stderr, stderr_truncated = trim_text(result.stderr, config.max_output_bytes)
384
+ return {
385
+ "command": normalized,
386
+ "cwd": cwd_rel,
387
+ "exit_code": result.returncode,
388
+ "duration_ms": int((time.time() - started) * 1000),
389
+ "stdout": stdout,
390
+ "stderr": stderr,
391
+ "truncated": stdout_truncated or stderr_truncated,
392
+ }
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.3
2
+ Name: project-mcp
3
+ Version: 0.1.0
4
+ Summary: Expose one local project as a token-protected MCP server for ChatGPT.
5
+ Requires-Dist: fastapi>=0.138.1
6
+ Requires-Dist: mcp[cli]>=1.28,<2
7
+ Requires-Dist: pydantic-settings>=2.14.2
8
+ Requires-Dist: rich>=15.0.0
9
+ Requires-Dist: typer>=0.26.8
10
+ Requires-Dist: uvicorn[standard]>=0.49.0
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Project MCP
15
+
16
+ Expose one local project as a token-protected MCP server for ChatGPT.
17
+
18
+ Project MCP is intentionally small:
19
+
20
+ - one local project root
21
+ - one fixed project token
22
+ - one Streamable HTTP MCP endpoint at `/mcp`
23
+ - Cloudflare quick tunnel for public HTTPS access
24
+ - no login, no hosted relay, no ChatGPT widget UI
25
+
26
+ ## Quick Start
27
+
28
+ Run without installing:
29
+
30
+ ```bash
31
+ uvx project-mcp setup --root .
32
+ uvx project-mcp start
33
+ ```
34
+
35
+ Or install as a persistent tool:
36
+
37
+ ```bash
38
+ uv tool install project-mcp
39
+ project-mcp setup --root .
40
+ project-mcp start
41
+ ```
42
+
43
+ `start` prints a ChatGPT Server URL like:
44
+
45
+ ```text
46
+ https://example.trycloudflare.com/mcp?project_mcp_token=pmcp_...
47
+ ```
48
+
49
+ In ChatGPT Developer Mode, create an app/connector with:
50
+
51
+ ```text
52
+ Connection: Server URL
53
+ Authentication: None / No Authentication
54
+ ```
55
+
56
+ ## Commands
57
+
58
+ ```bash
59
+ project-mcp setup --root . --port 8080
60
+ project-mcp start --root .
61
+ project-mcp start --read-only
62
+ project-mcp start --no-bash
63
+ project-mcp doctor
64
+ project-mcp token rotate
65
+ project-mcp settings show
66
+ ```
67
+
68
+ Profiles are stored outside the repository:
69
+
70
+ ```text
71
+ ~/.project-mcp/workspaces/<sha256-realpath>.json
72
+ ```
73
+
74
+ The token is fixed per device and project root until you rotate it.
75
+
76
+ ## Tools
77
+
78
+ Project MCP exposes:
79
+
80
+ - `server_config`
81
+ - `workspace_info`
82
+ - `tree`
83
+ - `search`
84
+ - `read_file`
85
+ - `write_file`
86
+ - `edit_file`
87
+ - `show_changes`
88
+ - `run_check`
89
+
90
+ `--read-only` hides `write_file` and `edit_file`. `--no-bash` hides `run_check`.
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ uv sync
96
+ uv run ruff check .
97
+ uv run pytest
98
+ uv build
99
+ uv publish
100
+ ```
@@ -0,0 +1,12 @@
1
+ project_mcp/__init__.py,sha256=XQWQ1z0PB7abTrG_1VYq7fRlJm0zFDXwg7TreJOX24k,77
2
+ project_mcp/app.py,sha256=bUNDk7l7yK9OtzDYZzF5PdtoITe-NlvjAivoHJ5cpw0,4815
3
+ project_mcp/cli.py,sha256=ea93hIs-PNMgCRZhEY3--mYtoaPQBnDv9YWhfKFB6-o,9570
4
+ project_mcp/config.py,sha256=i70Y_-BRNNXFSSIrXbF6Vt1e-MFmKYC8PkiblY74SKE,2333
5
+ project_mcp/mcp_server.py,sha256=XmjsbPrO7vD5yVudU1oiWIb5n5IpLKJEK_KmrD52cjA,4360
6
+ project_mcp/profiles.py,sha256=RuyXOhtHj0qycYI6IiBdhMcSK4yQFQAAxYlbvhCdb1U,3001
7
+ project_mcp/security.py,sha256=87QDhFK-qGZiSsSH4ufIWv5r6TFPqMdUqOxdTKx6XiA,3395
8
+ project_mcp/workspace.py,sha256=LiM_PaeLiEAcgClzRcJuz-3InkQbbjvf00saWN3B1aA,13698
9
+ project_mcp-0.1.0.dist-info/WHEEL,sha256=8ZlpUMJ7mlDirmlHRhDirEx_nPnARrwDjeE92mlk68E,81
10
+ project_mcp-0.1.0.dist-info/entry_points.txt,sha256=Xs0NDw7irn5vTIifA69kyT6wRg-WVoxZkjevk2b4L2g,53
11
+ project_mcp-0.1.0.dist-info/METADATA,sha256=aWIw5_vm0immRQ5Az-Y4Q25kWoqIqGb-Q67SnZF-kqY,1938
12
+ project_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.21
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ project-mcp = project_mcp.cli:app
3
+