misukisu commited on
Commit
c75749c
·
verified ·
1 Parent(s): b25ab31

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +109 -162
app.py CHANGED
@@ -1,28 +1,38 @@
1
- """High-Performance Stealth Web Scraper & Browser Automation MCP Server
2
-
3
- All-in-one file designed for Hugging Face Spaces (Port 7860).
4
  """
5
 
6
- import asyncio
7
- import base64
8
- import json
9
- import logging
10
  import os
11
  import sys
12
- from contextlib import asynccontextmanager
 
 
 
13
  from typing import Any, AsyncGenerator
 
14
 
15
  from bs4 import BeautifulSoup
16
- from duckduckgo_search import DDGS
17
  import httpx
 
18
  from lxml_html_clean import Cleaner
19
  import markdownify
 
 
 
20
  from mcp.server.fastmcp import FastMCP
21
- from patchright.async_api import Browser, BrowserContext, Page, async_playwright
 
 
 
 
 
 
 
 
22
  from starlette.applications import Starlette
 
23
  from starlette.responses import JSONResponse
24
- from starlette.routing import Mount, Route
25
- import trafilatura
26
  import uvicorn
27
 
28
  # ==========================================
@@ -31,7 +41,7 @@ import uvicorn
31
  logging.basicConfig(
32
  level=logging.INFO,
33
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
34
- handlers=[logging.StreamHandler(sys.stdout)],
35
  )
36
  logger = logging.getLogger("mcp_server")
37
 
@@ -46,10 +56,7 @@ BROWSER_TIMEOUT_MS = 30000
46
  # ==========================================
47
  # 2. Browser Pool
48
  # ==========================================
49
-
50
-
51
  class StealthBrowserPool:
52
-
53
  def __init__(self):
54
  self._playwright = None
55
  self._browser: Browser | None = None
@@ -62,7 +69,7 @@ class StealthBrowserPool:
62
  if not self._playwright:
63
  self._playwright = await async_playwright().start()
64
  if not self._browser or not self._browser.is_connected():
65
- logger.info("Starting Chromium Stealth instance...")
66
  self._browser = await self._playwright.chromium.launch(
67
  headless=True,
68
  args=[
@@ -72,8 +79,8 @@ class StealthBrowserPool:
72
  "--disable-gpu",
73
  "--disable-blink-features=AutomationControlled",
74
  "--no-first-run",
75
- "--window-size=1920,1080",
76
- ],
77
  )
78
  self._request_counter = 0
79
  return self._browser
@@ -104,7 +111,7 @@ class StealthBrowserPool:
104
  "Chrome/131.0.0.0 Safari/537.36"
105
  ),
106
  locale="en-US",
107
- timezone_id="America/New_York",
108
  )
