|
82 | 82 | from pydantic import AliasChoices, BaseModel, Field |
83 | 83 | from smithery.decorators import smithery |
84 | 84 | from starlette.requests import Request |
85 | | -from starlette.responses import JSONResponse |
| 85 | +from starlette.responses import JSONResponse, RedirectResponse |
86 | 86 |
|
87 | 87 | # Configure logging |
88 | 88 | logging.basicConfig( |
|
94 | 94 | # Matches scrapegraph-py v2 (env.py): https://v2-api.scrapegraphai.com/api |
95 | 95 | DEFAULT_API_BASE_URL = "https://v2-api.scrapegraphai.com/api" |
96 | 96 |
|
| 97 | +# Where to send humans who open the /mcp endpoint in a browser. |
| 98 | +DOCS_URL = os.getenv( |
| 99 | + "MCP_DOCS_URL", |
| 100 | + "https://docs.scrapegraphai.com/services/mcp-server/introduction", |
| 101 | +) |
| 102 | + |
| 103 | + |
| 104 | +class BrowserRedirectMiddleware: |
| 105 | + """Redirect browser visits to the MCP endpoint to the docs. |
| 106 | +
|
| 107 | + The ``/mcp`` path is the real MCP streamable-HTTP endpoint: clients POST |
| 108 | + JSON-RPC there and open ``GET`` streams with ``Accept: text/event-stream``. |
| 109 | + A person pasting ``https://mcp.scrapegraphai.com/mcp`` into a browser sends |
| 110 | + ``GET`` with ``Accept: text/html`` instead — that (and only that) is |
| 111 | + redirected to the documentation so real MCP traffic is left untouched. |
| 112 | + """ |
| 113 | + |
| 114 | + def __init__(self, app, docs_url: str = DOCS_URL) -> None: |
| 115 | + self.app = app |
| 116 | + self.docs_url = docs_url |
| 117 | + |
| 118 | + async def __call__(self, scope, receive, send) -> None: |
| 119 | + if scope["type"] == "http" and self._is_browser_navigation(scope): |
| 120 | + response = RedirectResponse(self.docs_url, status_code=302) |
| 121 | + await response(scope, receive, send) |
| 122 | + return |
| 123 | + await self.app(scope, receive, send) |
| 124 | + |
| 125 | + def _is_browser_navigation(self, scope) -> bool: |
| 126 | + if scope["method"] not in ("GET", "HEAD"): |
| 127 | + return False |
| 128 | + if scope["path"].rstrip("/") != "/mcp": |
| 129 | + return False |
| 130 | + accept = "" |
| 131 | + for name, value in scope.get("headers", []): |
| 132 | + if name == b"accept": |
| 133 | + accept = value.decode("latin-1").lower() |
| 134 | + break |
| 135 | + # Real MCP SSE streams advertise text/event-stream; browsers ask for HTML. |
| 136 | + return "text/html" in accept and "text/event-stream" not in accept |
| 137 | + |
97 | 138 |
|
98 | 139 | def _api_base_url() -> str: |
99 | 140 | # SGAI_API_URL mirrors scrapegraph-py v2; SCRAPEGRAPH_API_BASE_URL is a legacy alias. |
@@ -2041,7 +2082,14 @@ def main() -> None: |
2041 | 2082 | port = int(os.getenv("PORT", "8000")) |
2042 | 2083 | logger.info(f"Starting ScapeGraph MCP server in HTTP mode on {host}:{port}") |
2043 | 2084 | print(f"Starting ScapeGraph MCP server in HTTP mode on {host}:{port}") |
2044 | | - mcp.run(transport="http", host=host, port=port) |
| 2085 | + from starlette.middleware import Middleware |
| 2086 | + |
| 2087 | + mcp.run( |
| 2088 | + transport="http", |
| 2089 | + host=host, |
| 2090 | + port=port, |
| 2091 | + middleware=[Middleware(BrowserRedirectMiddleware)], |
| 2092 | + ) |
2045 | 2093 | else: |
2046 | 2094 | # Local stdio mode (Claude Desktop, Cursor, etc.) |
2047 | 2095 | server_path = os.path.abspath(__file__) |
|
0 commit comments