fix: rewrite to MCP Streamable HTTP (JSON-RPC on /) with Bearer auth
Some checks are pending
Check Python Version Consistency / Check Python Version (push) Waiting to run
Some checks are pending
Check Python Version Consistency / Check Python Version (push) Waiting to run
This commit is contained in:
parent
9d0f45cdd6
commit
303f190c58
|
|
@ -1,40 +1,56 @@
|
|||
"""opax-mcp — MCP tool server for OPAX/Vauco
|
||||
Transport: HTTP (FastAPI + uvicorn) for Cloud Run
|
||||
Auth: Cloud Run IAM (Authorization header) + X-MCP-Secret header for tool-level auth
|
||||
"""opax-mcp — MCP Streamable HTTP server for OPAX/Vauco
|
||||
Transport: MCP Streamable HTTP (JSON-RPC 2.0) on POST /
|
||||
Auth: Authorization: Bearer <secret> OR X-MCP-Secret: <secret> OR api-key: <secret>
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import httpx
|
||||
import base64
|
||||
import google.auth
|
||||
import google.auth.transport.requests
|
||||
from fastapi import FastAPI, HTTPException, Header
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing import Any, Optional
|
||||
|
||||
app = FastAPI(title="opax-mcp", version="2.0.0")
|
||||
|
||||
OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no")
|
||||
OPAX_IAP_CLIENT_ID = os.environ.get("OPAX_IAP_CLIENT_ID", "")
|
||||
MCP_SECRET = os.environ.get("MCP_SECRET", "")
|
||||
|
||||
# Gitea
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "http://34.59.131.162:3000")
|
||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||
GITEA_REPO = os.environ.get("GITEA_REPO", "chris/OSVauco")
|
||||
app = FastAPI(title="opax-mcp", version="3.0.0")
|
||||
|
||||
OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no")
|
||||
OPAX_IAP_CLIENT_ID = os.environ.get("OPAX_IAP_CLIENT_ID", "")
|
||||
MCP_SECRET = os.environ.get("MCP_SECRET", "")
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "http://34.59.131.162:3000")
|
||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||
GITEA_REPO = os.environ.get("GITEA_REPO", "chris/OSVauco")
|
||||
GOOGLE_CLOUD_PROJECT = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
|
||||
|
||||
|
||||
def _auth_check(x_mcp_secret: Optional[str]):
|
||||
if MCP_SECRET and x_mcp_secret != MCP_SECRET:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _verify_auth(request: Request):
|
||||
if not MCP_SECRET:
|
||||
return
|
||||
# Authorization: Bearer <token>
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer ") and auth[7:] == MCP_SECRET:
|
||||
return
|
||||
# X-MCP-Secret or api-key
|
||||
if request.headers.get("X-MCP-Secret") == MCP_SECRET:
|
||||
return
|
||||
if request.headers.get("api-key") == MCP_SECRET:
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OPAX helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _opax_identity_token() -> str:
|
||||
"""Hent identity token for opax.vauco.no (Cloud Run IAP/IAM)."""
|
||||
metadata_url = (
|
||||
"http://metadata.google.internal/computeMetadata/v1/instance"
|
||||
f"/service-accounts/default/identity?audience={OPAX_IAP_CLIENT_ID or OPAX_BASE_URL}&format=full"
|
||||
f"/service-accounts/default/identity?audience={OPAX_IAP_CLIENT_ID or OPAX_BASE_URL}&format=full"
|
||||
)
|
||||
try:
|
||||
resp = httpx.get(metadata_url, headers={"Metadata-Flavor": "Google"}, timeout=5)
|
||||
|
|
@ -48,106 +64,54 @@ def _opax_identity_token() -> str:
|
|||
|
||||
|
||||
def _opax_headers() -> dict:
|
||||
return {
|
||||
"Authorization": f"Bearer {_opax_identity_token()}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return {"Authorization": f"Bearer {_opax_identity_token()}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "opax-mcp", "version": "2.0.0", "git": "gitea"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool request/response schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ToolRequest(BaseModel):
|
||||
tool: str
|
||||
params: dict = {}
|
||||
|
||||
|
||||
@app.post("/tools/call")
|
||||
async def call_tool(
|
||||
req: ToolRequest,
|
||||
x_mcp_secret: Optional[str] = Header(default=None, alias="X-MCP-Secret"),
|
||||
api_key: Optional[str] = Header(default=None, alias="api-key")
|
||||
):
|
||||
_auth_check(x_mcp_secret or api_key)
|
||||
handler = TOOLS.get(req.tool)
|
||||
if not handler:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown tool: {req.tool}")
|
||||
result = await handler(req.params)
|
||||
return {"tool": req.tool, "result": result}
|
||||
|
||||
|
||||
@app.get("/tools")
|
||||
async def list_tools(
|
||||
x_mcp_secret: Optional[str] = Header(default=None, alias="X-MCP-Secret"),
|
||||
api_key: Optional[str] = Header(default=None, alias="api-key")
|
||||
):
|
||||
_auth_check(x_mcp_secret or api_key)
|
||||
return {"tools": list(TOOLS.keys())}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — OPAX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _opax_get(path: str) -> Any:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.get(f"{OPAX_BASE_URL}{path}", headers=_opax_headers())
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.get(f"{OPAX_BASE_URL}{path}", headers=_opax_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _opax_post(path: str, body: dict) -> Any:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(f"{OPAX_BASE_URL}{path}", json=body, headers=_opax_headers())
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(f"{OPAX_BASE_URL}{path}", json=body, headers=_opax_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — Gitea
|
||||
# Gitea helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _gitea_headers() -> dict:
|
||||
return {
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
return {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json", "Accept": "application/json"}
|
||||
|
||||
|
||||
async def _gitea_get(path: str) -> Any:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.get(f"{GITEA_URL}/api/v1{path}", headers=_gitea_headers())
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.get(f"{GITEA_URL}/api/v1{path}", headers=_gitea_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _gitea_post(path: str, body: dict) -> Any:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers())
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _gitea_put(path: str, body: dict) -> Any:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.put(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers())
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.put(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Billing tools
|
||||
# Tool implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_billing_summary(p): return await _opax_get("/billing/summary")
|
||||
|
|
@ -157,82 +121,36 @@ async def get_billing_anomalies(p): return await _opax_get("/billing/anom
|
|||
async def get_billing_history(p): return await _opax_get("/billing/history")
|
||||
async def get_billing_budget(p): return await _opax_get("/billing/budget")
|
||||
async def get_billing_tokens_by_module(p): return await _opax_get("/telemetry/history")
|
||||
|
||||
async def set_billing_budget(p):
|
||||
return await _opax_post("/billing/budget", {
|
||||
"budget": p.get("amount", 500)
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Onboarding tools
|
||||
# ---------------------------------------------------------------------------
|
||||
return await _opax_post("/billing/budget", {"budget": p.get("amount", 500)})
|
||||
|
||||
async def create_invite(p):
|
||||
return await _opax_post("/onboard/invite", {
|
||||
"company": p.get("company", p.get("name", "")),
|
||||
"email": p.get("email"),
|
||||
"tier": p.get("tier", "starter")
|
||||
"tier": p.get("tier", "starter"),
|
||||
})
|
||||
|
||||
async def list_customers(p):
|
||||
"""Hent onboardede kunder frå Firestore via OPAX state."""
|
||||
return await _opax_get("/state")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notify tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def list_customers(p): return await _opax_get("/state")
|
||||
async def send_webhook(p):
|
||||
return await _opax_post("/notify/webhook", {
|
||||
"url": p.get("url", ""),
|
||||
"event": p.get("event", "custom"),
|
||||
"title": p.get("title", "OPAX varsel"),
|
||||
"body": p.get("message", p.get("body", "")),
|
||||
"url": p.get("url", ""), "event": p.get("event", "custom"),
|
||||
"title": p.get("title", "OPAX varsel"), "body": p.get("message", p.get("body", "")),
|
||||
})
|
||||
|
||||
async def send_email(p):
|
||||
return await _opax_post("/notify/email", {
|
||||
"to": p.get("to"),
|
||||
"subject": p.get("subject"),
|
||||
"event": p.get("event", "digest"),
|
||||
})
|
||||
return await _opax_post("/notify/email", {"to": p.get("to"), "subject": p.get("subject"), "event": p.get("event", "digest")})
|
||||
|
||||
async def send_sms(p):
|
||||
return await _opax_post("/notify/sms", {
|
||||
"to": p.get("to"),
|
||||
"body": p.get("message", p.get("body", "")),
|
||||
"event": p.get("event", "spike"),
|
||||
"tier": p.get("tier", "guard"),
|
||||
})
|
||||
return await _opax_post("/notify/sms", {"to": p.get("to"), "body": p.get("message", p.get("body", "")), "event": p.get("event", "spike"), "tier": p.get("tier", "guard")})
|
||||
|
||||
async def get_notify_channels(p):
|
||||
return await _opax_get("/notify/channels")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent tools
|
||||
# ---------------------------------------------------------------------------
|
||||
async def get_notify_channels(p): return await _opax_get("/notify/channels")
|
||||
|
||||
async def run_jason(p):
|
||||
return await _opax_post("/run", {
|
||||
"message": p.get("prompt", p.get("message", "")),
|
||||
"user_id": p.get("user_id", "opax"),
|
||||
"session_id": p.get("session_id", "mcp"),
|
||||
"mode": p.get("mode", "light"),
|
||||
})
|
||||
return await _opax_post("/run", {"message": p.get("prompt", p.get("message", "")), "user_id": p.get("user_id", "opax"), "session_id": p.get("session_id", "mcp"), "mode": p.get("mode", "light")})
|
||||
|
||||
async def run_emma(p):
|
||||
return await _opax_post("/emma", {
|
||||
"message": p.get("prompt", p.get("message", "")),
|
||||
"session_id": p.get("session_id", "mcp"),
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform tools
|
||||
# ---------------------------------------------------------------------------
|
||||
return await _opax_post("/emma", {"message": p.get("prompt", p.get("message", "")), "session_id": p.get("session_id", "mcp")})
|
||||
|
||||
async def get_health(p): return await _opax_get("/health")
|
||||
async def get_build_status(p): return await _opax_get("/opax/build-status")
|
||||
|
|
@ -240,47 +158,25 @@ async def get_state(p): return await _opax_get("/state")
|
|||
async def get_telemetry(p): return await _opax_get("/telemetry/history")
|
||||
|
||||
async def run_terminal(p):
|
||||
return await _opax_post("/terminal/exec", {
|
||||
"cmd": p.get("command", p.get("cmd", "help"))
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea tools (erstatter GitHub)
|
||||
# ---------------------------------------------------------------------------
|
||||
return await _opax_post("/terminal/exec", {"cmd": p.get("command", p.get("cmd", "help"))})
|
||||
|
||||
async def list_commits(p):
|
||||
repo = p.get("repo", GITEA_REPO)
|
||||
limit = p.get("limit", 10)
|
||||
return await _gitea_get(f"/repos/{repo}/commits?limit={limit}")
|
||||
return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/commits?limit={p.get('limit', 10)}")
|
||||
|
||||
async def get_file(p):
|
||||
repo = p.get("repo", GITEA_REPO)
|
||||
path = p.get("path", "")
|
||||
ref = p.get("ref", "main")
|
||||
return await _gitea_get(f"/repos/{repo}/contents/{path}?ref={ref}")
|
||||
return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/contents/{p.get('path', '')}?ref={p.get('ref', 'main')}")
|
||||
|
||||
async def list_open_issues(p):
|
||||
repo = p.get("repo", GITEA_REPO)
|
||||
return await _gitea_get(f"/repos/{repo}/issues?state=open&limit=20")
|
||||
return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/issues?state=open&limit=20")
|
||||
|
||||
async def create_issue(p):
|
||||
repo = p.get("repo", GITEA_REPO)
|
||||
return await _gitea_post(f"/repos/{repo}/issues", {
|
||||
"title": p.get("title"),
|
||||
"body": p.get("body", ""),
|
||||
})
|
||||
return await _gitea_post(f"/repos/{p.get('repo', GITEA_REPO)}/issues", {"title": p.get("title"), "body": p.get("body", "")})
|
||||
|
||||
async def push_file(p):
|
||||
"""Opprett eller oppdater ein fil i Gitea."""
|
||||
repo = p.get("repo", GITEA_REPO)
|
||||
path = p.get("path")
|
||||
content = base64.b64encode(p.get("content", "").encode()).decode()
|
||||
body = {
|
||||
"message": p.get("message", f"chore: oppdater {path} via opax-mcp"),
|
||||
"content": content,
|
||||
"branch": p.get("branch", "main"),
|
||||
}
|
||||
body = {"message": p.get("message", f"chore: oppdater {path} via opax-mcp"), "content": content, "branch": p.get("branch", "main")}
|
||||
if p.get("sha"):
|
||||
body["sha"] = p["sha"]
|
||||
return await _gitea_put(f"/repos/{repo}/contents/{path}", body)
|
||||
|
|
@ -288,40 +184,117 @@ async def push_file(p):
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool registry
|
||||
# Tool registry + MCP schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOLS = {
|
||||
# Billing
|
||||
"get_billing_summary": get_billing_summary,
|
||||
"get_billing_forecast": get_billing_forecast,
|
||||
"get_billing_credits": get_billing_credits,
|
||||
"get_billing_anomalies": get_billing_anomalies,
|
||||
"get_billing_history": get_billing_history,
|
||||
"get_billing_budget": get_billing_budget,
|
||||
"set_billing_budget": set_billing_budget,
|
||||
"get_billing_tokens_by_module": get_billing_tokens_by_module,
|
||||
# Onboarding
|
||||
"create_invite": create_invite,
|
||||
"list_customers": list_customers,
|
||||
# Notify
|
||||
"send_webhook": send_webhook,
|
||||
"send_email": send_email,
|
||||
"send_sms": send_sms,
|
||||
"get_notify_channels": get_notify_channels,
|
||||
# Agents
|
||||
"run_jason": run_jason,
|
||||
"run_emma": run_emma,
|
||||
# Platform
|
||||
"get_health": get_health,
|
||||
"get_build_status": get_build_status,
|
||||
"get_state": get_state,
|
||||
"get_telemetry": get_telemetry,
|
||||
"run_terminal": run_terminal,
|
||||
# Gitea (erstatter GitHub)
|
||||
"list_commits": list_commits,
|
||||
"get_file": get_file,
|
||||
"list_open_issues": list_open_issues,
|
||||
"create_issue": create_issue,
|
||||
"push_file": push_file,
|
||||
"get_billing_summary": (get_billing_summary, "Hent billing-sammendrag for OPAX", {}),
|
||||
"get_billing_forecast": (get_billing_forecast, "Hent billing-prognose", {}),
|
||||
"get_billing_credits": (get_billing_credits, "Hent gjenværende kreditter", {}),
|
||||
"get_billing_anomalies": (get_billing_anomalies, "Hent billing-anomalier", {}),
|
||||
"get_billing_history": (get_billing_history, "Hent billing-historikk", {}),
|
||||
"get_billing_budget": (get_billing_budget, "Hent nåværende budsjett", {}),
|
||||
"get_billing_tokens_by_module":(get_billing_tokens_by_module,"Hent token-forbruk per modul", {}),
|
||||
"set_billing_budget": (set_billing_budget, "Sett månedlig budsjett", {"type":"object","properties":{"amount":{"type":"number","description":"Budsjett i NOK"}},"required":[]}),
|
||||
"create_invite": (create_invite, "Inviter ny kunde til OPAX", {"type":"object","properties":{"company":{"type":"string"},"email":{"type":"string"},"tier":{"type":"string"}},"required":["email"]}),
|
||||
"list_customers": (list_customers, "List alle kunder", {}),
|
||||
"send_webhook": (send_webhook, "Send webhook-varsling", {"type":"object","properties":{"url":{"type":"string"},"title":{"type":"string"},"message":{"type":"string"}},"required":["url"]}),
|
||||
"send_email": (send_email, "Send e-post", {"type":"object","properties":{"to":{"type":"string"},"subject":{"type":"string"}},"required":["to","subject"]}),
|
||||
"send_sms": (send_sms, "Send SMS", {"type":"object","properties":{"to":{"type":"string"},"message":{"type":"string"}},"required":["to","message"]}),
|
||||
"get_notify_channels": (get_notify_channels, "Hent varslingkanaler", {}),
|
||||
"run_jason": (run_jason, "Kjør Jason-agenten med en prompt", {"type":"object","properties":{"prompt":{"type":"string"},"mode":{"type":"string"}},"required":["prompt"]}),
|
||||
"run_emma": (run_emma, "Kjør Emma-agenten med en prompt", {"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]}),
|
||||
"get_health": (get_health, "Hent helsestatus for OPAX", {}),
|
||||
"get_build_status": (get_build_status, "Hent siste build-status", {}),
|
||||
"get_state": (get_state, "Hent platform-tilstand", {}),
|
||||
"get_telemetry": (get_telemetry, "Hent telemetri-historikk", {}),
|
||||
"run_terminal": (run_terminal, "Kjør terminalkommando på VM", {"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}),
|
||||
"list_commits": (list_commits, "List siste commits i Gitea-repo", {}),
|
||||
"get_file": (get_file, "Hent fil fra Gitea-repo", {"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}),
|
||||
"list_open_issues": (list_open_issues, "List åpne issues i Gitea-repo", {}),
|
||||
"create_issue": (create_issue, "Opprett nytt issue i Gitea-repo", {"type":"object","properties":{"title":{"type":"string"},"body":{"type":"string"}},"required":["title"]}),
|
||||
"push_file": (push_file, "Opprett eller oppdater fil i Gitea-repo", {"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"},"message":{"type":"string"}},"required":["path","content"]}),
|
||||
}
|
||||
|
||||
|
||||
def _tool_list_result():
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"inputSchema": schema if schema else {"type": "object", "properties": {}},
|
||||
}
|
||||
for name, (_, desc, schema) in TOOLS.items()
|
||||
]
|
||||
|
||||
|
||||
def _jsonrpc_ok(req_id, result):
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
||||
|
||||
|
||||
def _jsonrpc_err(req_id, code, message):
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP Streamable HTTP endpoint — POST /
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/")
|
||||
async def mcp_handler(request: Request):
|
||||
_verify_auth(request)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(_jsonrpc_err(None, -32700, "Parse error"), status_code=400)
|
||||
|
||||
method = body.get("method", "")
|
||||
req_id = body.get("id")
|
||||
params = body.get("params", {})
|
||||
|
||||
# initialize
|
||||
if method == "initialize":
|
||||
return JSONResponse(_jsonrpc_ok(req_id, {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "opax-mcp", "version": "3.0.0"},
|
||||
}))
|
||||
|
||||
# tools/list
|
||||
if method == "tools/list":
|
||||
return JSONResponse(_jsonrpc_ok(req_id, {"tools": _tool_list_result()}))
|
||||
|
||||
# tools/call
|
||||
if method == "tools/call":
|
||||
tool_name = params.get("name") or params.get("tool")
|
||||
tool_args = params.get("arguments", params.get("params", {}))
|
||||
|
||||
entry = TOOLS.get(tool_name)
|
||||
if not entry:
|
||||
return JSONResponse(_jsonrpc_err(req_id, -32601, f"Unknown tool: {tool_name}"))
|
||||
|
||||
handler, _, _ = entry
|
||||
try:
|
||||
result = await handler(tool_args)
|
||||
except Exception as e:
|
||||
return JSONResponse(_jsonrpc_err(req_id, -32000, str(e)))
|
||||
|
||||
return JSONResponse(_jsonrpc_ok(req_id, {
|
||||
"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]
|
||||
}))
|
||||
|
||||
# notifications (fire-and-forget, no response needed)
|
||||
if method.startswith("notifications/"):
|
||||
return JSONResponse(status_code=202, content={})
|
||||
|
||||
return JSONResponse(_jsonrpc_err(req_id, -32601, f"Method not found: {method}"), status_code=404)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health (keepalive)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "opax-mcp", "version": "3.0.0"}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user