bigwolfe commited on
Commit
d497a35
·
1 Parent(s): bc6b6db

mcp over http is failing

Browse files
backend/src/api/main.py CHANGED
@@ -5,10 +5,15 @@ from __future__ import annotations
5
  import logging
6
  from pathlib import Path
7
 
 
8
  from fastapi import FastAPI, HTTPException, Request
9
  from fastapi.middleware.cors import CORSMiddleware
10
  from fastapi.responses import JSONResponse
11
  from fastapi.staticfiles import StaticFiles
 
 
 
 
12
 
13
  from .routes import auth, index, notes, search
14
  from ..mcp.server import mcp
@@ -81,9 +86,49 @@ app.include_router(search.router, tags=["search"])
81
  app.include_router(index.router, tags=["index"])
82
 
83
  # Hosted MCP HTTP endpoint (mounted Starlette app)
84
- mcp_http_app = mcp.http_app(path="/", transport="http")
85
- app.mount("/mcp", mcp_http_app)
86
- logger.info("MCP HTTP endpoint mounted at /mcp")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
 
89
  @app.get("/health")
 
5
  import logging
6
  from pathlib import Path
7
 
8
+ import asyncio
9
  from fastapi import FastAPI, HTTPException, Request
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from fastapi.responses import JSONResponse
12
  from fastapi.staticfiles import StaticFiles
13
+ from fastapi.routing import ASGIRoute
14
+ from starlette.responses import Response
15
+
16
+ from fastmcp.server.streamable_http import StreamableHTTPSessionManager
17
 
18
  from .routes import auth, index, notes, search
19
  from ..mcp.server import mcp
 
86
  app.include_router(index.router, tags=["index"])
87
 
88
  # Hosted MCP HTTP endpoint (mounted Starlette app)
89
+
90
+ session_manager = StreamableHTTPSessionManager(
91
+ app=mcp._mcp_server,
92
+ event_store=None,
93
+ json_response=False,
94
+ stateless=True,
95
+ )
96
+
97
+
98
+ @app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
99
+ async def mcp_http_bridge(request: Request) -> Response:
100
+ """Forward HTTP requests to the FastMCP streamable HTTP session manager."""
101
+
102
+ send_queue: asyncio.Queue = asyncio.Queue()
103
+
104
+ async def send(message):
105
+ await send_queue.put(message)
106
+
107
+ await session_manager.handle_request(request.scope, request.receive, send)
108
+ await send_queue.put(None)
109
+
110
+ result_body = b""
111
+ headers = {}
112
+ status = 200
113
+
114
+ while True:
115
+ message = await send_queue.get()
116
+ if message is None:
117
+ break
118
+ msg_type = message["type"]
119
+ if msg_type == "http.response.start":
120
+ status = message.get("status", 200)
121
+ raw_headers = message.get("headers", [])
122
+ headers = {key.decode(): value.decode() for key, value in raw_headers}
123
+ elif msg_type == "http.response.body":
124
+ result_body += message.get("body", b"")
125
+ if not message.get("more_body"):
126
+ break
127
+
128
+ return Response(content=result_body, status_code=status, headers=headers)
129
+
130
+
131
+ logger.info("MCP HTTP endpoint mounted at /mcp via StreamableHTTPSessionManager")
132
 
133
 
134
  @app.get("/health")
backend/src/api/routes/auth.py CHANGED
@@ -5,17 +5,21 @@ from __future__ import annotations
5
  import logging
6
  import secrets
7
  import time
 
8
  from typing import Optional
9
  from urllib.parse import urlencode
10
 
11
  import httpx
12
  from fastapi import APIRouter, Depends, HTTPException, Query, Request
13
  from fastapi.responses import RedirectResponse
14
- from pydantic import BaseModel
15
 
 
 
 
16
  from ...services.config import get_config
17
  from ...services.seed import ensure_welcome_note
18
- from ..middleware import extract_user_id_from_jwt
 
19
 
20
  logger = logging.getLogger(__name__)
21
 
@@ -24,6 +28,8 @@ router = APIRouter()
24
  OAUTH_STATE_TTL_SECONDS = 300
