Skip to content

Commit b5d1319

Browse files
Merge pull request #123 from Madhan5422i/fix/revamp-docs-mcp
feat(docs-mcp)
2 parents 3ad5196 + e9a7ff6 commit b5d1319

5 files changed

Lines changed: 110 additions & 136 deletions

File tree

juspay_docs_mcp/server.py

Lines changed: 33 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,11 @@
1414
Discovery runs once at module import; results are persisted to snapshot.json.
1515
"""
1616

17-
import json
1817
import logging
19-
import os
20-
import re
21-
from contextvars import ContextVar
2218
from typing import Annotated, Optional
23-
from urllib.parse import urljoin, urlparse
19+
from urllib.parse import urlparse
2420

2521
import httpx
26-
from markdownify import markdownify
2722
from mcp.server.fastmcp import FastMCP
2823
from pydantic import Field
2924

@@ -33,43 +28,25 @@
3328
logger = logging.getLogger(__name__)
3429

3530

36-
# ----------------------------------------------------------------------------
37-
# Per-request credentials (ContextVar populated by middleware in juspay_mcp.main)
38-
# ----------------------------------------------------------------------------
39-
40-
juspay_request_credentials: ContextVar[Optional[dict]] = ContextVar(
41-
"juspay_request_credentials", default=None
42-
)
43-
31+
# ---------------------------------------------------------------------------
32+
# Static domain allowlist
33+
# ---------------------------------------------------------------------------
4434

45-
def set_juspay_request_credentials(credentials):
46-
"""Set Juspay credentials for the current request context."""
47-
juspay_request_credentials.set(credentials)
35+
_ALLOWED_SUFFIXES = (".juspay.io", ".juspay.in")
36+
_ALLOWED_EXACT = frozenset({"juspay.io", "juspay.in", "dth95m2xtyv8v.cloudfront.net"})
37+
_ALLOWED_SCHEMES = frozenset({"http", "https"})
38+
_ALLOWED_DOMAINS_DISPLAY = "*.juspay.io, *.juspay.in, dth95m2xtyv8v.cloudfront.net"
4839

4940

50-
def get_juspay_request_credentials():
51-
"""Get Juspay credentials from current request context."""
52-
return juspay_request_credentials.get()
53-
54-
55-
# ----------------------------------------------------------------------------
56-
# Transcripts (curated commentary appended to specific URLs in doc_fetch_tool)
57-
# ----------------------------------------------------------------------------
58-
59-
def _load_transcripts() -> dict:
60-
path = os.path.join(os.path.dirname(__file__), "transcripts.json")
61-
try:
62-
with open(path, "r") as f:
63-
return {str(k): str(v) for k, v in json.load(f).items()}
64-
except FileNotFoundError:
65-
logger.info("No transcripts.json at %s; running without transcripts", path)
66-
return {}
67-
except json.JSONDecodeError as e:
68-
logger.warning("Failed to parse transcripts.json: %s", e)
69-
return {}
70-
71-
72-
_TRANSCRIPTS_MAP = _load_transcripts()
41+
def _url_allowed(url: str) -> bool:
42+
parsed = urlparse(url)
43+
if parsed.scheme not in _ALLOWED_SCHEMES:
44+
return False
45+
# hostname is lowercased and strips the port.
46+
host = parsed.hostname or ""
47+
if host in _ALLOWED_EXACT:
48+
return True
49+
return any(host.endswith(s) for s in _ALLOWED_SUFFIXES)
7350

7451

7552
# ----------------------------------------------------------------------------
@@ -112,10 +89,10 @@ def _slug_for(entry: dict) -> Optional[str]:
11289
_CAT_HINT: str = ", ".join(_CATEGORIES) if _CATEGORIES else "(none discovered)"
11390

11491
logger.info(
115-
"Docs MCP initialized: %d products across %d categories, %d allowed domains",
92+
"Docs MCP initialized: %d products across %d categories (allowed: %s)",
11693
len(_ENRICHED_SOURCES),
11794
len(_CATEGORIES),
118-
len(_DOMAINS),
95+
_ALLOWED_DOMAINS_DISPLAY,
11996
)
12097

12198

@@ -134,39 +111,15 @@ def _slug_for(entry: dict) -> Optional[str]:
134111
# Helpers
135112
# ----------------------------------------------------------------------------
136113

137-
_META_REFRESH_RE = re.compile(
138-
r'<meta http-equiv="refresh" content="[^;]+;\s*url=([^"]+)"',
139-
re.IGNORECASE,
140-
)
141-
142114

143-
def _url_allowed(url: str) -> bool:
144-
return any(url.startswith(d) for d in _DOMAINS)
145115

146116

147-
async def _fetch_with_processing(url: str) -> str:
148-
"""Fetch URL, follow meta-refresh once, inject transcript, markdownify."""
117+
async def _fetch(url: str) -> str:
118+
"""Fetch URL and return the raw response text."""
149119
try:
150120
response = await _HTTPX.get(url)
151121
response.raise_for_status()
152-
content = response.text
153-
154-
m = _META_REFRESH_RE.search(content)
155-
if m:
156-
new_url = urljoin(str(response.url), m.group(1))
157-
if not _url_allowed(new_url):
158-
return (
159-
f"Error: redirect URL not allowed. "
160-
f"Allowed domains: {', '.join(sorted(_DOMAINS))}"
161-
)
162-
response = await _HTTPX.get(new_url)
163-
response.raise_for_status()
164-
content = response.text
165-
166-
if url in _TRANSCRIPTS_MAP:
167-
content = content + "\n\n---\n\n" + _TRANSCRIPTS_MAP[url]
168-
169-
return markdownify(content)
122+
return response.text
170123
except (httpx.HTTPStatusError, httpx.RequestError) as e:
171124
return f"Encountered an HTTP error: {e}"
172125

@@ -255,11 +208,10 @@ async def explore_product(
255208
) -> str:
256209
"""Fetch the llms.txt index for a specific Juspay product.
257210
258-
Looks up the product slug in the discovered catalog and returns its
259-
documentation index as markdown. The index contains .md content links
260-
that you can read via doc_fetch_tool(url).
211+
Looks up the product slug in the catalog and returns the raw llms.txt
212+
content. The index contains .md content links readable via doc_fetch_tool(url).
261213
262-
Returns an error if the slug isn't in the catalog - call list_products()
214+
Returns an error if the slug isn't in the catalog call list_products()
263215
to see valid slugs.
264216
"""
265217
entry = _SLUG_INDEX.get(product)
@@ -274,7 +226,7 @@ async def explore_product(
274226
f"Call list_products() to see all slugs. "
275227
f"Examples: {sample}{more}."
276228
)
277-
return await _fetch_with_processing(entry["llms_txt"])
229+
return await _fetch(entry["llms_txt"])
278230

279231

280232
@mcp.tool()
@@ -283,25 +235,24 @@ async def doc_fetch_tool(
283235
str,
284236
Field(
285237
description=(
286-
"URL to fetch. Must be on an allowed domain (auto-derived "
287-
"from the discovered product catalog, typically juspay.io)."
238+
f"URL to fetch. Must be on an allowed domain: {_ALLOWED_DOMAINS_DISPLAY}."
288239
),
289240
),
290241
],
291242
) -> str:
292-
"""Fetch any allowed Juspay docs URL as markdown.
243+
"""Fetch any allowed Juspay docs URL and return its raw text content.
293244
294245
Use this after explore_product() to read specific pages by URL.
295-
Follows meta-refresh redirects once. Returns markdown-converted
296-
content. Returns an error if the URL is on a disallowed domain.
246+
Returns markdown mirror of the URL.
247+
Returns an error if the URL is on a disallowed domain.
297248
"""
298249
url = url.strip()
299250
if not _url_allowed(url):
300251
return (
301-
f"Error: URL not allowed. Must start with one of: "
302-
f"{', '.join(sorted(_DOMAINS))}"
252+
f"Error: URL not on an allowed domain. "
253+
f"Allowed: {_ALLOWED_DOMAINS_DISPLAY}"
303254
)
304-
return await _fetch_with_processing(url)
255+
return await _fetch(url)
305256

