patdev commited on
Commit
91d2e72
·
verified ·
1 Parent(s): 0629dc1

Update mcp-bridge

Browse files
Files changed (7) hide show
  1. .gitignore +0 -5
  2. Dockerfile +2 -4
  3. README.md +17 -70
  4. app.py +41 -201
  5. requirements.txt +3 -3
  6. scripts/sync_hf.ps1 +3 -9
  7. scripts/sync_hf.py +15 -36
.gitignore CHANGED
@@ -1,10 +1,5 @@
1
  .env
2
- .venv/
3
- venv/
4
  __pycache__/
5
  *.pyc
6
- *.pyo
7
- *.pyd
8
- .DS_Store
9
  .hf/
10
  *.zip
 
1
  .env
 
 
2
  __pycache__/
3
  *.pyc
 
 
 
4
  .hf/
5
  *.zip
Dockerfile CHANGED
@@ -1,11 +1,9 @@
1
  FROM python:3.11-slim
2
 
3
- ENV PYTHONDONTWRITEBYTECODE=1 \
4
- PYTHONUNBUFFERED=1 \
5
- PORT=7860
6
 
7
  WORKDIR /app
8
-
9
  COPY requirements.txt .
10
  RUN pip install --no-cache-dir -r requirements.txt
11
 
 
1
  FROM python:3.11-slim
2
 
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
 
5
 
6
  WORKDIR /app
 
7
  COPY requirements.txt .
8
  RUN pip install --no-cache-dir -r requirements.txt
9
 
README.md CHANGED
@@ -1,97 +1,44 @@
1
  ---
2
- title: MCP Bridge
3
- emoji: 🌉
4
- colorFrom: indigo
5
- colorTo: blue
6
  sdk: docker
7
  app_port: 7860
8
- pinned: false
9
  ---
10
 
11
- # MCP Bridge
12
 
13
- Generic MCP bridge for Hugging Face Spaces.
14
 
15
- It exposes a static MCP SSE endpoint:
16
 
17
- ```txt
18
- https://patdev-mcp-bridge.hf.space/sse
19
- ```
20
-
21
- Tools exposed by this bridge:
22
-
23
- - `mcp_connect`
24
- - `mcp_tool_list`
25
- - `mcp_tool_call`
26
- - `mcp_disconnect`
27
- - `mcp_session_list`
28
 
29
- ## Authentication
30
 
31
- Set this Space Secret in Hugging Face:
32
-
33
- ```txt
34
- MCP_API_KEY=your-long-random-secret
35
- ```
36
 
37
- Clients must send either:
38
 
39
  ```txt
40
- Authorization: Bearer your-long-random-secret
41
  ```
42
 
43
  or:
44
 
45
  ```txt
46
- x-api-key: your-long-random-secret
47
- ```
48
-
49
- `/` and `/health` are public. `/sse` and `/messages` require the API key.
50
-
51
- ## Deploy / sync
52
-
53
- Recommended cross-platform command:
54
-
55
- ```bash
56
- python scripts/sync_hf.py --space-id patdev/mcp-bridge --api-key "your-long-random-secret" --restart
57
- ```
58
-
59
- This creates the Space if needed, uploads the project, optionally sets `MCP_API_KEY`, and can restart the Space.
60
-
61
- Linux/macOS wrapper:
62
-
63
- ```bash
64
- chmod +x scripts/sync_hf.sh
65
- ./scripts/sync_hf.sh --api-key "your-long-random-secret" --restart
66
- ```
67
-
68
- Windows PowerShell wrapper:
69
-
70
- ```powershell
71
- powershell -ExecutionPolicy Bypass -File .\scripts\sync_hf.ps1 -ApiKey "your-long-random-secret" -Restart
72
  ```
73
 
74
- The `-ExecutionPolicy Bypass` part only applies to this process; it does not change your permanent Windows policy.
75
-
76
- ## Local run
77
-
78
- ```bash
79
- export MCP_API_KEY="dev-secret"
80
- python -m uvicorn app:app --host 0.0.0.0 --port 7860
81
- ```
82
-
83
- Then use:
84
 
85
  ```txt
86
- http://localhost:7860/sse
87
  ```
88
 
89
- ## Test client
90
 
91
- ```bash
92
- python test_client.py --url http://localhost:7860/sse --api-key dev-secret
93
  ```
94
 
95
- ## Notes
96
-
97
- Sessions are stored in memory. If the Space sleeps or restarts, run `mcp_connect` again.
 
