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.
@@ -0,0 +1,3 @@
1
+ """Browse MCP Proxy - A proxy server for browser-based MCP Inspector connections."""
2
+
3
+ __version__ = "0.1.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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ browse-mcp-proxy=browse_mcp_proxy.__main__:main
3
+