306257

307258
# Export the underlying low-level Server for main.py / stdio.py

juspay_docs_mcp/snapshot.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"fetched_at": "2026-05-15T10:02:42.687046Z",
2+
"fetched_at": "2026-05-19T13:47:55.391152Z",
33
"sources": [
44
{
55
"name": "Name: Juspay Billing\n\nEnd to end Mandate lifecycle. By using Billing, you treat Mandates as a configuration, not a codebase",

juspay_mcp/auth/middleware.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def __init__(
5656
cfg: OAuthConfig,
5757
portal: PortalClient,
5858
validation_cache: dict[str, tuple[PortalUserInfo, float]] | None = None,
59+
skip_path_prefixes: tuple[str, ...] = (),
5960
) -> None:
6061
super().__init__(app)
6162
self._cfg = cfg
@@ -66,6 +67,7 @@ def __init__(
6667
self._cache: dict[str, tuple[PortalUserInfo, float]] = (
6768
validation_cache if validation_cache is not None else {}
6869
)
70+
self._skip_path_prefixes = skip_path_prefixes
6971

7072
async def _validate_with_cache(self, token: str) -> PortalUserInfo | None:
7173
# Dev bypass: useful for curl-driven smoke tests so we don't need a
@@ -131,7 +133,12 @@ async def dispatch(
131133
request: Request,
132134
call_next: Callable[[Request], Awaitable[Response]],
133135
) -> Response:
134-
if _is_public_path(request.url.path):
136+
path = request.url.path
137+
if _is_public_path(path):
138+
return await call_next(request)
139+
if self._skip_path_prefixes and any(
140+
path == p or path.startswith(p + "/") for p in self._skip_path_prefixes
141+
):
135142
return await call_next(request)
136143

137144
auth_header = request.headers.get("authorization") or request.headers.get("Authorization")

juspay_mcp/auth/routes.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -348,16 +348,18 @@ async def revoke(request: Request) -> Response:
348348
return JSONResponse({"revoked": revoked})
349349

350350
# ---------- assemble ------------------------------------------------------
351+
_WELL_KNOWN_METHODS = ["GET", "OPTIONS"]
352+
351353
routes: list[Route] = [
352-
Route("/.well-known/oauth-protected-resource", endpoint=prm_root, methods=["GET"]),
354+
Route("/.well-known/oauth-protected-resource", endpoint=prm_root, methods=_WELL_KNOWN_METHODS),
353355
Route(
354356
"/{mount:str}/.well-known/oauth-protected-resource",
355357
endpoint=prm_for_mount,
356-
methods=["GET"],
358+
methods=_WELL_KNOWN_METHODS,
357359
),
358-
Route("/.well-known/oauth-authorization-server", endpoint=asm, methods=["GET"]),
359-
Route("/.well-known/openid-configuration", endpoint=asm, methods=["GET"]),
360-
Route("/.well-known/jwks.json", endpoint=jwks, methods=["GET"]),
360+
Route("/.well-known/oauth-authorization-server", endpoint=asm, methods=_WELL_KNOWN_METHODS),
361+
Route("/.well-known/openid-configuration", endpoint=asm, methods=_WELL_KNOWN_METHODS),
362+
Route("/.well-known/jwks.json", endpoint=jwks, methods=_WELL_KNOWN_METHODS),
361363
Route("/oauth/register", endpoint=register, methods=["POST"]),
362364
Route("/oauth/authorize", endpoint=authorize, methods=["GET"]),
363365
Route("/oauth/callback", endpoint=callback, methods=["GET"]),

0 commit comments

Comments
 (0)