1
  ---
2
+ title: mcp-bridge
 
 
 
3
  sdk: docker
4
  app_port: 7860
 
5
  ---
6
 
7
+ # mcp-bridge
8
 
9
+ A small authenticated MCP-over-SSE bridge for Hugging Face Spaces.
10
 
11
+ Public endpoints:
12
 
13
+ - `/` returns only `{"ok": true}`
14
+ - `/health` returns only `{"ok": true}`
 
 
 
 
 
 
 
 
 
15
 
16
+ MCP endpoint:
17
 
18
+ - `https://patdev-mcp-bridge.hf.space/sse`
 
 
 
 
19
 
20
+ Required auth for MCP routes:
21
 
22
  ```txt
23
+ Authorization: Bearer <MCP_API_KEY>
24
  ```
25
 
26
  or:
27
 
28
  ```txt
29
+ x-api-key: <MCP_API_KEY>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  ```
31
 
32
+ Set the Space secret:
 
 
 
 
 
 
 
 
 
33
 
34
  ```txt
35
+ MCP_API_KEY=<your-long-random-secret>
36
  ```
37
 
38
+ ## Sync
39
 
40
+ ```powershell
41
+ python .\scripts\sync_hf.py --space-id patdev/mcp-bridge --api-key "YOUR_LONG_RANDOM_KEY" --restart
42
  ```
43
 
44
+ If you accidentally used a placeholder key, rotate it immediately by rerunning the command with a new random value.
 
 
app.py CHANGED
@@ -1,162 +1,58 @@
1
- import json
2
  import os
3
- import secrets
4
  import uuid
5
  from typing import Any, Dict, Optional
6
- from urllib.parse import urlparse
7
 
8
  from fastmcp import Client, FastMCP
 
9
  from starlette.requests import Request
10
- from starlette.responses import JSONResponse, PlainTextResponse
11
 
12
- APP_NAME = "mcp-bridge"
13
- DEFAULT_TRANSPORT = "sse"
14
 
 
15
  SESSIONS: Dict[str, Dict[str, Any]] = {}
16
 
17
 
18
- def _configured_api_key() -> Optional[str]:
19
- key = os.getenv("MCP_API_KEY", "").strip()
20
- return key or None
 
21
 
22
 
23
- def _allowed_hosts() -> set[str]:
24
- raw = os.getenv("ALLOWED_MCP_HOSTS", "").strip()
25
- return {h.strip().lower() for h in raw.split(",") if h.strip()}
26
 
 
 
 
27
 
28
- def _check_remote_url(url: str) -> None:
29
- parsed = urlparse(url)
30
- if parsed.scheme not in {"http", "https"}:
31
- raise ValueError("Remote MCP URL must start with http:// or https://")
32
 
33
- allowed = _allowed_hosts()
34
- if allowed and (parsed.hostname or "").lower() not in allowed:
35
- raise ValueError(
36
- f"Remote MCP host '{parsed.hostname}' is not in ALLOWED_MCP_HOSTS"
37
- )
38
 
 
 
39
 
40
- def _safe_headers(headers: Optional[Dict[str, str]]) -> Dict[str, str]:
41
- if not headers:
42
- return {}
43
- return {str(k): str(v) for k, v in headers.items()}
44
 
