Skip to content

Commit e6d0c3d

Browse files
Userclaude
andcommitted
feat: Add MCP (Model Context Protocol) host backend implementation
- Add MCP server models and schemas for storing server configurations - Implement MCP connection manager supporting both stdio and HTTP+SSE transports - Create REST API endpoints for managing MCP servers (CRUD operations) - Add WebSocket endpoint for real-time MCP communication - Implement tool calling, resource fetching, and prompt retrieval - Add database migration for mcp_servers table with JSON fields - Support both local (stdio) and remote (HTTP+SSE) MCP servers - Add aiohttp dependency for HTTP client functionality This backend implementation provides a foundation for browser-based MCP hosting, allowing users to connect to and interact with MCP servers similar to Claude Desktop. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 19e3c63 commit e6d0c3d

File tree

10 files changed

+986
-1
lines changed

10 files changed

+986
-1
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Add MCP server table
2+
3+
Revision ID: 2025_05_27_0402
4+
Revises: 2025_05_26_1343
5+
Create Date: 2025-05-27 04:02:50.892296
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
from sqlalchemy.dialects import postgresql
13+
import sqlmodel
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = '2025_05_27_0402'
17+
down_revision: Union[str, None] = '2025_05_26_1343'
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
# Create enum type for transport
24+
op.execute("CREATE TYPE mcptransporttype AS ENUM ('stdio', 'http_sse')")
25+
26+
# Create mcp_servers table
27+
op.create_table('mcp_servers',
28+
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
29+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
30+
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
31+
sa.Column('name', sa.String(), nullable=False),
32+
sa.Column('description', sa.String(), nullable=True),
33+
sa.Column('transport', sa.Enum('stdio', 'http_sse', name='mcptransporttype'), nullable=False),
34+
sa.Column('command', sa.String(), nullable=True),
35+
sa.Column('args', postgresql.JSON(astext_type=sa.Text()), nullable=True),
36+
sa.Column('url', sa.String(), nullable=True),
37+
sa.Column('headers', postgresql.JSON(astext_type=sa.Text()), nullable=True),
38+
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='true'),
39+
sa.Column('is_remote', sa.Boolean(), nullable=False, server_default='false'),
40+
sa.Column('capabilities', postgresql.JSON(astext_type=sa.Text()), nullable=True),
41+
sa.Column('tools', postgresql.JSON(astext_type=sa.Text()), nullable=True),
42+
sa.Column('resources', postgresql.JSON(astext_type=sa.Text()), nullable=True),
43+
sa.Column('prompts', postgresql.JSON(astext_type=sa.Text()), nullable=True),
44+
sa.PrimaryKeyConstraint('id')
45+
)
46+
op.create_index(op.f('ix_mcp_servers_id'), 'mcp_servers', ['id'], unique=False)
47+
op.create_index(op.f('ix_mcp_servers_name'), 'mcp_servers', ['name'], unique=True)
48+
49+
50+
def downgrade() -> None:
51+
# Drop table and indexes
52+
op.drop_index(op.f('ix_mcp_servers_name'), table_name='mcp_servers')
53+
op.drop_index(op.f('ix_mcp_servers_id'), table_name='mcp_servers')
54+
op.drop_table('mcp_servers')
55+
56+
# Drop enum type
57+
op.execute("DROP TYPE mcptransporttype")

backend/app/api/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from fastapi import APIRouter
22

3-
from app.api.routes import items, login, private, users, utils
3+
from app.api.routes import items, login, private, users, utils, mcp
44
from app.api.routes.auth.router import router as auth_router
55
from app.core.config import settings
66

@@ -14,6 +14,7 @@
1414
api_router.include_router(users.router, prefix="/users", tags=["users"])
1515
api_router.include_router(utils.router, prefix="/utils", tags=["utils"])
1616
api_router.include_router(items.router, prefix="/items", tags=["items"])
17+
api_router.include_router(mcp.router, prefix="/mcp", tags=["mcp"])
1718

1819
# Include private routes in local environment
1920
if settings.ENVIRONMENT == "local":

