project-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,87 @@
1
+ # Project MCP
2
+
3
+ Expose one local project as a token-protected MCP server for ChatGPT.
4
+
5
+ Project MCP is intentionally small:
6
+
7
+ - one local project root
8
+ - one fixed project token
9
+ - one Streamable HTTP MCP endpoint at `/mcp`
10
+ - Cloudflare quick tunnel for public HTTPS access
11
+ - no login, no hosted relay, no ChatGPT widget UI
12
+
13
+ ## Quick Start
14
+
15
+ Run without installing:
16
+
17
+ ```bash
18
+ uvx project-mcp setup --root .
19
+ uvx project-mcp start
20
+ ```
21
+
22
+ Or install as a persistent tool:
23
+
24
+ ```bash
25
+ uv tool install project-mcp
26
+ project-mcp setup --root .
27
+ project-mcp start
28
+ ```
29
+
30
+ `start` prints a ChatGPT Server URL like:
31
+
32
+ ```text
33
+ https://example.trycloudflare.com/mcp?project_mcp_token=pmcp_...
34
+ ```
35
+
36
+ In ChatGPT Developer Mode, create an app/connector with:
37
+
38
+ ```text
39
+ Connection: Server URL
40
+ Authentication: None / No Authentication
41
+ ```
42
+
43
+ ## Commands
44
+
45
+ ```bash
46
+ project-mcp setup --root . --port 8080
47
+ project-mcp start --root .
48
+ project-mcp start --read-only
49
+ project-mcp start --no-bash
50
+ project-mcp doctor
51
+ project-mcp token rotate
52
+ project-mcp settings show
53
+ ```
54
+
55
+ Profiles are stored outside the repository:
56
+
57
+ ```text
58
+ ~/.project-mcp/workspaces/<sha256-realpath>.json
59
+ ```
60
+
61
+ The token is fixed per device and project root until you rotate it.
62
+
63
+ ## Tools
64
+
65
+ Project MCP exposes:
66
+
67
+ - `server_config`
68
+ - `workspace_info`
69
+ - `tree`
70
+ - `search`
71
+ - `read_file`
72
+ - `write_file`
73
+ - `edit_file`
74
+ - `show_changes`
75
+ - `run_check`
76
+
77
+ `--read-only` hides `write_file` and `edit_file`. `--no-bash` hides `run_check`.
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ uv sync
83
+ uv run ruff check .
84
+ uv run pytest
85
+ uv build
86
+ uv publish
87
+ ```
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "project-mcp"
3
+ version = "0.1.0"
4
+ description = "Expose one local project as a token-protected MCP server for ChatGPT."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "fastapi>=0.138.1",
9
+ "mcp[cli]>=1.28,<2",
10
+ "pydantic-settings>=2.14.2",
11
+ "rich>=15.0.0",
12
+ "typer>=0.26.8",
13
+ "uvicorn[standard]>=0.49.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ project-mcp = "project_mcp.cli:app"
18
+
19
+ [tool.ruff]
20
+ line-length = 100
21
+ target-version = "py310"
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+ asyncio_mode = "auto"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.11.21,<0.12.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "httpx>=0.28.1",
34
+ "pytest>=9.1.1",
35
+ "pytest-asyncio>=1.4.0",
36
+ "ruff>=0.15.20",
37
+ ]
@@ -0,0 +1,5 @@
1
+ """Project MCP package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -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()
@@ -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()