45
-
46
- def _jsonable(value: Any) -> Any:
47
- """Convert FastMCP/Pydantic objects into JSON-friendly values."""
48
- if hasattr(value, "model_dump"):
49
- try:
50
- return value.model_dump(mode="json")
51
- except TypeError:
52
- return value.model_dump()
53
- if isinstance(value, dict):
54
- return {str(k): _jsonable(v) for k, v in value.items()}
55
- if isinstance(value, (list, tuple, set)):
56
- return [_jsonable(v) for v in value]
57
- try:
58
- json.dumps(value)
59
- return value
60
- except TypeError:
61
- return str(value)
62
-
63
-
64
- class APIKeyMiddleware:
65
- """
66
- Minimal API-key middleware for the MCP endpoint.
67
-
68
- Public:
69
- - GET /
70
- - GET /health
71
-
72
- Protected:
73
- - /sse
74
- - /messages
75
- - any other route accidentally added later
76
- """
77
-
78
- def __init__(self, app):
79
- self.app = app
80
-
81
- async def __call__(self, scope, receive, send):
82
- if scope.get("type") != "http":
83
- await self.app(scope, receive, send)
84
- return
85
-
86
- path = scope.get("path", "") or ""
87
- method = scope.get("method", "") or ""
88
-
89
- if path in {"/", "/health"} and method in {"GET", "HEAD"}:
90
- await self.app(scope, receive, send)
91
- return
92
-
93
- expected_key = _configured_api_key()
94
- if not expected_key:
95
- response = JSONResponse(
96
- {
97
- "ok": False,
98
- "error": "MCP_API_KEY is not configured on this Space.",
99
- },
100
- status_code=503,
101
- )
102
- await response(scope, receive, send)
103
- return
104
-
105
- raw_headers = dict(scope.get("headers") or [])
106
- auth_header = raw_headers.get(b"authorization", b"").decode("utf-8", "ignore")
107
- x_api_key = raw_headers.get(b"x-api-key", b"").decode("utf-8", "ignore")
108
-
109
- bearer = ""
110
- if auth_header.lower().startswith("bearer "):
111
- bearer = auth_header[7:].strip()
112
-
113
- valid = secrets.compare_digest(x_api_key, expected_key) or secrets.compare_digest(
114
- bearer, expected_key
115
- )
116
-
117
- if not valid:
118
- response = JSONResponse(
119
- {
120
- "ok": False,
121
- "error": "Unauthorized. Send Authorization: Bearer <MCP_API_KEY> or x-api-key: <MCP_API_KEY>.",
122
- },
123
- status_code=401,
124
- )
125
- await response(scope, receive, send)
126
- return
127
-
128
- await self.app(scope, receive, send)
129
-
130
-
131
- mcp = FastMCP(APP_NAME)
132
 
133
 
134
  @mcp.tool()
