browse-mcp-proxy 0.1.1__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.
- browse_mcp_proxy/__init__.py +3 -0
- browse_mcp_proxy/__main__.py +115 -0
- browse_mcp_proxy/proxy.py +650 -0
- browse_mcp_proxy-0.1.1.dist-info/METADATA +228 -0
- browse_mcp_proxy-0.1.1.dist-info/RECORD +7 -0
- browse_mcp_proxy-0.1.1.dist-info/WHEEL +4 -0
- browse_mcp_proxy-0.1.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browse MCP Proxy - CLI Entry Point
|
|
3
|
+
|
|
4
|
+
A proxy server that enables browser-based MCP Inspector connections.
|
|
5
|
+
Bridges CORS, session management, and transport types.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import secrets
|
|
9
|
+
import sys
|
|
10
|
+
import webbrowser
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
import uvicorn
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
from .proxy import create_app
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="browse-mcp-proxy",
|
|
21
|
+
help="MCP Proxy Server for browser-based Inspector connections.",
|
|
22
|
+
add_completion=False,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def serve(
|
|
28
|
+
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
|
|
29
|
+
port: int = typer.Option(6277, "--port", "-p", help="Port to bind to"),
|
|
30
|
+
client_port: int = typer.Option(
|
|
31
|
+
6274, "--client-port", "-c", help="Client port for CORS"
|
|
32
|
+
),
|
|
33
|
+
auth_token: Optional[str] = typer.Option(
|
|
34
|
+
None, "--auth-token", "-t", envvar="MCP_PROXY_AUTH_TOKEN", help="Auth token"
|
|
35
|
+
),
|
|
36
|
+
no_auth: bool = typer.Option(
|
|
37
|
+
False, "--no-auth", help="Disable authentication (DANGEROUS)"
|
|
38
|
+
),
|
|
39
|
+
open_browser: bool = typer.Option(
|
|
40
|
+
False, "--open", "-o", help="Open browser with auth token"
|
|
41
|
+
),
|
|
42
|
+
log_level: str = typer.Option("info", "--log-level", "-l", help="Log level"),
|
|
43
|
+
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
|
|
44
|
+
):
|
|
45
|
+
"""
|
|
46
|
+
Start the MCP proxy server.
|
|
47
|
+
|
|
48
|
+
This proxy enables browser-based applications to connect to MCP servers
|
|
49
|
+
by handling CORS, session management, and transport type conversion.
|
|
50
|
+
|
|
51
|
+
Example usage:
|
|
52
|
+
browse-mcp-proxy serve --port 6277 --open
|
|
53
|
+
"""
|
|
54
|
+
# Configure logging
|
|
55
|
+
logger.remove()
|
|
56
|
+
logger.add(
|
|
57
|
+
sys.stderr,
|
|
58
|
+
level=log_level.upper(),
|
|
59
|
+
format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan> - <level>{message}</level>",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Generate or use provided auth token
|
|
63
|
+
token = "" if no_auth else (auth_token or secrets.token_hex(32))
|
|
64
|
+
if no_auth:
|
|
65
|
+
logger.warning("Authentication is DISABLED - this is not recommended!")
|
|
66
|
+
|
|
67
|
+
# Create FastAPI app
|
|
68
|
+
fastapi_app = create_app(
|
|
69
|
+
client_port=client_port,
|
|
70
|
+
auth_token=token,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Log startup info
|
|
74
|
+
logger.info(f"Starting MCP Proxy Server on http://{host}:{port}")
|
|
75
|
+
if token:
|
|
76
|
+
logger.info(f"Auth token: {token}")
|
|
77
|
+
|
|
78
|
+
# Open browser if requested
|
|
79
|
+
if open_browser and token:
|
|
80
|
+
browser_url = f"http://localhost:{client_port}/?MCP_PROXY_AUTH_TOKEN={token}&MCP_PROXY_ADDRESS=http://localhost:{port}"
|
|
81
|
+
logger.info(f"Opening browser: {browser_url}")
|
|
82
|
+
webbrowser.open(browser_url)
|
|
83
|
+
|
|
84
|
+
# Run server
|
|
85
|
+
uvicorn.run(
|
|
86
|
+
fastapi_app,
|
|
87
|
+
host=host,
|
|
88
|
+
port=port,
|
|
89
|
+
log_level=log_level.lower(),
|
|
90
|
+
reload=reload,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def token():
|
|
96
|
+
"""Generate a new authentication token."""
|
|
97
|
+
new_token = secrets.token_hex(32)
|
|
98
|
+
print(new_token)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command()
|
|
102
|
+
def version():
|
|
103
|
+
"""Show version information."""
|
|
104
|
+
from . import __version__
|
|
105
|
+
|
|
106
|
+
print(f"browse-mcp-proxy version {__version__}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main():
|
|
110
|
+
"""CLI entry point."""
|
|
111
|
+
app()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Proxy Server
|
|
3
|
+
|
|
4
|
+
This module implements a proxy server that bridges browser clients with MCP servers.
|
|
5
|
+
It handles:
|
|
6
|
+
- CORS for browser access
|
|
7
|
+
- Session ID management
|
|
8
|
+
- Transport type conversion (STDIO, SSE, HTTP)
|
|
9
|
+
- Authentication token management
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import subprocess
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Literal, Optional
|
|
19
|
+
from uuid import uuid4
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
from fastapi import FastAPI, HTTPException, Query, Request, Response
|
|
23
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
24
|
+
from loguru import logger
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
from sse_starlette.sse import EventSourceResponse
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# Types and Models
|
|
31
|
+
# =============================================================================
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProxyConfig(BaseModel):
|
|
35
|
+
"""Configuration for the proxy server."""
|
|
36
|
+
|
|
37
|
+
host: str = "127.0.0.1"
|
|
38
|
+
port: int = 6277
|
|
39
|
+
client_port: int = 6274
|
|
40
|
+
auth_token: Optional[str] = None
|
|
41
|
+
allow_origins: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConnectRequest(BaseModel):
|
|
45
|
+
"""Request body for /connect endpoint."""
|
|
46
|
+
|
|
47
|
+
url: str
|
|
48
|
+
transport_type: Literal["stdio", "sse", "streamable-http"] = "streamable-http"
|
|
49
|
+
command: Optional[str] = None
|
|
50
|
+
args: Optional[list[str]] = None
|
|
51
|
+
env: Optional[dict[str, str]] = None
|
|
52
|
+
headers: Optional[dict[str, str]] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Session:
|
|
57
|
+
"""Represents an active proxy session."""
|
|
58
|
+
|
|
59
|
+
id: str
|
|
60
|
+
transport_type: str
|
|
61
|
+
target_url: Optional[str] = None
|
|
62
|
+
process: Optional[subprocess.Popen] = None
|
|
63
|
+
client: Optional[httpx.AsyncClient] = None
|
|
64
|
+
server_session_id: Optional[str] = None
|
|
65
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# =============================================================================
|
|
69
|
+
# Session Manager
|
|
70
|
+
# =============================================================================
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SessionManager:
|
|
74
|
+
"""Manages proxy sessions between browser clients and MCP servers."""
|
|
75
|
+
|
|
76
|
+
def __init__(self):
|
|
77
|
+
self._sessions: dict[str, Session] = {}
|
|
78
|
+
self._lock = asyncio.Lock()
|
|
79
|
+
|
|
80
|
+
async def create_session(
|
|
81
|
+
self,
|
|
82
|
+
transport_type: str,
|
|
83
|
+
target_url: Optional[str] = None,
|
|
84
|
+
headers: Optional[dict[str, str]] = None,
|
|
85
|
+
) -> Session:
|
|
86
|
+
"""Create a new session."""
|
|
87
|
+
async with self._lock:
|
|
88
|
+
session_id = str(uuid4())
|
|
89
|
+
session = Session(
|
|
90
|
+
id=session_id,
|
|
91
|
+
transport_type=transport_type,
|
|
92
|
+
target_url=target_url,
|
|
93
|
+
headers=headers or {},
|
|
94
|
+
)
|
|
95
|
+
self._sessions[session_id] = session
|
|
96
|
+
logger.info(f"Created session {session_id} for {transport_type} transport")
|
|
97
|
+
return session
|
|
98
|
+
|
|
99
|
+
async def get_session(self, session_id: str) -> Optional[Session]:
|
|
100
|
+
"""Get a session by ID."""
|
|
101
|
+
return self._sessions.get(session_id)
|
|
102
|
+
|
|
103
|
+
async def delete_session(self, session_id: str) -> bool:
|
|
104
|
+
"""Delete a session and cleanup resources."""
|
|
105
|
+
async with self._lock:
|
|
106
|
+
session = self._sessions.pop(session_id, None)
|
|
107
|
+
if session:
|
|
108
|
+
# Cleanup process if STDIO
|
|
109
|
+
if session.process:
|
|
110
|
+
try:
|
|
111
|
+
session.process.terminate()
|
|
112
|
+
session.process.wait(timeout=5)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.warning(f"Error terminating process: {e}")
|
|
115
|
+
try:
|
|
116
|
+
session.process.kill()
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# Cleanup HTTP client
|
|
121
|
+
if session.client:
|
|
122
|
+
try:
|
|
123
|
+
await session.client.aclose()
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.warning(f"Error closing HTTP client: {e}")
|
|
126
|
+
|
|
127
|
+
logger.info(f"Deleted session {session_id}")
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
async def update_session(
|
|
132
|
+
self,
|
|
133
|
+
session_id: str,
|
|
134
|
+
server_session_id: Optional[str] = None,
|
|
135
|
+
headers: Optional[dict[str, str]] = None,
|
|
136
|
+
) -> Optional[Session]:
|
|
137
|
+
"""Update session properties."""
|
|
138
|
+
session = self._sessions.get(session_id)
|
|
139
|
+
if session:
|
|
140
|
+
if server_session_id is not None:
|
|
141
|
+
session.server_session_id = server_session_id
|
|
142
|
+
if headers is not None:
|
|
143
|
+
session.headers.update(headers)
|
|
144
|
+
return session
|
|
145
|
+
|
|
146
|
+
def list_sessions(self) -> list[dict[str, Any]]:
|
|
147
|
+
"""List all active sessions."""
|
|
148
|
+
return [
|
|
149
|
+
{
|
|
150
|
+
"id": s.id,
|
|
151
|
+
"transport_type": s.transport_type,
|
|
152
|
+
"target_url": s.target_url,
|
|
153
|
+
"has_process": s.process is not None,
|
|
154
|
+
"server_session_id": s.server_session_id,
|
|
155
|
+
}
|
|
156
|
+
for s in self._sessions.values()
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# =============================================================================
|
|
161
|
+
# Proxy Application
|
|
162
|
+
# =============================================================================
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# Global session manager
|
|
166
|
+
session_manager = SessionManager()
|
|
167
|
+
|
|
168
|
+
# Auth token (generated at startup or from env)
|
|
169
|
+
_auth_token: str = ""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_auth_token() -> str:
|
|
173
|
+
"""Get the current auth token."""
|
|
174
|
+
return _auth_token
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def set_auth_token(token: str):
|
|
178
|
+
"""Set the auth token."""
|
|
179
|
+
global _auth_token
|
|
180
|
+
_auth_token = token
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@asynccontextmanager
|
|
184
|
+
async def lifespan(app: FastAPI):
|
|
185
|
+
"""Application lifespan handler."""
|
|
186
|
+
logger.info("MCP Proxy server starting...")
|
|
187
|
+
yield
|
|
188
|
+
# Cleanup all sessions on shutdown
|
|
189
|
+
for session_id in list(session_manager._sessions.keys()):
|
|
190
|
+
await session_manager.delete_session(session_id)
|
|
191
|
+
logger.info("MCP Proxy server stopped")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def create_app(
|
|
195
|
+
client_port: int = 6274,
|
|
196
|
+
auth_token: Optional[str] = None,
|
|
197
|
+
allow_origins: Optional[list[str]] = None,
|
|
198
|
+
) -> FastAPI:
|
|
199
|
+
"""Create the FastAPI application."""
|
|
200
|
+
app = FastAPI(
|
|
201
|
+
title="MCP Proxy Server",
|
|
202
|
+
description="Proxy server for browser-based MCP Inspector connections",
|
|
203
|
+
version="0.1.0",
|
|
204
|
+
lifespan=lifespan,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Set auth token
|
|
208
|
+
token = auth_token or secrets.token_hex(32)
|
|
209
|
+
set_auth_token(token)
|
|
210
|
+
|
|
211
|
+
# Configure CORS
|
|
212
|
+
origins = allow_origins or [
|
|
213
|
+
f"http://localhost:{client_port}",
|
|
214
|
+
f"http://127.0.0.1:{client_port}",
|
|
215
|
+
"http://localhost:1420", # Tauri dev server
|
|
216
|
+
"http://127.0.0.1:1420",
|
|
217
|
+
"tauri://localhost", # Tauri production
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
app.add_middleware(
|
|
221
|
+
CORSMiddleware,
|
|
222
|
+
allow_origins=origins,
|
|
223
|
+
allow_credentials=True,
|
|
224
|
+
allow_methods=["*"],
|
|
225
|
+
allow_headers=["*"],
|
|
226
|
+
expose_headers=["mcp-session-id", "x-proxy-session-id"],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Register routes
|
|
230
|
+
register_routes(app)
|
|
231
|
+
|
|
232
|
+
return app
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def register_routes(app: FastAPI):
|
|
236
|
+
"""Register all API routes."""
|
|
237
|
+
|
|
238
|
+
@app.get("/health")
|
|
239
|
+
async def health_check():
|
|
240
|
+
"""Health check endpoint."""
|
|
241
|
+
return {"status": "ok"}
|
|
242
|
+
|
|
243
|
+
@app.get("/config")
|
|
244
|
+
async def get_config():
|
|
245
|
+
"""Get proxy configuration."""
|
|
246
|
+
return {
|
|
247
|
+
"auth_token": get_auth_token(),
|
|
248
|
+
"sessions": session_manager.list_sessions(),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
@app.get("/sessions")
|
|
252
|
+
async def list_sessions():
|
|
253
|
+
"""List all active proxy sessions."""
|
|
254
|
+
return {"sessions": session_manager.list_sessions()}
|
|
255
|
+
|
|
256
|
+
@app.delete("/sessions/{session_id}")
|
|
257
|
+
async def delete_session(session_id: str):
|
|
258
|
+
"""Delete a proxy session."""
|
|
259
|
+
if await session_manager.delete_session(session_id):
|
|
260
|
+
return {"status": "deleted", "session_id": session_id}
|
|
261
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
262
|
+
|
|
263
|
+
# =========================================================================
|
|
264
|
+
# STDIO Transport Proxy
|
|
265
|
+
# =========================================================================
|
|
266
|
+
|
|
267
|
+
@app.get("/stdio")
|
|
268
|
+
async def stdio_proxy(
|
|
269
|
+
request: Request,
|
|
270
|
+
command: str = Query(..., description="Command to execute"),
|
|
271
|
+
args: str = Query("", description="Comma-separated arguments"),
|
|
272
|
+
env: str = Query("", description="Comma-separated KEY=VALUE pairs"),
|
|
273
|
+
):
|
|
274
|
+
"""
|
|
275
|
+
STDIO transport proxy using SSE for bidirectional communication.
|
|
276
|
+
|
|
277
|
+
The browser connects via SSE and sends messages as query parameters.
|
|
278
|
+
The proxy spawns the MCP server process and relays messages.
|
|
279
|
+
"""
|
|
280
|
+
# Verify auth
|
|
281
|
+
_verify_auth(request)
|
|
282
|
+
|
|
283
|
+
# Parse arguments
|
|
284
|
+
cmd_args = [a.strip() for a in args.split(",") if a.strip()] if args else []
|
|
285
|
+
|
|
286
|
+
# Parse environment variables
|
|
287
|
+
cmd_env = {}
|
|
288
|
+
if env:
|
|
289
|
+
for pair in env.split(","):
|
|
290
|
+
if "=" in pair:
|
|
291
|
+
key, value = pair.split("=", 1)
|
|
292
|
+
cmd_env[key.strip()] = value.strip()
|
|
293
|
+
|
|
294
|
+
# Create session
|
|
295
|
+
session = await session_manager.create_session(
|
|
296
|
+
transport_type="stdio",
|
|
297
|
+
target_url=f"stdio://{command}",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
# Spawn process
|
|
302
|
+
full_cmd = [command] + cmd_args
|
|
303
|
+
process = subprocess.Popen(
|
|
304
|
+
full_cmd,
|
|
305
|
+
stdin=subprocess.PIPE,
|
|
306
|
+
stdout=subprocess.PIPE,
|
|
307
|
+
stderr=subprocess.PIPE,
|
|
308
|
+
env={**dict(os.environ), **cmd_env},
|
|
309
|
+
)
|
|
310
|
+
session.process = process
|
|
311
|
+
|
|
312
|
+
async def event_generator():
|
|
313
|
+
"""Generate SSE events from process stdout."""
|
|
314
|
+
try:
|
|
315
|
+
while True:
|
|
316
|
+
if process.poll() is not None:
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
# Read line from stdout
|
|
320
|
+
if process.stdout is None:
|
|
321
|
+
break
|
|
322
|
+
line = await asyncio.get_event_loop().run_in_executor(
|
|
323
|
+
None, process.stdout.readline
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if line:
|
|
327
|
+
yield {
|
|
328
|
+
"event": "message",
|
|
329
|
+
"data": line.decode("utf-8").strip(),
|
|
330
|
+
}
|
|
331
|
+
else:
|
|
332
|
+
await asyncio.sleep(0.01)
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error(f"STDIO proxy error: {e}")
|
|
336
|
+
yield {"event": "error", "data": str(e)}
|
|
337
|
+
finally:
|
|
338
|
+
await session_manager.delete_session(session.id)
|
|
339
|
+
|
|
340
|
+
return EventSourceResponse(
|
|
341
|
+
event_generator(),
|
|
342
|
+
headers={"x-proxy-session-id": session.id},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
except Exception as e:
|
|
346
|
+
await session_manager.delete_session(session.id)
|
|
347
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
348
|
+
|
|
349
|
+
@app.post("/stdio/{session_id}/message")
|
|
350
|
+
async def stdio_send_message(session_id: str, request: Request):
|
|
351
|
+
"""Send a message to a STDIO session."""
|
|
352
|
+
_verify_auth(request)
|
|
353
|
+
|
|
354
|
+
session = await session_manager.get_session(session_id)
|
|
355
|
+
if not session or not session.process:
|
|
356
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
357
|
+
|
|
358
|
+
body = await request.body()
|
|
359
|
+
try:
|
|
360
|
+
if session.process.stdin is None:
|
|
361
|
+
raise HTTPException(status_code=500, detail="Process stdin is not available")
|
|
362
|
+
session.process.stdin.write(body + b"\n")
|
|
363
|
+
session.process.stdin.flush()
|
|
364
|
+
return {"status": "sent"}
|
|
365
|
+
except Exception as e:
|
|
366
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
367
|
+
|
|
368
|
+
# =========================================================================
|
|
369
|
+
# SSE Transport Proxy
|
|
370
|
+
# =========================================================================
|
|
371
|
+
|
|
372
|
+
@app.get("/sse")
|
|
373
|
+
async def sse_proxy(
|
|
374
|
+
request: Request,
|
|
375
|
+
url: str = Query(..., description="Target MCP server URL"),
|
|
376
|
+
):
|
|
377
|
+
"""
|
|
378
|
+
SSE transport proxy.
|
|
379
|
+
|
|
380
|
+
Connects to the target MCP server via SSE and relays events to the browser.
|
|
381
|
+
"""
|
|
382
|
+
_verify_auth(request)
|
|
383
|
+
|
|
384
|
+
# Extract custom headers
|
|
385
|
+
headers = _extract_custom_headers(request)
|
|
386
|
+
|
|
387
|
+
# Create session
|
|
388
|
+
session = await session_manager.create_session(
|
|
389
|
+
transport_type="sse",
|
|
390
|
+
target_url=url,
|
|
391
|
+
headers=headers,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
async def event_generator():
|
|
395
|
+
"""Generate SSE events from target server."""
|
|
396
|
+
try:
|
|
397
|
+
async with httpx.AsyncClient() as client:
|
|
398
|
+
session.client = client
|
|
399
|
+
|
|
400
|
+
async with client.stream(
|
|
401
|
+
"GET", url, headers=headers, timeout=None
|
|
402
|
+
) as response:
|
|
403
|
+
async for line in response.aiter_lines():
|
|
404
|
+
if line:
|
|
405
|
+
# Parse SSE format
|
|
406
|
+
if line.startswith("data:"):
|
|
407
|
+
data = line[5:].strip()
|
|
408
|
+
yield {"event": "message", "data": data}
|
|
409
|
+
elif line.startswith("event:"):
|
|
410
|
+
# Handle event type
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
except Exception as e:
|
|
414
|
+
logger.error(f"SSE proxy error: {e}")
|
|
415
|
+
yield {"event": "error", "data": str(e)}
|
|
416
|
+
finally:
|
|
417
|
+
await session_manager.delete_session(session.id)
|
|
418
|
+
|
|
419
|
+
return EventSourceResponse(
|
|
420
|
+
event_generator(),
|
|
421
|
+
headers={"x-proxy-session-id": session.id},
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
@app.post("/sse/{session_id}/message")
|
|
425
|
+
async def sse_send_message(session_id: str, request: Request):
|
|
426
|
+
"""Send a message to an SSE session's message endpoint."""
|
|
427
|
+
_verify_auth(request)
|
|
428
|
+
|
|
429
|
+
session = await session_manager.get_session(session_id)
|
|
430
|
+
if not session:
|
|
431
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
432
|
+
if not session.target_url:
|
|
433
|
+
raise HTTPException(status_code=400, detail="Session has no target URL")
|
|
434
|
+
|
|
435
|
+
# SSE uses a separate message endpoint
|
|
436
|
+
message_url = session.target_url.replace("/sse", "/message")
|
|
437
|
+
body = await request.json()
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
async with httpx.AsyncClient() as client:
|
|
441
|
+
response = await client.post(
|
|
442
|
+
message_url,
|
|
443
|
+
json=body,
|
|
444
|
+
headers=session.headers,
|
|
445
|
+
)
|
|
446
|
+
return response.json()
|
|
447
|
+
except Exception as e:
|
|
448
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
449
|
+
|
|
450
|
+
# =========================================================================
|
|
451
|
+
# Streamable HTTP Transport Proxy
|
|
452
|
+
# =========================================================================
|
|
453
|
+
|
|
454
|
+
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
|
|
455
|
+
async def http_proxy(
|
|
456
|
+
request: Request,
|
|
457
|
+
url: str = Query(..., description="Target MCP server URL"),
|
|
458
|
+
transport_type: str = Query(
|
|
459
|
+
"streamable-http", description="Transport type hint"
|
|
460
|
+
),
|
|
461
|
+
):
|
|
462
|
+
"""
|
|
463
|
+
Streamable HTTP transport proxy.
|
|
464
|
+
|
|
465
|
+
Handles all HTTP methods and manages session IDs between browser and server.
|
|
466
|
+
"""
|
|
467
|
+
_verify_auth(request)
|
|
468
|
+
|
|
469
|
+
# Get or create proxy session from header
|
|
470
|
+
proxy_session_id = request.headers.get("x-proxy-session-id")
|
|
471
|
+
|
|
472
|
+
# Extract custom headers (excluding proxy auth)
|
|
473
|
+
headers = _extract_custom_headers(request)
|
|
474
|
+
|
|
475
|
+
if request.method == "GET":
|
|
476
|
+
# Initial connection or SSE stream request
|
|
477
|
+
session = await session_manager.create_session(
|
|
478
|
+
transport_type=transport_type,
|
|
479
|
+
target_url=url,
|
|
480
|
+
headers=headers,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# For GET, return SSE stream
|
|
484
|
+
async def event_generator():
|
|
485
|
+
try:
|
|
486
|
+
async with httpx.AsyncClient() as client:
|
|
487
|
+
async with client.stream(
|
|
488
|
+
"GET", url, headers=headers, timeout=None
|
|
489
|
+
) as response:
|
|
490
|
+
# Capture server session ID
|
|
491
|
+
server_session = response.headers.get("mcp-session-id")
|
|
492
|
+
if server_session:
|
|
493
|
+
await session_manager.update_session(
|
|
494
|
+
session.id, server_session_id=server_session
|
|
495
|
+
)
|
|
496
|
+
yield {
|
|
497
|
+
"event": "session",
|
|
498
|
+
"data": f'{{"proxy_session_id": "{session.id}", "server_session_id": "{server_session}"}}',
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async for line in response.aiter_lines():
|
|
502
|
+
if line:
|
|
503
|
+
yield {"event": "message", "data": line}
|
|
504
|
+
|
|
505
|
+
except Exception as e:
|
|
506
|
+
logger.error(f"HTTP stream proxy error: {e}")
|
|
507
|
+
yield {"event": "error", "data": str(e)}
|
|
508
|
+
finally:
|
|
509
|
+
await session_manager.delete_session(session.id)
|
|
510
|
+
|
|
511
|
+
return EventSourceResponse(
|
|
512
|
+
event_generator(),
|
|
513
|
+
headers={"x-proxy-session-id": session.id},
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
elif request.method == "POST":
|
|
517
|
+
# JSON-RPC request
|
|
518
|
+
body = await request.json()
|
|
519
|
+
|
|
520
|
+
# Get existing session or create new one
|
|
521
|
+
session = None
|
|
522
|
+
if proxy_session_id:
|
|
523
|
+
session = await session_manager.get_session(proxy_session_id)
|
|
524
|
+
|
|
525
|
+
if not session:
|
|
526
|
+
session = await session_manager.create_session(
|
|
527
|
+
transport_type=transport_type,
|
|
528
|
+
target_url=url,
|
|
529
|
+
headers=headers,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
# Add server session ID if we have one
|
|
533
|
+
request_headers = {**headers}
|
|
534
|
+
if session.server_session_id:
|
|
535
|
+
request_headers["mcp-session-id"] = session.server_session_id
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
async with httpx.AsyncClient() as client:
|
|
539
|
+
response = await client.post(
|
|
540
|
+
url,
|
|
541
|
+
json=body,
|
|
542
|
+
headers=request_headers,
|
|
543
|
+
timeout=30.0,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Capture session ID from response
|
|
547
|
+
server_session = response.headers.get("mcp-session-id")
|
|
548
|
+
if server_session and server_session != session.server_session_id:
|
|
549
|
+
await session_manager.update_session(
|
|
550
|
+
session.id, server_session_id=server_session
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
# Check content type for streaming response
|
|
554
|
+
content_type = response.headers.get("content-type", "")
|
|
555
|
+
|
|
556
|
+
if "text/event-stream" in content_type:
|
|
557
|
+
# Streaming response - relay as SSE
|
|
558
|
+
async def stream_response():
|
|
559
|
+
async for line in response.aiter_lines():
|
|
560
|
+
if line:
|
|
561
|
+
yield {"event": "message", "data": line}
|
|
562
|
+
|
|
563
|
+
return EventSourceResponse(
|
|
564
|
+
stream_response(),
|
|
565
|
+
headers={
|
|
566
|
+
"x-proxy-session-id": session.id,
|
|
567
|
+
"mcp-session-id": server_session or "",
|
|
568
|
+
},
|
|
569
|
+
)
|
|
570
|
+
else:
|
|
571
|
+
# Regular JSON response
|
|
572
|
+
return Response(
|
|
573
|
+
content=response.content,
|
|
574
|
+
status_code=response.status_code,
|
|
575
|
+
headers={
|
|
576
|
+
"x-proxy-session-id": session.id,
|
|
577
|
+
"mcp-session-id": server_session or "",
|
|
578
|
+
"content-type": content_type,
|
|
579
|
+
},
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
except httpx.TimeoutException:
|
|
583
|
+
raise HTTPException(status_code=504, detail="Request timeout")
|
|
584
|
+
except Exception as e:
|
|
585
|
+
logger.error(f"HTTP proxy error: {e}")
|
|
586
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
587
|
+
|
|
588
|
+
elif request.method == "DELETE":
|
|
589
|
+
# Session termination
|
|
590
|
+
if proxy_session_id:
|
|
591
|
+
session = await session_manager.get_session(proxy_session_id)
|
|
592
|
+
if session and session.server_session_id:
|
|
593
|
+
# Send DELETE to server
|
|
594
|
+
try:
|
|
595
|
+
async with httpx.AsyncClient() as client:
|
|
596
|
+
await client.delete(
|
|
597
|
+
url,
|
|
598
|
+
headers={
|
|
599
|
+
**headers,
|
|
600
|
+
"mcp-session-id": session.server_session_id,
|
|
601
|
+
},
|
|
602
|
+
)
|
|
603
|
+
except Exception as e:
|
|
604
|
+
logger.warning(f"Error terminating server session: {e}")
|
|
605
|
+
|
|
606
|
+
await session_manager.delete_session(proxy_session_id)
|
|
607
|
+
|
|
608
|
+
return {"status": "terminated"}
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _verify_auth(request: Request):
|
|
612
|
+
"""Verify the proxy authentication token."""
|
|
613
|
+
auth_header = request.headers.get("x-mcp-proxy-auth", "")
|
|
614
|
+
expected = f"Bearer {get_auth_token()}"
|
|
615
|
+
|
|
616
|
+
# Use constant-time comparison
|
|
617
|
+
if not secrets.compare_digest(auth_header, expected):
|
|
618
|
+
# Also check if auth is disabled (for development)
|
|
619
|
+
if get_auth_token() != "":
|
|
620
|
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _extract_custom_headers(request: Request) -> dict[str, str]:
|
|
624
|
+
"""Extract custom headers to forward to the target server."""
|
|
625
|
+
headers = {}
|
|
626
|
+
|
|
627
|
+
# Standard headers to forward
|
|
628
|
+
forward_prefixes = ["mcp-", "authorization", "content-type", "accept"]
|
|
629
|
+
exclude_headers = ["x-mcp-proxy-auth", "x-proxy-session-id", "host"]
|
|
630
|
+
|
|
631
|
+
for key, value in request.headers.items():
|
|
632
|
+
key_lower = key.lower()
|
|
633
|
+
|
|
634
|
+
# Skip excluded headers
|
|
635
|
+
if key_lower in exclude_headers:
|
|
636
|
+
continue
|
|
637
|
+
|
|
638
|
+
# Forward if matches prefix or is in whitelist
|
|
639
|
+
if any(key_lower.startswith(prefix) for prefix in forward_prefixes):
|
|
640
|
+
headers[key] = value
|
|
641
|
+
|
|
642
|
+
# Handle custom auth header passthrough
|
|
643
|
+
custom_auth = request.headers.get("x-custom-auth-header")
|
|
644
|
+
if custom_auth:
|
|
645
|
+
# Extract the custom header name and value
|
|
646
|
+
parts = custom_auth.split(":", 1)
|
|
647
|
+
if len(parts) == 2:
|
|
648
|
+
headers[parts[0].strip()] = parts[1].strip()
|
|
649
|
+
|
|
650
|
+
return headers
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: browse-mcp-proxy
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A proxy server for MCP Inspector that enables browser-based connections to MCP servers.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: mcp,proxy,inspector,browser,websocket
|
|
7
|
+
Author: Xueyuan Lin
|
|
8
|
+
Author-email: linxy59@mail2.sysu.edu.cn
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Dist: fastapi (>=0.115.0)
|
|
19
|
+
Requires-Dist: httpx (>=0.28.1)
|
|
20
|
+
Requires-Dist: loguru (>=0.7.0)
|
|
21
|
+
Requires-Dist: mcp[cli] (>=1.6.0)
|
|
22
|
+
Requires-Dist: pydantic (>=2.0.0)
|
|
23
|
+
Requires-Dist: sse-starlette (>=2.0.0)
|
|
24
|
+
Requires-Dist: typer (>=0.12.0)
|
|
25
|
+
Requires-Dist: uvicorn[standard] (>=0.30.0)
|
|
26
|
+
Project-URL: Homepage, https://github.com/LinXueyuanStdio/browse-mcp
|
|
27
|
+
Project-URL: Repository, https://github.com/LinXueyuanStdio/browse-mcp
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Browse MCP Proxy
|
|
31
|
+
|
|
32
|
+
A proxy server that enables browser-based applications to connect to MCP (Model Context Protocol) servers. This proxy handles CORS, session management, and transport type conversion.
|
|
33
|
+
|
|
34
|
+
## Why This Proxy?
|
|
35
|
+
|
|
36
|
+
Browser-based applications face several challenges when connecting to MCP servers:
|
|
37
|
+
|
|
38
|
+
1. **CORS Restrictions**: Browsers enforce Same-Origin Policy, preventing direct connections to MCP servers on different origins.
|
|
39
|
+
|
|
40
|
+
2. **Session Management**: MCP's Streamable HTTP transport requires session IDs that browsers can't automatically manage across requests.
|
|
41
|
+
|
|
42
|
+
3. **STDIO Transport**: Browser JavaScript cannot spawn local processes, but this proxy can bridge that gap.
|
|
43
|
+
|
|
44
|
+
## Architecture
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
┌─────────────────┐
|
|
48
|
+
│ Browser/Tauri │ (Inspector UI)
|
|
49
|
+
│ Frontend App │
|
|
50
|
+
└────────┬────────┘
|
|
51
|
+
│ 1. HTTP requests (same-origin or CORS-allowed)
|
|
52
|
+
│ 2. X-MCP-Proxy-Auth header for security
|
|
53
|
+
↓
|
|
54
|
+
┌─────────────────┐
|
|
55
|
+
│ MCP Proxy │ (this server)
|
|
56
|
+
│ Server │ ┌──────────────────────┐
|
|
57
|
+
│ │ │ Session Management │
|
|
58
|
+
│ • CORS enabled │ │ proxy_id → server_id │
|
|
59
|
+
│ • Auth token │←→│ • Target URL │
|
|
60
|
+
│ • Transports │ │ • Custom headers │
|
|
61
|
+
│ │ └──────────────────────┘
|
|
62
|
+
└────────┬────────┘
|
|
63
|
+
│ 3. Forwards requests with proper session handling
|
|
64
|
+
│ 4. Supports STDIO, SSE, and HTTP transports
|
|
65
|
+
↓
|
|
66
|
+
┌─────────────────┐
|
|
67
|
+
│ Target MCP │ (any MCP server)
|
|
68
|
+
│ Server │
|
|
69
|
+
└─────────────────┘
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Installation
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Using pip
|
|
76
|
+
pip install browse-mcp-proxy
|
|
77
|
+
|
|
78
|
+
# Using poetry
|
|
79
|
+
poetry add browse-mcp-proxy
|
|
80
|
+
|
|
81
|
+
# From source
|
|
82
|
+
cd backend/browse-mcp-proxy
|
|
83
|
+
poetry install
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Usage
|
|
87
|
+
|
|
88
|
+
### Start the Proxy Server
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Basic usage
|
|
92
|
+
browse-mcp-proxy serve
|
|
93
|
+
|
|
94
|
+
# Custom port
|
|
95
|
+
browse-mcp-proxy serve --port 6277
|
|
96
|
+
|
|
97
|
+
# With auto-generated auth token and browser opening
|
|
98
|
+
browse-mcp-proxy serve --open
|
|
99
|
+
|
|
100
|
+
# Disable auth (development only!)
|
|
101
|
+
browse-mcp-proxy serve --no-auth
|
|
102
|
+
|
|
103
|
+
# With custom auth token
|
|
104
|
+
browse-mcp-proxy serve --auth-token YOUR_SECRET_TOKEN
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Generate Auth Token
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
browse-mcp-proxy token
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## API Endpoints
|
|
114
|
+
|
|
115
|
+
### Health Check
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
GET /health
|
|
119
|
+
Response: {"status": "ok"}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Configuration
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
GET /config
|
|
126
|
+
Response: {
|
|
127
|
+
"auth_token": "...",
|
|
128
|
+
"sessions": [...]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Sessions
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
GET /sessions
|
|
136
|
+
DELETE /sessions/{session_id}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### STDIO Transport
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
GET /stdio?command=python&args=-m,browse_mcp&env=KEY=VALUE
|
|
143
|
+
POST /stdio/{session_id}/message
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### SSE Transport
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
GET /sse?url=http://localhost:8000/sse
|
|
150
|
+
POST /sse/{session_id}/message
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Streamable HTTP Transport
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
GET /mcp?url=http://localhost:8000/mcp
|
|
157
|
+
POST /mcp?url=http://localhost:8000/mcp
|
|
158
|
+
DELETE /mcp?url=http://localhost:8000/mcp
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Headers
|
|
162
|
+
|
|
163
|
+
### Authentication
|
|
164
|
+
|
|
165
|
+
All requests must include:
|
|
166
|
+
```
|
|
167
|
+
X-MCP-Proxy-Auth: Bearer YOUR_AUTH_TOKEN
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Session Tracking
|
|
171
|
+
|
|
172
|
+
The proxy returns a session ID that should be included in subsequent requests:
|
|
173
|
+
```
|
|
174
|
+
X-Proxy-Session-Id: uuid-string
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Custom Headers
|
|
178
|
+
|
|
179
|
+
To pass custom headers to the target server:
|
|
180
|
+
```
|
|
181
|
+
X-Custom-Auth-Header: Header-Name: Header-Value
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Environment Variables
|
|
185
|
+
|
|
186
|
+
| Variable | Description | Default |
|
|
187
|
+
|----------|-------------|---------|
|
|
188
|
+
| `MCP_PROXY_AUTH_TOKEN` | Authentication token | Random 32-byte hex |
|
|
189
|
+
|
|
190
|
+
## Integration with Desktop App
|
|
191
|
+
|
|
192
|
+
In the Tauri desktop app, the proxy can be started alongside the MCP server:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// Start proxy
|
|
196
|
+
await invoke("start_mcp_proxy", { port: 6277 });
|
|
197
|
+
|
|
198
|
+
// Connect Inspector to proxy
|
|
199
|
+
const proxyUrl = "http://localhost:6277/mcp";
|
|
200
|
+
const targetUrl = "http://localhost:8000/mcp";
|
|
201
|
+
|
|
202
|
+
fetch(`${proxyUrl}?url=${encodeURIComponent(targetUrl)}`, {
|
|
203
|
+
headers: {
|
|
204
|
+
"X-MCP-Proxy-Auth": `Bearer ${authToken}`,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Security
|
|
210
|
+
|
|
211
|
+
- **Auth Token**: Required by default. Disable with `--no-auth` (not recommended for production).
|
|
212
|
+
- **CORS**: Configured to allow specific origins (localhost ports by default).
|
|
213
|
+
- **Origin Validation**: Prevents DNS rebinding attacks.
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
# Install dependencies
|
|
219
|
+
poetry install
|
|
220
|
+
|
|
221
|
+
# Run with auto-reload
|
|
222
|
+
browse-mcp-proxy serve --reload --log-level debug
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
MIT License
|
|
228
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
browse_mcp_proxy/__init__.py,sha256=M0ewAPmPsgZhkhAhHiN6w0sJau7SzNtJP29hRBqLELU,108
|
|
2
|
+
browse_mcp_proxy/__main__.py,sha256=PR5aXqetMzZRQmPY8q7cB44RrZAHnrZZc7nhNzt2qB8,3138
|
|
3
|
+
browse_mcp_proxy/proxy.py,sha256=8gxOcky14FtMS8KzlpXSPbQJY-NpX77tCKi_ikaIJKE,23074
|
|
4
|
+
browse_mcp_proxy-0.1.1.dist-info/METADATA,sha256=tAVDVvZDBFqDsv_MlKWTQY7CJUwFrF6WDFY35i3u2cA,5725
|
|
5
|
+
browse_mcp_proxy-0.1.1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
6
|
+
browse_mcp_proxy-0.1.1.dist-info/entry_points.txt,sha256=gbIL2KflpEoSbtS-sFX8l8lTiV7SfST1f42mk26G_2k,67
|
|
7
|
+
browse_mcp_proxy-0.1.1.dist-info/RECORD,,
|