Skip to content

Commit dd1f1ea

Browse files
committed
feat: add MCP
1 parent 0564013 commit dd1f1ea

File tree

10 files changed

+437
-192
lines changed

10 files changed

+437
-192
lines changed

app/actions/mcps.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,8 @@ mcps:
3939
config:
4040
type: http
4141
url: https://mcp.exa.ai/mcp?exa_api_key=${EXA_API_KEY}
42+
- display_name: GitRules
43+
slug: GitRules
44+
config:
45+
type: http
46+
url: https://gitrules.com/mcp

app/main.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from app.routes import install, actions
77
from app.services.actions_loader import actions_loader
88
from api_analytics.fastapi import Analytics
9+
from fastapi_mcp import FastApiMCP
910
import os
1011
from dotenv import load_dotenv
1112

@@ -28,12 +29,12 @@
2829
app.include_router(install.router)
2930
app.include_router(actions.router)
3031

31-
@app.get("/favicon.ico")
32+
@app.get("/favicon.ico", operation_id="get_favicon")
3233
async def favicon():
3334
favicon_path = static_dir / "favicon.ico"
3435
return FileResponse(favicon_path, media_type="image/x-icon")
3536

36-
@app.get("/", response_class=HTMLResponse)
37+
@app.get("/", response_class=HTMLResponse, operation_id="get_index_page")
3738
async def index(request: Request):
3839
# Get all actions data for server-side rendering
3940
agents = [agent.dict() for agent in actions_loader.get_agents()]
@@ -84,6 +85,16 @@ async def index(request: Request):
8485
}
8586
)
8687

87-
@app.get("/health")
88+
@app.get("/health", operation_id="health_check")
8889
async def health_check():
89-
return {"status": "healthy"}
90+
return {"status": "healthy"}
91+
92+
# Create MCP server that only exposes endpoints tagged with "mcp"
93+
mcp = FastApiMCP(
94+
app,
95+
name="gitrules-search",
96+
include_tags=["mcp"]
97+
)
98+
99+
# Mount the MCP server with HTTP/SSE transport
100+
mcp.mount_http(mount_path="/mcp")

app/routes/actions.py

Lines changed: 171 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
from fastapi import APIRouter, HTTPException, Body
1+
from fastapi import APIRouter, HTTPException, Body, Query
22
from app.models.actions import ActionsResponse, Agent, Rule, MCP
33
from app.services.actions_loader import actions_loader
44
from app.services.mcp_installer import get_agent_content, get_rule_content, create_mcp_config
5-
from typing import List, Dict, Any
5+
from app.services.search_service import search_service
6+
from typing import List, Dict, Any, Optional
67
import json
78

8-
router = APIRouter(prefix="/api/actions", tags=["actions"])
9+
router = APIRouter(prefix="/api", tags=["actions"])
910

10-
@router.get("/", response_model=ActionsResponse)
11+
@router.get("/actions", response_model=ActionsResponse, operation_id="get_all_actions_endpoint")
1112
async def get_all_actions():
1213
"""Get all available actions (agents, rules, MCPs)"""
1314
return ActionsResponse(
@@ -16,80 +17,52 @@ async def get_all_actions():
1617
mcps=actions_loader.get_mcps()
1718
)
1819

19-
@router.get("/agents", response_model=List[Agent])
20+
@router.get("/agents", operation_id="get_agents_endpoint")
2021
async def get_agents():
21-
"""Get all available agents"""
22-
return actions_loader.get_agents()
22+
"""Get all available agents with tags only"""
23+
agents = actions_loader.get_agents()
24+
return [
25+
{
26+
"name": agent.name,
27+
"display_name": agent.display_name,
28+
"slug": agent.slug,
29+
"tags": agent.tags,
30+
"filename": agent.filename
31+
}
32+
for agent in agents
33+
]
2334

24-
@router.get("/rules", response_model=List[Rule])
35+
@router.get("/rules", operation_id="get_rules_endpoint")
2536
async def get_rules():
26-
"""Get all available rules"""
27-
return actions_loader.get_rules()
37+
"""Get all available rules with tags only"""
38+
rules = actions_loader.get_rules()
39+
return [
40+
{
41+
"name": rule.name,
42+
"display_name": rule.display_name,
43+
"slug": rule.slug,
44+
"tags": rule.tags,
45+
"filename": rule.filename
46+
}
47+
for rule in rules
48+
]
2849

