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.
- project_mcp/__init__.py +5 -0
- project_mcp/app.py +136 -0
- project_mcp/cli.py +258 -0
- project_mcp/config.py +95 -0
- project_mcp/mcp_server.py +122 -0
- project_mcp/profiles.py +106 -0
- project_mcp/security.py +96 -0
- project_mcp/workspace.py +392 -0
- project_mcp-0.1.0.dist-info/METADATA +100 -0
- project_mcp-0.1.0.dist-info/RECORD +12 -0
- project_mcp-0.1.0.dist-info/WHEEL +4 -0
- project_mcp-0.1.0.dist-info/entry_points.txt +3 -0
project_mcp/__init__.py
ADDED
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
|
project_mcp/profiles.py
ADDED
|
@@ -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
|
project_mcp/security.py
ADDED
|
@@ -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
|
project_mcp/workspace.py
ADDED
|
@@ -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,,
|