Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: opencode

on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]

jobs:
opencode:
if: |
contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: read
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
model: opencode/claude-opus-4-5
99 changes: 99 additions & 0 deletions demo/registries/modal-demo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
{
"$comment": "MCP Gateway Demo - Modal Deployment (Remote servers only)",
"tools": [
{
"$comment": "=== CLOUDFLARE DOCS - Remote MCP server (public, no auth) ===",
"name": "search_cloudflare_docs",
"description": "Search Cloudflare documentation - returns XML-like structured results",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
},
"server": {"url": "https://docs.mcp.cloudflare.com/mcp", "transport": "streamablehttp"},
"originalName": "search_cloudflare_documentation"
},
{
"name": "cf_docs",
"source": "search_cloudflare_docs",
"description": "DEMO: Simplified alias for Cloudflare docs search"
},
{
"$comment": "=== CLOUDFLARE RADAR - OAuth-protected remote MCP server ===",
"name": "get_trending_domains",
"description": "Get trending domains from Cloudflare Radar (requires OAuth)",
"inputSchema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Number of results (default 10)"},
"rankingType": {
"type": "string",
"enum": ["POPULAR", "TRENDING_RISE", "TRENDING_STEADY"],
"description": "Ranking type"
}
}
},
"server": {
"url": "https://radar.mcp.cloudflare.com/mcp",
"transport": "streamablehttp",
"auth": "oauth"
},
"originalName": "get_domains_ranking"
},
{
"name": "trending_domains",
"source": "get_trending_domains",
"description": "DEMO: Trending domains with clean output - extracts domain names and rank changes",
"outputSchema": {
"type": "object",
"properties": {
"domains": {
"type": "array",
"source_field": "$.result.top_0[*]",
"items": {
"type": "object",
"properties": {
"rank": {"type": "integer", "source_field": "$.rank"},
"domain": {"type": "string", "source_field": "$.domain"},
"change": {"type": "number", "source_field": "$.pctRankChange"}
}
}
}
}
}
},
{
"name": "get_ip_info",
"description": "Get IP address information from Cloudflare Radar (requires OAuth)",
"inputSchema": {
"type": "object",
"properties": {
"ip": {"type": "string", "description": "IPv4 or IPv6 address"}
},
"required": ["ip"]
},
"server": {
"url": "https://radar.mcp.cloudflare.com/mcp",
"transport": "streamablehttp",
"auth": "oauth"
},
"originalName": "get_ip_details"
},
{
"name": "ip_lookup",
"source": "get_ip_info",
"description": "DEMO: Simplified IP lookup with projected output",
"outputSchema": {
"type": "object",
"properties": {
"ip": {"type": "string", "source_field": "$.ip"},
"asn": {"type": "integer", "source_field": "$.asn"},
"asn_name": {"type": "string", "source_field": "$.asnName"},
"location": {"type": "string", "source_field": "$.locationAlpha2"}
}
}
}
]
}
53 changes: 53 additions & 0 deletions demo/ui/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,12 +395,65 @@ def ChatPanel(messages: list = None, scenarios: list = None):
Option(name, value=key) for key, name in scenarios
])

# JavaScript for localStorage API key management
api_key_script = Script("""
// Load API key from localStorage on page load
document.addEventListener('DOMContentLoaded', function() {
const savedKey = localStorage.getItem('openrouter_api_key');
const input = document.getElementById('api-key-input');
if (savedKey && input) {
input.value = savedKey;
}
});

// Save API key to localStorage when changed
function saveApiKey(input) {
if (input.value) {
localStorage.setItem('openrouter_api_key', input.value);
} else {
localStorage.removeItem('openrouter_api_key');
}
}

// Add API key header to HTMX requests for chat
document.body.addEventListener('htmx:configRequest', function(evt) {
if (evt.detail.path === '/agent/send') {
const apiKey = localStorage.getItem('openrouter_api_key');
if (apiKey) {
evt.detail.headers['X-OpenRouter-Key'] = apiKey;
}
}
});
""")

# API key input section
api_key_section = Div(
Div(
UkIcon("key", height=14, width=14),
Span("OpenRouter API Key", cls="label-text"),
A("(get one)", href="https://openrouter.ai/keys", target="_blank", cls="api-key-link"),
cls="api-key-label"
),
Input(
type="password",
id="api-key-input",
placeholder="sk-or-...",
cls="api-key-input",
onchange="saveApiKey(this)",
oninput="saveApiKey(this)"
),
P("Stored in your browser only. Never sent to our server except for LLM calls.", cls="api-key-hint"),
cls="api-key-section"
)

