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.
- project_mcp-0.1.0/PKG-INFO +100 -0
- project_mcp-0.1.0/README.md +87 -0
- project_mcp-0.1.0/pyproject.toml +37 -0
- project_mcp-0.1.0/src/project_mcp/__init__.py +5 -0
- project_mcp-0.1.0/src/project_mcp/app.py +136 -0
- project_mcp-0.1.0/src/project_mcp/cli.py +258 -0
- project_mcp-0.1.0/src/project_mcp/config.py +95 -0
- project_mcp-0.1.0/src/project_mcp/mcp_server.py +122 -0
- project_mcp-0.1.0/src/project_mcp/profiles.py +106 -0
- project_mcp-0.1.0/src/project_mcp/security.py +96 -0
- project_mcp-0.1.0/src/project_mcp/workspace.py +392 -0
|
@@ -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,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()
|