109
  await context.add_init_script("""
110
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
@@ -130,35 +137,25 @@ class StealthBrowserPool:
130
  self._semaphore.release()
131
  await self._check_recycle()
132
 
133
-
134
  browser_pool = StealthBrowserPool()
135
 
136
  # ==========================================
137
  # 3. Content Extraction Pipeline
138
  # ==========================================
139
-
140
-
141
  def clean_html(raw_html: str) -> str:
142
  cleaner = Cleaner(
143
- scripts=True,
144
- javascript=True,
145
- comments=True,
146
- style=True,
147
- links=False,
148
- meta=False,
149
- page_structure=False,
150
- safe_attrs_only=False,
151
  )
152
  return cleaner.clean_html(raw_html)
153
 
154
-
155
  def extract_content(html: str, url: str = "") -> dict[str, Any]:
156
  extracted = trafilatura.extract(
157
  html,
158
  url=url,
159
  include_links=True,
160
  include_images=True,
161
- output_format="markdown",
162
  )
163
  metadata = trafilatura.extract_metadata(html) or {}
164
  meta_dict = {
@@ -171,25 +168,17 @@ def extract_content(html: str, url: str = "") -> dict[str, Any]:
171
  if not extracted or len(extracted.strip()) < 100:
172
  cleaned = clean_html(html)
173
  soup = BeautifulSoup(cleaned, "html.parser")
174
- for tag in soup(
175
- ["nav", "footer", "aside", "header", "script", "style", "noscript"]
176
- ):
177
  tag.decompose()
178
  if not meta_dict["title"] and soup.title:
179
  meta_dict["title"] = soup.title.string or ""
180
- extracted = markdownify.markdownify(
181
- str(soup), heading_style="ATX", strip=["svg"]
182
- )
183
 
184
  if len(extracted) > MAX_CONTENT_CHARS:
185
- extracted = (
186
- extracted[:MAX_CONTENT_CHARS]
187
- + f"\n\n... [Truncated: reached {MAX_CONTENT_CHARS} limit]"
188
- )
189
 
190
  return {"content": extracted.strip(), "metadata": meta_dict}
191
 
192
-
193
  def extract_json_ld(html: str) -> list[dict]:
194
  soup = BeautifulSoup(html, "html.parser")
195
  schemas = []
@@ -202,27 +191,19 @@ def extract_json_ld(html: str) -> list[dict]:
202
  continue
203
  return schemas
204
 
205
-
206
  # ==========================================
207
  # 4. Hybrid Scraping Core
208
  # ==========================================
209
-
210
  FAST_HEADERS = {
211
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
212
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
213
  "Accept-Language": "en-US,en;q=0.9",
214
  }
215
 
216
-
217
  async def dismiss_popups(page: Page):
218
  selectors = [
219
- 'button[id*="cookie" i]',
220
- 'button[class*="cookie" i]',
221
- 'button[aria-label*="accept" i]',
222
- 'button:has-text("Accept all")',
223
- 'button:has-text("Accept")',
224
- 'button:has-text("I agree")',
225
- 'button:has-text("Got it")',
226
  ]
227
  for sel in selectors:
228
  try:
@@ -234,32 +215,14 @@ async def dismiss_popups(page: Page):
234
  except Exception:
235
  continue
236
 
237
-
238
- async def scrape_pipeline(
239
- url: str,
240
- force_browser: bool = False,
241
- auto_scroll: bool = True,
242
- wait_for_selector: str | None = None,
243
- ) -> dict:
244
  if not force_browser:
245
  try:
246
- async with httpx.AsyncClient(
247
- http2=True,
248
- timeout=HTTP_TIMEOUT_SEC,
249
- follow_redirects=True,
250
- headers=FAST_HEADERS,
251
- ) as client:
252
  resp = await client.get(url)
253
  if resp.status_code == 200:
254
  html = resp.text
255
- if not any(
256
- s in html.lower()
257
- for s in [
258
- "cf-challenge",
259
- "ray-id",
260
- "just a moment...",
261
- ]
262
- ):
263
  parsed = extract_content(html, url=url)
264
  if len(parsed["content"]) >= 150:
265
  return {
@@ -267,7 +230,7 @@ async def scrape_pipeline(
267
  "status": 200,
268
  "engine": "fast-http",
269
  "content": parsed["content"],
270
- "metadata": parsed["metadata"],
271
  }
272
  except Exception:
273
  pass
@@ -275,16 +238,11 @@ async def scrape_pipeline(
275
  async with browser_pool.get_page() as page:
276
  await page.goto(url, wait_until="domcontentloaded")
277
  try:
278
- cf_frame = await page.query_selector(
279
- "iframe[src*='challenges.cloudflare.com']"
280
- )
281
  if cf_frame:
282
  box = await cf_frame.bounding_box()
283
  if box:
284
- await page.mouse.click(
285
- box["x"] + box["width"] / 2,
286
- box["y"] + box["height"] / 2,
287
- )
288
  await asyncio.sleep(2.0)
289
  except Exception:
290
  pass
@@ -293,9 +251,7 @@ async def scrape_pipeline(
293
 
294
  if wait_for_selector:
295
  try:
296
- await page.wait_for_selector(
297
- wait_for_selector, state="visible", timeout=8000
298
- )
299
  except Exception:
300
  pass
301
 
@@ -318,32 +274,30 @@ async def scrape_pipeline(
318
  "status": 200,
319
  "engine": "patchright-stealth",
320
  "content": parsed["content"],
321
- "metadata": parsed["metadata"],
322
  }
323
 
324
-
325
  # ==========================================
326
  # 5. MCP Server & Tool Definitions
327
  # ==========================================
328
- mcp = FastMCP("WebScraper")
329
-
330
-
331
- @mcp.tool()
332
- async def scrape_url(
333
- url: str,
334
- force_browser: bool = False,
335
- auto_scroll: bool = True,
336
- wait_for_selector: str | None = None,
337
- ) -> dict:
338
- """Scrapes cleaned Markdown content and metadata from any URL."""
339
- return await scrape_pipeline(
340
- url, force_browser, auto_scroll, wait_for_selector
341
- )
342
-
343
 
344
- @mcp.tool()
 
 
 
345
  async def search_and_scrape(query: str, max_results: int = 3) -> dict:
346
- """Searches DuckDuckGo and concurrently extracts content from top results."""
347
  max_results = min(max(1, max_results), 5)
348
  loop = asyncio.get_running_loop()
349
 
@@ -355,61 +309,50 @@ async def search_and_scrape(query: str, max_results: int = 3) -> dict:
355
  if not results:
356
  return {"query": query, "results": []}
357
 
358
- tasks = [
359
- scrape_pipeline(res["href"], force_browser=False, auto_scroll=False)
360
- for res in results
361
- ]
362
  scraped_payloads = await asyncio.gather(*tasks, return_exceptions=True)
363
 
364
  enriched = []
365
  for meta, payload in zip(results, scraped_payloads):
366
  if isinstance(payload, dict):
367
- enriched.append(
368
- {
369
- "title": meta.get("title", ""),
370
- "url": meta.get("href", ""),
371
- "snippet": meta.get("body", ""),
372
- "content": payload.get("content", ""),
373
- }
374
- )
375
  else:
376
- enriched.append(
377
- {
378
- "title": meta.get("title", ""),
379
- "url": meta.get("href", ""),
380
- "snippet": meta.get("body", ""),
381
- "content": f"[Error: {str(payload)}]",
382
- }
383
- )
384
  return {"query": query, "results": enriched}
385
 
386
-
387
- @mcp.tool()
388
- async def take_screenshot(
389
- url: str, full_page: bool = True, wait_seconds: float = 1.0
390
- ) -> dict:
391
- """Takes a full-page or viewport screenshot of a webpage."""
392
  async with browser_pool.get_page() as page:
393
  await page.goto(url, wait_until="networkidle")
394
  if wait_seconds > 0:
395
  await asyncio.sleep(min(wait_seconds, 10.0))
396
- screenshot_bytes = await page.screenshot(
397
- full_page=full_page, type="png"
398
- )
399
  b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
400
  return {
401
  "url": page.url,
402
  "title": await page.title(),
403
  "format": "image/png;base64",
404
- "base64_image": b64,
405
  }
406
 
407
-
408
- @mcp.tool()
409
- async def interact_page(
410
- url: str, actions: list[dict[str, Any]], extract_markdown: bool = True
411
- ) -> dict:
412
- """Executes a list of browser actions (click, type, press, wait, evaluate)."""
413
  async with browser_pool.get_page() as page:
414
  await page.goto(url, wait_until="domcontentloaded")
415
  logs = []
@@ -450,12 +393,11 @@ async def interact_page(
450
  res["metadata"] = parsed["metadata"]
451
  return res
452
 
453
-
454
- @mcp.tool()
455
- async def extract_structured_data(
456
- url: str, css_selectors: dict[str, str] | None = None
457
- ) -> dict:
458
- """Parses structured JSON-LD schemas and arbitrary CSS selector targets."""
459
  async with browser_pool.get_page() as page:
460
  await page.goto(url, wait_until="domcontentloaded")
461
  html = await page.content()
@@ -470,31 +412,36 @@ async def extract_structured_data(
470
  return {
471
  "url": url,
472
  "custom_fields": custom_data,
473
- "json_ld_schemas": extract_json_ld(html),
474
  }
475
 
476
-
477
  # ==========================================
478
- # 6. Starlette SSE Router & Server Runner
479
  # ==========================================
480
-
481
-
482
  async def health_check(request):
483
- return JSONResponse(
484
- {
485
- "status": "healthy",
486
- "mcp_sse_endpoint": "/sse",
487
- "transport": "Server-Sent Events (SSE)",
488
- }
489
- )
490
-
 
 
 
 
 
 
 
 
491
 
492
- # Mount the MCP SSE application with health endpoints
493
  starlette_app = Starlette(
494
  routes=[
495
  Route("/", endpoint=health_check),
496
  Route("/healthz", endpoint=health_check),
497
- Mount("/", app=mcp.sse_app()),
 
498
  ]
499
  )
500
 
 
1
+ """
2
+ High-Performance Stealth Web Scraper & Browser Automation MCP Server
3
+ Configured for Hugging Face Spaces with Cloud Host-Header validation bypass.
4
  """
5
 
 
 
 
 
6
  import os
7
  import sys
8
+ import json
9
+ import asyncio
10
+ import logging
11
+ import base64
12
  from typing import Any, AsyncGenerator
13
+ from contextlib import asynccontextmanager
14
 
15
  from bs4 import BeautifulSoup
 
16
  import httpx
17
+ import trafilatura
18
  from lxml_html_clean import Cleaner
19
  import markdownify
20
+ from duckduckgo_search import DDGS
21
+ from patchright.async_api import async_playwright, Browser, BrowserContext, Page
22
+
23
  from mcp.server.fastmcp import FastMCP
24
+ try:
25
+ from mcp.server.transport_security import TransportSecuritySettings
26
+ security_settings = TransportSecuritySettings(
27
+ enable_dns_rebinding_protection=False,
28
+ allowed_hosts=["*"]
29
+ )
30
+ except ImportError:
31
+ security_settings = None
32
+
33
  from starlette.applications import Starlette
34
+ from starlette.routing import Route, Mount
35
  from starlette.responses import JSONResponse
 
 
36
  import uvicorn
37
 
38
  # ==========================================
 
41
  logging.basicConfig(
42
  level=logging.INFO,
43
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
44
+ handlers=[logging.StreamHandler(sys.stdout)]
45
  )
46
  logger = logging.getLogger("mcp_server")
47
 
 
56
  # ==========================================
57
  # 2. Browser Pool
58
  # ==========================================
 
 
59
  class StealthBrowserPool:
 
60
  def __init__(self):
61
  self._playwright = None
62
  self._browser: Browser | None = None
 
69
  if not self._playwright:
70
  self._playwright = await async_playwright().start()
71
  if not self._browser or not self._browser.is_connected():
72
+ logger.info("Initializing Chromium Stealth instance...")
73
  self._browser = await self._playwright.chromium.launch(
74
  headless=True,
75
  args=[
 
79
  "--disable-gpu",
80
  "--disable-blink-features=AutomationControlled",
81
  "--no-first-run",
82
+ "--window-size=1920,1080"
83
+ ]
84
  )
85
  self._request_counter = 0
86
  return self._browser
 
111
  "Chrome/131.0.0.0 Safari/537.36"
112
  ),
113
  locale="en-US",
114
+ timezone_id="America/New_York"
115
  )
116
  await context.add_init_script("""
117
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
 
137
  self._semaphore.release()
138
  await self._check_recycle()
139
 
 
140
  browser_pool = StealthBrowserPool()
141
 
142
  # ==========================================
143
  # 3. Content Extraction Pipeline
144
  # ==========================================
 
 
145
  def clean_html(raw_html: str) -> str:
146
  cleaner = Cleaner(
147
+ scripts=True, javascript=True, comments=True, style=True,
148
+ links=False, meta=False, page_structure=False, safe_attrs_only=False
 
 
 
 
 
 
149
  )
150
  return cleaner.clean_html(raw_html)
151
 
 
152
  def extract_content(html: str, url: str = "") -> dict[str, Any]:
153
  extracted = trafilatura.extract(
154
  html,
155
  url=url,
156
  include_links=True,
157
  include_images=True,
158
+ output_format="markdown"
159
  )
160
  metadata = trafilatura.extract_metadata(html) or {}
161
  meta_dict = {
 
168
  if not extracted or len(extracted.strip()) < 100:
169
  cleaned = clean_html(html)
170
  soup = BeautifulSoup(cleaned, "html.parser")
171
+ for tag in soup(["nav", "footer", "aside", "header", "script", "style", "noscript"]):
 
 
172
  tag.decompose()
173
  if not meta_dict["title"] and soup.title:
174
  meta_dict["title"] = soup.title.string or ""
175
+ extracted = markdownify.markdownify(str(soup), heading_style="ATX", strip=["svg"])
 
 
176
 
177
  if len(extracted) > MAX_CONTENT_CHARS:
178
+ extracted = extracted[:MAX_CONTENT_CHARS] + f"\n\n... [Truncated: reached {MAX_CONTENT_CHARS} limit]"
 
 
 
179
 
180
  return {"content": extracted.strip(), "metadata": meta_dict}
181
 
 
182
  def extract_json_ld(html: str) -> list[dict]:
183
  soup = BeautifulSoup(html, "html.parser")
184
  schemas = []
 
191
  continue
192
  return schemas
193
 
 
194
  # ==========================================
195
  # 4. Hybrid Scraping Core
196
  # ==========================================
 
197
  FAST_HEADERS = {
198
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
199
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
200
  "Accept-Language": "en-US,en;q=0.9",
201
  }
202
 
 
203
  async def dismiss_popups(page: Page):
204
  selectors = [
205
+ 'button[id*="cookie" i]', 'button[class*="cookie" i]', 'button[aria-label*="accept" i]',
206
+ 'button:has-text("Accept all")', 'button:has-text("Accept")', 'button:has-text("I agree")', 'button:has-text("Got it")'
 
 
 
 
 
207
  ]
208
  for sel in selectors:
209
  try:
 
215
  except Exception:
216
  continue
217
 
218
+ async def scrape_pipeline(url: str, force_browser: bool = False, auto_scroll: bool = True, wait_for_selector: str | None = None) -> dict:
 
 
 
 
 
 
219
  if not force_browser:
220
  try:
221
+ async with httpx.AsyncClient(http2=True, timeout=HTTP_TIMEOUT_SEC, follow_redirects=True, headers=FAST_HEADERS) as client:
 
 
 
 
 
222
  resp = await client.get(url)
223
  if resp.status_code == 200:
224
  html = resp.text
225
+ if not any(s in html.lower() for s in ["cf-challenge", "ray-id", "just a moment..."]):
 
 
 
 
 
 
 
226
  parsed = extract_content(html, url=url)
227
  if len(parsed["content"]) >= 150:
228
  return {
 
230
  "status": 200,
231
  "engine": "fast-http",
232
  "content": parsed["content"],
233
+ "metadata": parsed["metadata"]
234
  }
235
  except Exception:
236
  pass
 
238
  async with browser_pool.get_page() as page:
239
  await page.goto(url, wait_until="domcontentloaded")
240
  try:
241
+ cf_frame = await page.query_selector("iframe[src*='challenges.cloudflare.com']")
 
 
242
  if cf_frame:
243
  box = await cf_frame.bounding_box()
244
  if box:
245
+ await page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
 
 
 
246
  await asyncio.sleep(2.0)
247
  except Exception:
248
  pass
 
251
 
252
  if wait_for_selector:
253
  try:
254
+ await page.wait_for_selector(wait_for_selector, state="visible", timeout=8000)
 
 
255
  except Exception:
256
  pass
257
 
 
274
  "status": 200,
275
  "engine": "patchright-stealth",
276
  "content": parsed["content"],
277
+ "metadata": parsed["metadata"]
278
  }
279
 
 
280
  # ==========================================
281
  # 5. MCP Server & Tool Definitions
282
  # ==========================================
283
+ # Initialize FastMCP with transport_security disabled for public cloud proxying
284
+ if security_settings:
285
+ mcp = FastMCP("WebScraper", host=HOST, transport_security=security_settings)
286
+ else:
287
+ mcp = FastMCP("WebScraper", host=HOST)
288
+
289
+ @mcp.tool(
290
+ name="scrape_url",
291
+ description="Scrapes cleaned Markdown content and metadata from any URL."
292
+ )
293
+ async def scrape_url(url: str, force_browser: bool = False, auto_scroll: bool = True, wait_for_selector: str | None = None) -> dict:
294
+ return await scrape_pipeline(url, force_browser, auto_scroll, wait_for_selector)
 
 
 
295
 
296
+ @mcp.tool(
297
+ name="search_and_scrape",
298
+ description="Searches DuckDuckGo and concurrently extracts content from top results."
299
+ )
300
  async def search_and_scrape(query: str, max_results: int = 3) -> dict:
 
301
  max_results = min(max(1, max_results), 5)
302
  loop = asyncio.get_running_loop()
303
 
 
309
  if not results:
310
  return {"query": query, "results": []}
311
 
312
+ tasks = [scrape_pipeline(res["href"], force_browser=False, auto_scroll=False) for res in results]
 
 
 
313
  scraped_payloads = await asyncio.gather(*tasks, return_exceptions=True)
314
 
315
  enriched = []
316
  for meta, payload in zip(results, scraped_payloads):
317
  if isinstance(payload, dict):
318
+ enriched.append({
319
+ "title": meta.get("title", ""),
320
+ "url": meta.get("href", ""),
321
+ "snippet": meta.get("body", ""),
322
+ "content": payload.get("content", "")
323
+ })
 
 
324
  else:
325
+ enriched.append({
326
+ "title": meta.get("title", ""),
327
+ "url": meta.get("href", ""),
328
+ "snippet": meta.get("body", ""),
329
+ "content": f"[Error: {str(payload)}]"
330
+ })
 
 
331
  return {"query": query, "results": enriched}
332
 
333
+ @mcp.tool(
334
+ name="take_screenshot",
335
+ description="Takes a full-page or viewport screenshot of a webpage as Base64 PNG."
336
+ )
337
+ async def take_screenshot(url: str, full_page: bool = True, wait_seconds: float = 1.0) -> dict:
 
338
  async with browser_pool.get_page() as page:
339
  await page.goto(url, wait_until="networkidle")
340
  if wait_seconds > 0:
341
  await asyncio.sleep(min(wait_seconds, 10.0))
342
+ screenshot_bytes = await page.screenshot(full_page=full_page, type="png")
 
 
343
  b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
344
  return {
345
  "url": page.url,
346
  "title": await page.title(),
347
  "format": "image/png;base64",
348
+ "base64_image": b64
349
  }
350
 
351
+ @mcp.tool(
352
+ name="interact_page",
353
+ description="Executes a list of browser actions (click, type, press, wait, evaluate)."
354
+ )
355
+ async def interact_page(url: str, actions: list[dict[str, Any]], extract_markdown: bool = True) -> dict:
 
356
  async with browser_pool.get_page() as page:
357
  await page.goto(url, wait_until="domcontentloaded")
358
  logs = []
 
393
  res["metadata"] = parsed["metadata"]
394
  return res
395
 
396
+ @mcp.tool(
397
+ name="extract_structured_data",
398
+ description="Parses structured JSON-LD schemas and arbitrary CSS selector targets."
399
+ )
400
+ async def extract_structured_data(url: str, css_selectors: dict[str, str] | None = None) -> dict:
 
401
  async with browser_pool.get_page() as page:
402
  await page.goto(url, wait_until="domcontentloaded")
403
  html = await page.content()
 
412
  return {
413
  "url": url,
414
  "custom_fields": custom_data,
415
+ "json_ld_schemas": extract_json_ld(html)
416
  }
417
 
 
418
  # ==========================================
419
+ # 6. Starlette SSE Router & Tool Inspection
420
  # ==========================================
 
 
421
  async def health_check(request):
422
+ return JSONResponse({
423
+ "status": "healthy",
424
+ "mcp_sse_endpoint": "/sse",
425
+ "tools_endpoint": "/tools"
426
+ })
427
+
428
+ async def list_tools_endpoint(request):
429
+ """Direct HTTP endpoint exposing all registered MCP tools and schemas."""
430
+ tools_list = []
431
+ for tool_name, tool_obj in mcp._tool_manager._tools.items():
432
+ tools_list.append({
433
+ "name": tool_name,
434
+ "description": getattr(tool_obj, "description", "") or "",
435
+ "parameters": getattr(tool_obj, "parameters", {}) or {}
436
+ })
437
+ return JSONResponse({"tools": tools_list})
438
 
 
439
  starlette_app = Starlette(
440
  routes=[
441
  Route("/", endpoint=health_check),
442
  Route("/healthz", endpoint=health_check),
443
+ Route("/tools", endpoint=list_tools_endpoint),
444
+ Mount("/", app=mcp.sse_app())
445
  ]
446
  )
447