25
  oauth_states: dict[str, float] = {}
26
 
 
 
27
 
28
  def _create_oauth_state() -> str:
29
  """Generate a state token and store it with a timestamp."""
@@ -46,14 +52,6 @@ def _consume_oauth_state(state: str | None) -> None:
46
  del oauth_states[state]
47
 
48
 
49
- class UserInfo(BaseModel):
50
- """Current user information."""
51
-
52
- user_id: str
53
- username: str
54
- email: Optional[str] = None
55
-
56
-
57
  def get_base_url(request: Request) -> str:
58
  """
59
  Get the base URL for OAuth redirects.
@@ -271,23 +269,43 @@ async def callback(
271
  )
272
 
273
 
274
- @router.get("/api/me", response_model=UserInfo)
275
- async def get_current_user(user_id: str = Depends(extract_user_id_from_jwt)):
276
- """Return basic profile data for the authenticated user."""
277
- if user_id == "local-dev":
278
- return UserInfo(user_id="local-dev", username="local-dev")
279
 
280
- # Derive a friendly username if possible
281
- username = user_id
282
- email = None
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  if user_id.startswith("hf-"):
285
  username = user_id[len("hf-") :]
 
 
 
 
 
 
 
286
 
287
- return UserInfo(
288
  user_id=user_id,
289
- username=username,
290
- email=email,
 
291
  )
292
 
293
 
 
5
  import logging
6
  import secrets
7
  import time
8
+ from datetime import datetime, timezone
9
  from typing import Optional
10
  from urllib.parse import urlencode
11
 
12
  import httpx
13
  from fastapi import APIRouter, Depends, HTTPException, Query, Request
14
  from fastapi.responses import RedirectResponse
 
15
 
16
+ from ...models.auth import TokenResponse
17
+ from ...models.user import HFProfile, User
18
+ from ...services.auth import AuthService
19
  from ...services.config import get_config
20
  from ...services.seed import ensure_welcome_note
21
+ from ...services.vault import VaultService
22
+ from ..middleware import AuthContext, get_auth_context
23
 
24
  logger = logging.getLogger(__name__)
25
 
 
28
  OAUTH_STATE_TTL_SECONDS = 300
29
  oauth_states: dict[str, float] = {}
30
 
31
+ auth_service = AuthService()
32
+
33
 
34
  def _create_oauth_state() -> str:
35
  """Generate a state token and store it with a timestamp."""
 
52
  del oauth_states[state]
53
 
54
 
 
 
 
 
 
 
 
 
55
  def get_base_url(request: Request) -> str:
56
  """
57
  Get the base URL for OAuth redirects.
 
269
  )
270
 
271
 
272
+ @router.post("/api/tokens", response_model=TokenResponse)
273
+ async def create_api_token(auth: AuthContext = Depends(get_auth_context)):
274
+ """Issue a new JWT for the authenticated user."""
275
+ token, expires_at = auth_service.issue_token_response(auth.user_id)
276
+ return TokenResponse(token=token, token_type="bearer", expires_at=expires_at)
277
 
 
 
 
278
 
279
+ @router.get("/api/me", response_model=User)
280
+ async def get_current_user(auth: AuthContext = Depends(get_auth_context)):
281
+ """Return profile metadata for the authenticated user."""
282
+ user_id = auth.user_id
283
+ vault_service = VaultService()
284
+ vault_path = vault_service.initialize_vault(user_id)
285
+
286
+ # Attempt to derive a stable "created" timestamp from the vault directory
287
+ try:
288
+ stat = vault_path.stat()
289
+ created_dt = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
290
+ except Exception:
291
+ created_dt = datetime.now(timezone.utc)
292
+
293
+ profile: Optional[HFProfile] = None
294
  if user_id.startswith("hf-"):
295
  username = user_id[len("hf-") :]
296
+ profile = HFProfile(
297
+ username=username,
298
+ name=username.replace("-", " ").title(),
299
+ avatar_url=f"https://api.dicebear.com/7.x/initials/svg?seed={username}",
300
+ )
301
+ elif user_id not in {"local-dev", "demo-user"}:
302
+ profile = HFProfile(username=user_id)
303
 