29-
@router.get("/mcps", response_model=List[MCP])
50+
@router.get("/mcps", operation_id="get_mcps_endpoint")
3051
async def get_mcps():
31-
"""Get all available MCPs"""
32-
return actions_loader.get_mcps()
52+
"""Get all available MCPs with tags only"""
53+
mcps = actions_loader.get_mcps()
54+
return [
55+
{
56+
"name": mcp.name,
57+
"tags": mcp.tags if hasattr(mcp, 'tags') else []
58+
}
59+
for mcp in mcps
60+
]
3361

34-
@router.get("/agent-content/{agent_id}")
35-
async def get_agent_content_endpoint(agent_id: str):
36-
"""Get agent content for virtual workspace"""
37-
agents = actions_loader.get_agents()
38-
# Match by slug first, fallback to name for backward compat
39-
agent = next((a for a in agents if (a.slug == agent_id or a.name == agent_id)), None)
40-
41-
if not agent:
42-
raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}")
43-
44-
# Get content directly from the agent object (already loaded from consolidated file)
45-
content = agent.content
46-
if not content:
47-
raise HTTPException(status_code=500, detail="Agent has no content")
48-
49-
return {
50-
"filename": agent.filename,
51-
"content": content,
52-
"path": f".claude/agents/{agent.filename}"
53-
}
5462

55-
@router.get("/rule-content/{rule_id}")
56-
async def get_rule_content_endpoint(rule_id: str):
57-
"""Get rule content to append to CLAUDE.md"""
58-
rules = actions_loader.get_rules()
59-
# Match by slug first, fallback to name for backward compat
60-
rule = next((r for r in rules if (r.slug == rule_id or r.name == rule_id)), None)
61-
62-
if not rule:
63-
raise HTTPException(status_code=404, detail=f"Rule not found: {rule_id}")
64-
65-
# Get content directly from the rule object (already loaded from consolidated file)
66-
content = rule.content
67-
if not content:
68-
raise HTTPException(status_code=500, detail="Rule has no content")
69-
70-
return {
71-
"content": content.strip()
72-
}
7363

74-
@router.post("/mcp-config/{mcp_name}")
75-
async def get_mcp_config_endpoint(mcp_name: str, current_config: Dict[str, Any] = Body(default={})):
76-
"""Get updated MCP config for virtual workspace"""
77-
mcps = actions_loader.get_mcps()
78-
mcp = next((m for m in mcps if m.name == mcp_name), None)
79-
80-
if not mcp:
81-
raise HTTPException(status_code=404, detail="MCP not found")
82-
83-
updated_config, was_removed = create_mcp_config(current_config, mcp.name, mcp.config)
84-
85-
return {
86-
"filename": ".mcp.json",
87-
"content": updated_config,
88-
"path": ".mcp.json",
89-
"was_removed": was_removed
90-
}
9164

92-
@router.get("/merged-block")
65+
@router.get("/merged-block", operation_id="get_merged_actions_block_endpoint")
9366
async def get_merged_actions_block():
9467
"""Get all actions merged into a single block with metadata for frontend"""
9568
agents = actions_loader.get_agents()
@@ -125,4 +98,132 @@ async def get_merged_actions_block():
12598
]
12699
}
127100