135
  async def mcp_connect(url: str, headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
136
- """
137
- Register a remote MCP HTTP/SSE server URL and return a session_id.
138
- """
139
- _check_remote_url(url)
140
-
141
  session_id = str(uuid.uuid4())
142
- SESSIONS[session_id] = {
143
- "url": url,
144
- "headers": _safe_headers(headers),
145
- }
146
-
147
- return {
148
- "ok": True,
149
- "session_id": session_id,
150
- "url": url,
151
- "status": "connected_registered",
152
- }
153
 
154
 
155
  @mcp.tool()
156
  async def mcp_tool_list(session_id: str) -> Dict[str, Any]:
157
- """
158
- List tools from a registered remote MCP server.
159
- """
160
  cfg = SESSIONS.get(session_id)
161
  if not cfg:
162
  raise ValueError("Unknown session_id")
@@ -165,106 +61,50 @@ async def mcp_tool_list(session_id: str) -> Dict[str, Any]:
165
  tools = await client.list_tools()
166
 
167
  return {
168
- "ok": True,
169
  "session_id": session_id,
170
- "tools": _jsonable(tools),
171
  }
172
 
173
 
174
  @mcp.tool()
175
- async def mcp_tool_call(
176
- session_id: str,
177
- tool_name: str,
178
- arguments: Optional[Dict[str, Any]] = None,
179
- ) -> Dict[str, Any]:
180
- """
181
- Call a tool on a registered remote MCP server.
182
- """
183
  cfg = SESSIONS.get(session_id)
184
  if not cfg:
185
  raise ValueError("Unknown session_id")
186
 
187
  async with Client(cfg["url"], headers=cfg["headers"]) as client:
188
- result = await client.call_tool(tool_name, arguments or {})
189
 
190
  return {
191
- "ok": True,
192
  "session_id": session_id,
193
  "tool_name": tool_name,
194
- "result": _jsonable(result),
195
  }
196
 
197
 
198
  @mcp.tool()
199
  async def mcp_disconnect(session_id: str) -> Dict[str, Any]:
200
- """
201
- Remove a registered remote MCP session from memory.
202
- """
203
  existed = session_id in SESSIONS
204
  SESSIONS.pop(session_id, None)
205
- return {
206
- "ok": True,
207
- "session_id": session_id,
208
- "removed": existed,
209
- }
210
 
211
 
212
  @mcp.tool()
213
  async def mcp_session_list() -> Dict[str, Any]:
214
- """
215
- List registered remote MCP sessions without revealing headers.
216
- """
217
  return {
218
- "ok": True,
219
  "count": len(SESSIONS),
220
  "sessions": [
221
- {
222
- "session_id": session_id,
223
- "url": cfg["url"],
224
- "has_headers": bool(cfg.get("headers")),
225
- }
226
- for session_id, cfg in SESSIONS.items()
227
  ],
228
  }
229
 
230
 
231
- async def root(request: Request) -> JSONResponse:
232
- base_url = str(request.base_url).rstrip("/")
233
- return JSONResponse(
234
- {
235
- "ok": True,
236
- "name": APP_NAME,
237
- "transport": os.getenv("MCP_TRANSPORT", DEFAULT_TRANSPORT),
238
- "mcp_sse_url": f"{base_url}/sse",
239
- "auth_configured": bool(_configured_api_key()),
240
- "auth_header": "Authorization: Bearer <MCP_API_KEY>",
241
- "tools": [
242
- "mcp_connect",
243
- "mcp_tool_list",
244
- "mcp_tool_call",
245
- "mcp_disconnect",
246
- "mcp_session_list",
247
- ],
248
- }
249
- )
250
-
251
-
252
- async def health(request: Request) -> JSONResponse:
253
- return JSONResponse(
254
- {
255
- "ok": True,
256
- "auth_configured": bool(_configured_api_key()),
257
- "sessions": len(SESSIONS),
258
- }
259
- )
260
-
261
-
262
- transport = os.getenv("MCP_TRANSPORT", DEFAULT_TRANSPORT).strip() or DEFAULT_TRANSPORT
263
-
264
- # FastMCP returns a Starlette app, not a FastAPI app. Therefore use add_route()
265
- # instead of @app.get(...). This fixes:
266
- # AttributeError: 'StarletteWithLifespan' object has no attribute 'get'
267
- app = mcp.http_app(path="/sse", transport=transport)
268
- app.add_route("/", root, methods=["GET", "HEAD"])
269
- app.add_route("/health", health, methods=["GET", "HEAD"])
270
  app.add_middleware(APIKeyMiddleware)
 
 
 
 
1
  import os
 
2
  import uuid
3
  from typing import Any, Dict, Optional
 
4
 
5
  from fastmcp import Client, FastMCP
6
+ from starlette.middleware.base import BaseHTTPMiddleware
7
  from starlette.requests import Request
8
+ from starlette.responses import JSONResponse
9
 
 
 
10
 
11
+ mcp = FastMCP("mcp-bridge")
12
  SESSIONS: Dict[str, Dict[str, Any]] = {}
13
 
14
 
15
+ def _public_ok(request: Request) -> JSONResponse:
16
+ # Keep public routes intentionally boring: do not reveal tools, auth config,
17
+ # internal URLs, package versions, or environment details.
18
+ return JSONResponse({"ok": True})
19
 
20
 
21
+ class APIKeyMiddleware(BaseHTTPMiddleware):
22
+ async def dispatch(self, request: Request, call_next):
23
+ path = request.url.path.rstrip("/") or "/"
24
 
25
+ # Minimal public endpoints for liveness checks only.
26
+ if path in {"/", "/health"}:
27
+ return await call_next(request)
28
 
29
+ expected = os.getenv("MCP_API_KEY", "").strip()
30
+ if not expected:
31
+ return JSONResponse({"error": "server_auth_not_configured"}, status_code=503)
 
32
 
33
+ auth = request.headers.get("authorization", "")
34
+ x_api_key = request.headers.get("x-api-key", "")
 
 
 
35
 
36
+ bearer_ok = auth == f"Bearer {expected}"
37
+ key_ok = x_api_key == expected
38
 
39
+ if not (bearer_ok or key_ok):
40
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
 
 
41
 
42
+ return await call_next(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
 
45
  @mcp.tool()
46
  async def mcp_connect(url: str, headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
47
+ """Register a remote MCP HTTP/SSE server URL and return a session_id."""
 
 
 
 
48
  session_id = str(uuid.uuid4())
49
+ SESSIONS[session_id] = {"url": url, "headers": headers or {}}
50
+ return {"session_id": session_id, "status": "connected_registered"}
 
 
 
 
 
 
 
 
 
51
 
52
 
53
  @mcp.tool()
54
  async def mcp_tool_list(session_id: str) -> Dict[str, Any]:
55
+ """List tools from a registered remote MCP server."""
 
 
56
  cfg = SESSIONS.get(session_id)
57
  if not cfg:
58
  raise ValueError("Unknown session_id")
 
61
  tools = await client.list_tools()
62
 
63
  return {
 
64
  "session_id": session_id,
65
+ "tools": [tool.model_dump() if hasattr(tool, "model_dump") else str(tool) for tool in tools],
66
  }
67
 
68
 
69
  @mcp.tool()
70
+ async def mcp_tool_call(session_id: str, tool_name: str, arguments: Dict[str, Any] = {}) -> Dict[str, Any]:
71
+ """Call a tool on a registered remote MCP server."""
 
 
 
 
 
 
72
  cfg = SESSIONS.get(session_id)
73
  if not cfg:
74
  raise ValueError("Unknown session_id")
75
 
76
  async with Client(cfg["url"], headers=cfg["headers"]) as client:
77
+ result = await client.call_tool(tool_name, arguments)
78
 
79
  return {
 
80
  "session_id": session_id,
81
  "tool_name": tool_name,
82
+ "result": result.model_dump() if hasattr(result, "model_dump") else str(result),
83
  }
84
 
85
 
86
  @mcp.tool()
87
  async def mcp_disconnect(session_id: str) -> Dict[str, Any]:
88
+ """Forget a registered remote MCP session."""
 
 
89
  existed = session_id in SESSIONS
90
  SESSIONS.pop(session_id, None)
91
+ return {"session_id": session_id, "disconnected": existed}
 
 
 
 
92
 
93
 
94
  @mcp.tool()
95
  async def mcp_session_list() -> Dict[str, Any]:
96
+ """List registered session IDs without exposing headers."""
 
 
97
  return {
 
98
  "count": len(SESSIONS),
99
  "sessions": [
100
+ {"session_id": sid, "url": cfg.get("url")}
101
+ for sid, cfg in SESSIONS.items()
 
 
 
 
102
  ],
103
  }
104
 
105
 
106
+ # FastMCP returns a Starlette app. Add public routes with Starlette APIs only.
107
+ app = mcp.http_app(path="/sse")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  app.add_middleware(APIKeyMiddleware)
109
+ app.add_route("/", _public_ok, methods=["GET", "HEAD"])
110
+ app.add_route("/health", _public_ok, methods=["GET", "HEAD"])
requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
- fastmcp>=2.14.0,<3.0.0
2
- uvicorn[standard]>=0.30.0
3
- starlette>=0.36.0
4
  httpx>=0.27.0
5
  pydantic>=2.0.0
 
 
6
  huggingface_hub>=0.23.0
 
1
+ fastmcp>=2.0.0
 
 
2
  httpx>=0.27.0
3
  pydantic>=2.0.0
4
+ uvicorn[standard]>=0.30.0
5
+ starlette>=0.37.0
6
  huggingface_hub>=0.23.0
scripts/sync_hf.ps1 CHANGED
@@ -1,17 +1,11 @@
1
  param(
2
  [string]$SpaceId = "patdev/mcp-bridge",
3
- [string]$ApiKey = $env:MCP_API_KEY,
4
- [switch]$Private,
5
  [switch]$Restart
6
  )
7
 
8
  $ErrorActionPreference = "Stop"
9
- $ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
10
- Set-Location $ProjectRoot
11
-
12
- $argsList = @("scripts/sync_hf.py", "--space-id", $SpaceId)
13
- if ($ApiKey) { $argsList += @("--api-key", $ApiKey) }
14
- if ($Private) { $argsList += "--private" }
15
  if ($Restart) { $argsList += "--restart" }
16
-
17
  python @argsList
 
1
  param(
2
  [string]$SpaceId = "patdev/mcp-bridge",
3
+ [string]$ApiKey = "",
 
4
  [switch]$Restart
5
  )
6
 
7
  $ErrorActionPreference = "Stop"
8
+ $argsList = @("$PSScriptRoot\sync_hf.py", "--space-id", $SpaceId)
9
+ if ($ApiKey -ne "") { $argsList += @("--api-key", $ApiKey) }
 
 
 
 
10
  if ($Restart) { $argsList += "--restart" }
 
11
  python @argsList
scripts/sync_hf.py CHANGED
@@ -1,71 +1,50 @@
1
- #!/usr/bin/env python3
2
  import argparse
3
  import os
4
  from pathlib import Path
5
 
6
- from huggingface_hub import HfApi, whoami
7
 
8
 
9
  def main() -> None:
10
- parser = argparse.ArgumentParser(description="Create/sync the Hugging Face Docker Space.")
11
- parser.add_argument("--space-id", default=os.getenv("SPACE_ID", "patdev/mcp-bridge"))
12
- parser.add_argument("--private", action="store_true", help="Create Space as private if it does not exist.")
13
- parser.add_argument("--api-key", default=os.getenv("MCP_API_KEY"), help="Optional: set/update Space Secret MCP_API_KEY.")
14
- parser.add_argument("--restart", action="store_true", help="Restart the Space after upload.")
15
  args = parser.parse_args()
16
 
17
- repo_root = Path(__file__).resolve().parents[1]
18
  api = HfApi()
19
 
20
- user = whoami()
21
- print(f"Authenticated as: {user.get('name') or user.get('email') or user}")
22
  print(f"Space ID: {args.space_id}")
23
 
24
  print("Creating Space if needed...")
25
- api.create_repo(
26
- repo_id=args.space_id,
27
- repo_type="space",
28
- space_sdk="docker",
29
- private=args.private,
30
- exist_ok=True,
31
- )
32
 
33
  if args.api_key:
34
  print("Setting Space Secret MCP_API_KEY...")
35
- api.add_space_secret(args.space_id, key="MCP_API_KEY", value=args.api_key)
36
 
37
  print("Uploading project files...")
38
  commit = api.upload_folder(
39
  repo_id=args.space_id,
40
  repo_type="space",
41
- folder_path=str(repo_root),
42
- commit_message="Sync MCP Bridge",
43
- ignore_patterns=[
44
- ".git/*",
45
- ".hf/*",
46
- ".env",
47
- ".venv/*",
48
- "venv/*",
49
- "__pycache__/*",
50
- "*.pyc",
51
- "*.pyo",
52
- "*.pyd",
53
- ".DS_Store",
54
- "*.zip",
55
- ],
56
  )
57
  print(f"Uploaded: {commit.commit_url}")
58
 
59
  if args.restart:
60
  print("Restarting Space...")
61
- api.restart_space(args.space_id)
62
 
63
  owner, name = args.space_id.split("/", 1)
64
  print("\nDone.")
65
  print(f"Space: https://huggingface.co/spaces/{args.space_id}")
66
  print(f"MCP SSE: https://{owner}-{name}.hf.space/sse")
67
- if not args.api_key:
68
- print("\nReminder: set a Space Secret MCP_API_KEY, or /sse will return 503.")
69
 
70
 
71
  if __name__ == "__main__":
 
 
1
  import argparse
2
  import os
3
  from pathlib import Path
4
 
5
+ from huggingface_hub import HfApi, create_repo, whoami
6
 
7
 
8
  def main() -> None:
9
+ parser = argparse.ArgumentParser(description="Sync mcp-bridge to a Hugging Face Docker Space")
10
+ parser.add_argument("--space-id", default="patdev/mcp-bridge", help="HF Space ID, e.g. patdev/mcp-bridge")
11
+ parser.add_argument("--api-key", default=None, help="Value for Space secret MCP_API_KEY")
12
+ parser.add_argument("--restart", action="store_true", help="Restart the Space after upload")
 
13
  args = parser.parse_args()
14
 
15
+ root = Path(__file__).resolve().parents[1]
16
  api = HfApi()
17
 
18
+ user = whoami().get("name")
19
+ print(f"Authenticated as: {user}")
20
  print(f"Space ID: {args.space_id}")
21
 
22
  print("Creating Space if needed...")
23
+ create_repo(args.space_id, repo_type="space", space_sdk="docker", exist_ok=True)
 
 
 
 
 
 
24
 
25
  if args.api_key:
26
  print("Setting Space Secret MCP_API_KEY...")
27
+ api.add_space_secret(repo_id=args.space_id, key="MCP_API_KEY", value=args.api_key)
28
 
29
  print("Uploading project files...")
30
  commit = api.upload_folder(
31
  repo_id=args.space_id,
32
  repo_type="space",
33
+ folder_path=str(root),
34
+ path_in_repo=".",
35
+ ignore_patterns=[".git/*", ".hf/*", "__pycache__/*", "*.pyc", ".env", "*.zip"],
36
+ commit_message="Update mcp-bridge",
 
 
 
 
 
 
 
 
 
 
 
37
  )
38
  print(f"Uploaded: {commit.commit_url}")
39
 
40
  if args.restart:
41
  print("Restarting Space...")
42
+ api.restart_space(repo_id=args.space_id)
43
 
44
  owner, name = args.space_id.split("/", 1)
45
  print("\nDone.")
46
  print(f"Space: https://huggingface.co/spaces/{args.space_id}")
47
  print(f"MCP SSE: https://{owner}-{name}.hf.space/sse")
 
 
48
 
49
 
50
  if __name__ == "__main__":