return Div(
api_key_script,
Div(
UkIcon("bot", height=16, width=16),
Span("Agent Chat", cls="section-title"),
cls="section-header"
),
api_key_section,
Div(*message_elements, id="chat-messages", cls="chat-messages"),
Form(
Textarea(
Expand Down
80 changes: 56 additions & 24 deletions demo/ui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@
SCRIPT_DIR = Path(__file__).parent
GATEWAY_URL = os.environ.get("GATEWAY_URL", "http://localhost:8080")
REGISTRIES_DIR = Path(os.environ.get("REGISTRIES_DIR", SCRIPT_DIR.parent / "registries"))
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")

# OpenRouter configuration
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_MODEL = os.environ.get("OPENROUTER_MODEL", "anthropic/claude-3.5-haiku")

# FastHTML app setup with dark theme
hdrs = Theme.slate.headers(mode='dark') + [
Expand Down Expand Up @@ -487,8 +490,12 @@ async def get_scenario_prompt(request: Request):


@app.post("/agent/send")
async def send_to_agent(prompt: str):
"""Send a message to the AI agent."""
async def send_to_agent(request: Request, prompt: str):
"""Send a message to the AI agent via OpenRouter.

The API key is passed in the X-OpenRouter-Key header from the client's localStorage.
It's only used for this request and never logged or persisted server-side.
"""
global chat_messages

if not prompt.strip():
Expand All @@ -497,24 +504,18 @@ async def send_to_agent(prompt: str):
# Add user message
chat_messages.append({"content": prompt, "role": "user"})

# Check for API key
if not ANTHROPIC_API_KEY:
chat_messages.append({
"content": "ANTHROPIC_API_KEY not set. Please set it in docker-compose or environment.",
"role": "assistant"
})
# Get API key from header (sent from client's localStorage)
api_key = request.headers.get("X-OpenRouter-Key", "")

if not api_key:
error_msg = "Please enter your OpenRouter API key above. Get one at openrouter.ai/keys"
chat_messages.append({"content": error_msg, "role": "assistant"})
return Div(
ChatMessage(prompt, "user"),
ChatMessage(chat_messages[-1]["content"], "assistant")
ChatMessage(error_msg, "assistant")
)

# For now, return a placeholder - full agent integration would use Claude Agent SDK
# This is a simplified version that demonstrates the UI
try:
import anthropic

client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)

# Get available tools from registry
tools_desc = "\n".join([
f"- {t['name']}: {t.get('description', 'No description')}"
Expand All @@ -527,23 +528,54 @@ async def send_to_agent(prompt: str):

When using tools, explain what you're doing. Be concise."""

response = client.messages.create(
model="claude-3-5-haiku-20241022",
max_tokens=1024,
system=system,
messages=[{"role": "user", "content": prompt}]
)
# Call OpenRouter API (OpenAI-compatible)
async with httpx.AsyncClient() as client:
response = await client.post(
f"{OPENROUTER_BASE_URL}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"HTTP-Referer": GATEWAY_URL, # Required by OpenRouter
"X-Title": "MCP Gateway Demo",
},
json={
"model": OPENROUTER_MODEL,
"max_tokens": 1024,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt}
]
},
timeout=60.0
)

if response.status_code != 200:
error_data = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
error_msg = error_data.get("error", {}).get("message", f"OpenRouter error: {response.status_code}")
chat_messages.append({"content": error_msg, "role": "assistant"})
return Div(
ChatMessage(prompt, "user"),
ChatMessage(error_msg, "assistant")
)

assistant_content = response.content[0].text
result = response.json()
assistant_content = result["choices"][0]["message"]["content"]
chat_messages.append({"content": assistant_content, "role": "assistant"})

return Div(
ChatMessage(prompt, "user"),
ChatMessage(assistant_content, "assistant")
)

except httpx.TimeoutException:
error_msg = "Request timed out. Please try again."
chat_messages.append({"content": error_msg, "role": "assistant"})
return Div(
ChatMessage(prompt, "user"),
ChatMessage(error_msg, "assistant")
)
except Exception as e:
error_msg = f"Error: {str(e)}"
# Don't log the full exception to avoid leaking API key in stack traces
error_msg = f"Error: {type(e).__name__}: {str(e)}"
chat_messages.append({"content": error_msg, "role": "assistant"})
return Div(
ChatMessage(prompt, "user"),
Expand Down
1 change: 0 additions & 1 deletion demo/ui/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ dependencies = [
"monsterui>=0.1.0",
"httpx>=0.27.0",
"sse-starlette>=1.6.0",
"anthropic>=0.40.0",
]

[build-system]
Expand Down
54 changes: 54 additions & 0 deletions demo/ui/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,60 @@ html, body {
flex-direction: column;
}

/* API Key Section */
.api-key-section {
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-tertiary);
}

.api-key-label {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
font-size: 0.75rem;
color: var(--text-secondary);
}

.api-key-link {
color: var(--accent-primary);
text-decoration: none;
font-size: 0.7rem;
}

.api-key-link:hover {
color: var(--accent-primary-hover);
text-decoration: underline;
}

.api-key-input {
width: 100%;
padding: var(--space-sm) var(--space-md);
background: var(--bg-primary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.8rem;
}

.api-key-input:focus {
outline: none;
border-color: var(--accent-primary);
}

.api-key-input::placeholder {
color: var(--text-muted);
}

.api-key-hint {
margin-top: var(--space-xs);
font-size: 0.65rem;
color: var(--text-muted);
line-height: 1.4;
}

.chat-messages {
flex: 1;
overflow-y: auto;
Expand Down
Loading
Loading