304
+ return User(
305
  user_id=user_id,
306
+ hf_profile=profile,
307
+ vault_path=str(vault_path),
308
+ created=created_dt,
309
  )
310
 
311
 
scripts/test_mcp_tools.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Exercise all MCP tools exposed by the hosted server."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import asyncio
8
+ import json
9
+ import os
10
+ import sys
11
+ import uuid
12
+ from typing import Any, Dict
13
+
14
+ from fastmcp.client import Client, StreamableHttpTransport
15
+ from fastmcp.client.client import ToolError
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(description="Test MCP HTTP tools end-to-end")
20
+ parser.add_argument(
21
+ "--url",
22
+ default=os.environ.get("MCP_URL", "https://bigwolfe-document-mcp.hf.space/mcp"),
23
+ help="Hosted MCP endpoint base URL",
24
+ )
25
+ parser.add_argument(
26
+ "--token",
27
+ default=os.environ.get("MCP_TOKEN"),
28
+ help="Bearer token for authentication (or set MCP_TOKEN env variable)",
29
+ )
30
+ parser.add_argument(
31
+ "--note",
32
+ default=f"mcp-test-{uuid.uuid4().hex}.md",
33
+ help="Temporary note path to create and delete during the test",
34
+ )
35
+ return parser
36
+
37
+
38
+ async def exercise_tools(url: str, token: str, note_path: str) -> Dict[str, Any]:
39
+ transport = StreamableHttpTransport(url=url, headers={"Authorization": f"Bearer {token}"})
40
+ async with Client(transport, name="multi-tenant-audit") as client:
41
+ results: Dict[str, Any] = {}
42
+
43
+ tools = await client.list_tools()
44
+ results["list_tools"] = [tool.name for tool in tools]
45
+
46
+ results["list_notes_before"] = await client.call_tool("list_notes", {})
47
+
48
+ # Read the welcome note if it exists
49
+ try:
50
+ results["read_note"] = await client.call_tool(
51
+ "read_note", {"path": "Welcome.md"}
52
+ )
53
+ except ToolError as exc:
54
+ results["read_note_error"] = str(exc)
55
+
56
+ # Create a temporary note
57
+ body = "# MCP Test\n\nThis note was created by the MCP HTTP audit script."
58
+ results["write_note"] = await client.call_tool(
59
+ "write_note",
60
+ {
61
+ "path": note_path,
62
+ "body": body,
63
+ "title": "MCP Test",
64
+ "metadata": {"tags": ["mcp", "audit"]},
65
+ },
66
+ )
67
+
68
+ # Search for the word "MCP"
69
+ results["search_notes"] = await client.call_tool(
70
+ "search_notes", {"query": "MCP", "limit": 10}
71
+ )
72
+
73
+ # Fetch backlinks for the welcome note (if present)
74
+ try:
75
+ results["get_backlinks"] = await client.call_tool(
76
+ "get_backlinks", {"path": "Welcome.md"}
77
+ )
78
+ except ToolError as exc:
79
+ results["get_backlinks_error"] = str(exc)
80
+
81
+ # Fetch tags
82
+ results["get_tags"] = await client.call_tool("get_tags", {})
83
+
84
+ # Delete the temporary note and show list afterwards
85
+ results["delete_note"] = await client.call_tool(
86
+ "delete_note", {"path": note_path}
87
+ )
88
+ results["list_notes_after"] = await client.call_tool("list_notes", {})
89
+
90
+ return results
91
+
92
+
93
+ def main() -> None:
94
+ parser = build_parser()
95
+ args = parser.parse_args()
96
+
97
+ if not args.token:
98
+ parser.error("Bearer token must be provided via --token or MCP_TOKEN env variable")
99
+
100
+ try:
101
+ results = asyncio.run(exercise_tools(args.url, args.token, args.note))
102
+ except Exception as exc: # pragma: no cover
103
+ print(f"Error exercising MCP tools: {exc}", file=sys.stderr)
104
+ raise SystemExit(1) from exc
105
+
106
+ json.dump(results, sys.stdout, indent=2, sort_keys=True)
107
+ print()
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()