128-
return merged
101+
return merged
102+
103+
@router.get("/search/agents", tags=["mcp"], operation_id="search_agents_endpoint")
104+
async def search_agents(
105+
query: str = Query(..., description="Search query"),
106+
limit: int = Query(10, ge=1, le=100, description="Maximum number of results")
107+
):
108+
"""Search for agents by name, display_name, or content"""
109+
results = search_service.search_agents(query, limit)
110+
return {"results": results}
111+
112+
@router.get("/search/rules", tags=["mcp"], operation_id="search_rules_endpoint")
113+
async def search_rules(
114+
query: str = Query(..., description="Search query"),
115+
limit: int = Query(10, ge=1, le=100, description="Maximum number of results")
116+
):
117+
"""Search for rules by name, display_name, content, tags, or author"""
118+
results = search_service.search_rules(query, limit)
119+
return {"results": results}
120+
121+
@router.get("/search/mcps", tags=["mcp"], operation_id="search_mcps_endpoint")
122+
async def search_mcps(
123+
query: str = Query(..., description="Search query"),
124+
limit: int = Query(10, ge=1, le=100, description="Maximum number of results")
125+
):
126+
"""Search for MCPs by name or config content"""
127+
results = search_service.search_mcps(query, limit)
128+
return {"results": results}
129+
130+
@router.get("/search", tags=["mcp"], operation_id="search_all_endpoint")
131+
async def search_all(
132+
query: str = Query(..., description="Search query"),
133+
limit: int = Query(10, ge=1, le=100, description="Maximum number of results per category")
134+
):
135+
"""Search across all types (agents, rules, MCPs)"""
136+
return search_service.search_all(query, limit)
137+
138+
@router.get("/rules/{rule_ids}", tags=["mcp"], operation_id="get_multiple_rules_content")
139+
async def get_multiple_rules_content(rule_ids: str):
140+
"""Get content for multiple rules by comma-separated IDs/slugs"""
141+
ids = [id.strip() for id in rule_ids.split(',') if id.strip()]
142+
143+
if not ids:
144+
raise HTTPException(status_code=400, detail="No rule IDs provided")
145+
146+
rules = actions_loader.get_rules()
147+
results = []
148+
149+
for rule_id in ids:
150+
# Match by slug first, fallback to name for backward compat
151+
rule = next((r for r in rules if (r.slug == rule_id or r.name == rule_id)), None)
152+
153+
if rule:
154+
results.append({
155+
"id": rule_id,
156+
"slug": rule.slug,
157+
"name": rule.name,
158+
"display_name": rule.display_name,
159+
"content": rule.content,
160+
"filename": rule.filename
161+
})
162+
else:
163+
results.append({
164+
"id": rule_id,
165+
"error": f"Rule not found: {rule_id}"
166+
})
167+
168+
return {"rules": results}
169+
170+
@router.get("/agents/{agent_ids}", tags=["mcp"], operation_id="get_multiple_agents_content")
171+
async def get_multiple_agents_content(agent_ids: str):
172+
"""Get content for multiple agents by comma-separated IDs/slugs"""
173+
ids = [id.strip() for id in agent_ids.split(',') if id.strip()]
174+
175+
if not ids:
176+
raise HTTPException(status_code=400, detail="No agent IDs provided")
177+
178+
agents = actions_loader.get_agents()
179+
results = []
180+
181+
for agent_id in ids:
182+
# Match by slug first, fallback to name for backward compat
183+
agent = next((a for a in agents if (a.slug == agent_id or a.name == agent_id)), None)
184+
185+
if agent:
186+
results.append({
187+
"id": agent_id,
188+
"slug": agent.slug,
189+
"name": agent.name,
190+
"display_name": agent.display_name,
191+
"content": agent.content,
192+
"filename": agent.filename
193+
})
194+
else:
195+
results.append({
196+
"id": agent_id,
197+
"error": f"Agent not found: {agent_id}"
198+
})
199+
200+
return {"agents": results}
201+
202+
@router.get("/mcps/{mcp_ids}", tags=["mcp"], operation_id="get_multiple_mcps_config")
203+
async def get_multiple_mcps_config(mcp_ids: str):
204+
"""Get config for multiple MCPs by comma-separated names"""
205+
ids = [id.strip() for id in mcp_ids.split(',') if id.strip()]
206+
207+
if not ids:
208+
raise HTTPException(status_code=400, detail="No MCP IDs provided")
209+
210+
mcps = actions_loader.get_mcps()
211+
results = []
212+
213+
for mcp_id in ids:
214+
# Match by name
215+
mcp = next((m for m in mcps if m.name == mcp_id), None)
216+
217+
if mcp:
218+
results.append({
219+
"id": mcp_id,
220+
"name": mcp.name,
221+
"config": mcp.config
222+
})
223+
else:
224+
results.append({
225+
"id": mcp_id,
226+
"error": f"MCP not found: {mcp_id}"
227+
})
228+
229+
return {"mcps": results}

app/routes/install.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def extract_env_vars_from_files(files: Dict[str, str]) -> Set[str]:
2525
env_vars.update(matches)
2626
return env_vars
2727

28-
@router.post("/api/install")
28+
@router.post("/api/install", operation_id="create_install_script")
2929
async def create_install(request: Request, install: InstallCreate):
3030
"""Generate install script from files and store by hash"""
3131
# Extract unique directories
@@ -53,7 +53,7 @@ async def create_install(request: Request, install: InstallCreate):
5353

5454
return {"hash": content_hash}
5555

56-
@router.get("/api/install/{hash_id}.sh", response_class=PlainTextResponse)
56+
@router.get("/api/install/{hash_id}.sh", response_class=PlainTextResponse, operation_id="get_install_script")
5757
async def get_install(hash_id: str):
5858
"""Retrieve install by hash"""
5959
if hash_id not in installs_store:

0 commit comments

Comments
 (0)