backend/app/api/routes/mcp.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
"""API routes for MCP server management."""
2+
from typing import List, Any
3+
from fastapi import APIRouter, HTTPException, Depends
4+
from sqlmodel import Session, select
5+
from app.api.deps import get_current_active_user, get_current_active_superuser, get_db
6+
from app.models import User, MCPServer, MCPServerCreate, MCPServerUpdate, MCPServerPublic
7+
from app.services.mcp_manager import mcp_manager
8+
import logging
9+
10+
logger = logging.getLogger(__name__)
11+
12+
router = APIRouter()
13+
14+
15+
@router.get("/servers", response_model=List[MCPServerPublic])
16+
async def list_mcp_servers(
17+
*,
18+
session: Session = Depends(get_db),
19+
current_user: User = Depends(get_current_active_user),
20+
) -> List[MCPServerPublic]:
21+
"""List all MCP servers."""
22+
servers = session.exec(select(MCPServer)).all()
23+
24+
# Update runtime data from manager
25+
result = []
26+
for server in servers:
27+
server_dict = server.model_dump()
28+
if server.name in mcp_manager.connections:
29+
connection = mcp_manager.connections[server.name]
30+
server_dict["status"] = connection.status
31+
server_dict["error_message"] = connection.error_message
32+
else:
33+
server_dict["status"] = MCPServerStatus.DISCONNECTED
34+
server_dict["error_message"] = None
35+
result.append(MCPServerPublic(**server_dict))
36+
37+
return result
38+
39+
40+
@router.post("/servers", response_model=MCPServerPublic)
41+
async def create_mcp_server(
42+
*,
43+
session: Session = Depends(get_db),
44+
current_user: User = Depends(get_current_active_superuser),
45+
server_in: MCPServerCreate,
46+
) -> MCPServerPublic:
47+
"""Create a new MCP server configuration."""
48+
# Check if server with same name exists
49+
existing = session.exec(select(MCPServer).where(MCPServer.name == server_in.name)).first()
50+
if existing:
51+
raise HTTPException(status_code=400, detail="Server with this name already exists")
52+
53+
server = MCPServer.model_validate(server_in)
54+
session.add(server)
55+
session.commit()
56+
session.refresh(server)
57+
58+
# Auto-connect if enabled
59+
if server.is_enabled:
60+
try:
61+
await mcp_manager.connect_server(server)
62+
except Exception as e:
63+
logger.error(f"Failed to connect to server {server.name}: {e}")
64+
65+
return server
66+
67+
68+
@router.get("/servers/{server_id}", response_model=MCPServerPublic)
69+
async def get_mcp_server(
70+
*,
71+
session: Session = Depends(get_db),
72+
current_user: User = Depends(get_current_active_user),
73+
server_id: str,
74+
) -> MCPServerPublic:
75+
"""Get MCP server by ID."""
76+
server = session.get(MCPServer, server_id)
77+
if not server:
78+
raise HTTPException(status_code=404, detail="Server not found")
79+
80+
# Update runtime data from manager
81+
server_dict = server.model_dump()
82+
if server.name in mcp_manager.connections:
83+
connection = mcp_manager.connections[server.name]
84+
server_dict["status"] = connection.status
85+
server_dict["error_message"] = connection.error_message
86+
else:
87+
server_dict["status"] = MCPServerStatus.DISCONNECTED
88+
server_dict["error_message"] = None
89+
90+
return MCPServerPublic(**server_dict)
91+
92+
93+
@router.patch("/servers/{server_id}", response_model=MCPServerPublic)
94+
async def update_mcp_server(
95+
*,
96+
session: Session = Depends(get_db),
97+
current_user: User = Depends(get_current_active_superuser),
98+
server_id: str,
99+
server_in: MCPServerUpdate,
100+
) -> MCPServerPublic:
101+
"""Update MCP server configuration."""
102+
server = session.get(MCPServer, server_id)
103+
if not server:
104+
raise HTTPException(status_code=404, detail="Server not found")
105+
106+
# Disconnect if connected
107+
if server.name in mcp_manager.connections:
108+
await mcp_manager.disconnect_server(server.name)
109+
110+
# Update server
111+
update_data = server_in.model_dump(exclude_unset=True)
112+
for key, value in update_data.items():
113+
setattr(server, key, value)
114+
115+
session.add(server)
116+
session.commit()
117+
session.refresh(server)
118+
119+
# Reconnect if enabled
120+
if server.is_enabled:
121+
try:
122+
await mcp_manager.connect_server(server)
123+
except Exception as e:
124+
logger.error(f"Failed to connect to server {server.name}: {e}")
125+
126+
return server
127+
128+
129+
@router.delete("/servers/{server_id}")
130+
async def delete_mcp_server(
131+
*,
132+
session: Session = Depends(get_db),
133+
current_user: User = Depends(get_current_active_superuser),
134+
server_id: str,
135+
) -> dict:
136+
"""Delete MCP server."""
137+
server = session.get(MCPServer, server_id)
138+
if not server:
139+
raise HTTPException(status_code=404, detail="Server not found")
140+
141+
# Disconnect if connected
142+
if server.name in mcp_manager.connections:
143+
await mcp_manager.disconnect_server(server.name)
144+
145+
session.delete(server)
146+
session.commit()
147+
148+
return {"message": "Server deleted successfully"}
149+
150+
151+
@router.post("/servers/{server_id}/connect")
152+
async def connect_mcp_server(
153+
*,
154+
session: Session = Depends(get_db),
155+
current_user: User = Depends(get_current_active_user),
156+
server_id: str,
157+
) -> dict:
158+
"""Connect to an MCP server."""
159+
server = session.get(MCPServer, server_id)
160+
if not server:
161+
raise HTTPException(status_code=404, detail="Server not found")
162+
163+
if not server.is_enabled:
164+
raise HTTPException(status_code=400, detail="Server is disabled")
165+
166+
try:
167+
success = await mcp_manager.connect_server(server)
168+
if success:
169+
# Update server with discovered capabilities
170+
session.add(server)
171+
session.commit()
172+
return {"message": "Connected successfully", "status": mcp_manager.connections[server.name].status.value}
173+
else:
174+
connection = mcp_manager.connections.get(server.name)
175+
error_msg = connection.error_message if connection else "Failed to connect"
176+
raise HTTPException(status_code=500, detail=error_msg)
177+
except Exception as e:
178+
raise HTTPException(status_code=500, detail=str(e))
179+
180+
181+
@router.post("/servers/{server_id}/disconnect")
182+
async def disconnect_mcp_server(
183+
*,
184+
session: Session = Depends(get_db),
185+
current_user: User = Depends(get_current_active_user),
186+
server_id: str,
187+
) -> dict:
188+
"""Disconnect from an MCP server."""
189+
server = session.get(MCPServer, server_id)
190+
if not server:
191+
raise HTTPException(status_code=404, detail="Server not found")
192+
193+
if server.name not in mcp_manager.connections:
194+
raise HTTPException(status_code=400, detail="Server not connected")
195+
196+
await mcp_manager.disconnect_server(server.name)
197+
return {"message": "Disconnected successfully"}
198+
199+
200+
@router.post("/servers/{server_id}/tools/{tool_name}/call")
201+
async def call_mcp_tool(
202+
*,
203+
session: Session = Depends(get_db),
204+
current_user: User = Depends(get_current_active_user),
205+
server_id: str,
206+
tool_name: str,
207+
arguments: dict[str, Any],
208+
) -> Any:
209+
"""Call a tool on an MCP server."""
210+
server = session.get(MCPServer, server_id)
211+
if not server:
212+
raise HTTPException(status_code=404, detail="Server not found")
213+
214+
if server.name not in mcp_manager.connections:
215+
raise HTTPException(status_code=400, detail="Server not connected")
216+
217+
try:
218+
result = await mcp_manager.call_tool(server.name, tool_name, arguments)
219+
return result
220+
except Exception as e:
221+
raise HTTPException(status_code=500, detail=str(e))
222+
223+
224+
@router.get("/servers/{server_id}/resources/{uri:path}")
225+
async def get_mcp_resource(
226+
*,
227+
session: Session = Depends(get_db),
228+
current_user: User = Depends(get_current_active_user),
229+
server_id: str,
230+
uri: str,
231+
) -> Any:
232+
"""Get a resource from an MCP server."""
233+
server = session.get(MCPServer, server_id)
234+
if not server:
235+
raise HTTPException(status_code=404, detail="Server not found")
236+
237+
if server.name not in mcp_manager.connections:
238+
raise HTTPException(status_code=400, detail="Server not connected")
239+
240+
try:
241+
result = await mcp_manager.get_resource(server.name, uri)
242+
return result
243+
except Exception as e:
244+
raise HTTPException(status_code=500, detail=str(e))
245+
246+
247+
@router.post("/servers/{server_id}/prompts/{prompt_name}")
248+
async def get_mcp_prompt(
249+
*,
250+
session: Session = Depends(get_db),
251+
current_user: User = Depends(get_current_active_user),
252+
server_id: str,
253+
prompt_name: str,
254+
arguments: dict[str, Any],
255+
) -> Any:
256+
"""Get a prompt from an MCP server."""
257+
server = session.get(MCPServer, server_id)
258+
if not server:
259+
raise HTTPException(status_code=404, detail="Server not found")
260+
261+
if server.name not in mcp_manager.connections:
262+
raise HTTPException(status_code=400, detail="Server not connected")
263+
264+
try:
265+
result = await mcp_manager.get_prompt(server.name, prompt_name, arguments)
266+
return result
267+
except Exception as e:
268+
raise HTTPException(status_code=500, detail=str(e))

0 commit comments

Comments
 (0)