diff --git a/.gitignore b/.gitignore index ac765b955e..4a141d778a 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,6 @@ electron/package-lock.json electron/dist-electron/ electron/node_modules/ icon.iconset/ + +# VS Code Extension (independent Git repo) +vscode-extension/ diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index d20eb94f38..436f672a40 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -7,6 +7,7 @@ * omniroute Start the server (default port 20128) * omniroute --port 3000 Start on custom port * omniroute --no-open Start without opening browser + * omniroute --mcp Start MCP server (stdio transport for IDEs) * omniroute --help Show help * omniroute --version Show version */ @@ -86,9 +87,17 @@ if (args.includes("--help") || args.includes("-h")) { omniroute Start the server omniroute --port Use custom API port (default: 20128) omniroute --no-open Don't open browser automatically + omniroute --mcp Start MCP server (stdio transport for IDEs) omniroute --help Show this help omniroute --version Show version + \x1b[1mMCP Integration:\x1b[0m + The --mcp flag starts an MCP server over stdio, exposing OmniRoute + tools for AI agents in VS Code, Cursor, Claude Desktop, and Copilot. + + Available tools: omniroute_get_health, omniroute_list_combos, + omniroute_check_quota, omniroute_route_request, and more. + \x1b[1mConfig:\x1b[0m Loads .env from: ~/.omniroute/.env or ./.env Memory limit: OMNIROUTE_MEMORY_MB (default: 512) @@ -116,6 +125,19 @@ if (args.includes("--version") || args.includes("-v")) { process.exit(0); } +// ── MCP Server Mode ─────────────────────────────────────── +if (args.includes("--mcp")) { + try { + const { startMcpStdio } = await import(join(ROOT, "open-sse", "mcp-server", "server.ts")); + await startMcpStdio(); + } catch (err) { + console.error("\x1b[31m✖ Failed to start MCP server:\x1b[0m", err.message || err); + process.exit(1); + } + // MCP server runs indefinitely via stdio — don't fall through to Next.js server + await new Promise(() => {}); // Keep process alive +} + function parsePort(value, fallback) { const parsed = parseInt(String(value), 10); return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; diff --git a/omniroute_switchboard_mcp_a2a_expanded_report_2026-03-03.md b/omniroute_switchboard_mcp_a2a_expanded_report_2026-03-03.md new file mode 100644 index 0000000000..d62eae7aef --- /dev/null +++ b/omniroute_switchboard_mcp_a2a_expanded_report_2026-03-03.md @@ -0,0 +1,561 @@ +# Relatório Expandido — OmniRoute + Omni VS Code Extension + MCP/A2A + +Data: 2026-03-03 +Autor: Codex (análise técnica independente) + +## 1. Resumo Executivo + +A hipótese principal está correta: **Switchboard e OmniRoute são complementares**. + +- **Switchboard** opera na camada de orquestração local de agentes (terminal, workflow, inbox de arquivos, MCP embutido na extensão). +- **OmniRoute** opera na camada de gateway/proxy de LLM (roteamento, fallback, tradução de formato, custo, quotas, multi-provider). + +A combinação dos dois cria um efeito de plataforma: + +1. **Extensão Omni (fork do Switchboard)** para coordenação de agentes com UX forte dentro do VS Code. +2. **OmniRoute MCP Server + A2A Server** para expor inteligência operacional (health, quota, custo, combos, políticas) para agentes e para outros sistemas. + +### Conclusão de viabilidade + +- **Viabilidade técnica**: Alta. +- **Viabilidade de produto**: Alta, com diferenciação clara. +- **Viabilidade de execução**: Média-Alta, desde que faseada em entregas pequenas e com contratos estáveis. + +### Recomendação estratégica + +Implementar em 4 ondas: + +1. **Wave 1 (2-3 semanas):** MCP Server essencial no OmniRoute + cliente MCP na extensão Omni. +2. **Wave 2 (2-4 semanas):** extensão Omni forkada e priorizando OmniRoute como provider principal. +3. **Wave 3 (3-4 semanas):** A2A Server no OmniRoute + task lifecycle + streaming. +4. **Wave 4 (3-6 semanas):** Auto-Combo Engine autogerenciado + roteamento contextual e otimização contínua. + +--- + +## 2. Método e Fontes + +Esta análise foi refeita do zero, com três frentes: + +1. **Leitura do código do OmniRoute local** (rotas API, `open-sse`, DB, auth, métricas, CLI tooling). +2. **Leitura do código do Switchboard** (clone local do repositório, não apenas README). +3. **Benchmark externo com fontes primárias** (MCP, A2A, VS Code, GitHub Copilot, LiteLLM, Microsoft MCP Gateway, Kong). + +### Fontes principais (acessadas em 2026-03-03) + +- Switchboard: https://github.com/TentacleOpera/switchboard +- MCP Spec (revision 2025-11-25): https://modelcontextprotocol.io/specification/2025-11-05 +- MCP Security Best Practices: https://modelcontextprotocol.io/specification/draft/basic/security_best_practices +- A2A Spec (v0.3): https://a2a-protocol.org/latest/specification/ +- VS Code 1.102 release notes (MCP stable in agent mode): https://code.visualstudio.com/updates/v1_102 +- VS Code MCP docs: https://code.visualstudio.com/docs/copilot/chat/mcp-servers +- GitHub Copilot + MCP (public preview): https://docs.github.com/en/copilot/how-tos/provide-context/use-mcp/use-the-github-copilot-coding-agent-with-mcp +- LiteLLM README (A2A + MCP Gateway): https://github.com/BerriAI/litellm +- Microsoft MCP Gateway: https://github.com/microsoft/mcp-gateway +- Kong Konnect MCP Server: https://github.com/Kong/mcp-konnect +- Kong MCP Registry announcement (2026-02-02): https://konghq.com/blog/news-announcements/kong-just-launched-the-worlds-first-mcp-server-registry +- OpenClaw model failover/auth order: https://github.com/openclaw/openclaw/blob/main/docs/concepts/model-failover.md + +Observação: o conteúdo do Switchboard também inclui um documento próprio sobre compliance e ToS (interno do projeto), usado aqui como insumo arquitetural, não como parecer jurídico definitivo. + +--- + +## 3. Diagnóstico Técnico do OmniRoute (estado atual) + +### 3.1 Escopo atual do backend + +O projeto já está em escala relevante: + +- `124` arquivos `route.ts` em `src/app/api`. +- `18` endpoints OpenAI-like em `src/app/api/v1`. +- `37` providers declarados em `src/shared/constants/providers.ts`. +- Camada `open-sse` robusta com executores, tradutores, rate-limit manager, combo engine e serviços de sessão. + +### 3.2 Capacidades já prontas (e reaproveitáveis para MCP/A2A) + +O ponto mais importante: **muita coisa que um MCP/A2A precisaria já existe**. + +- Saúde e resiliência: + - `/api/monitoring/health` + - `/api/resilience` + - `/api/rate-limits` + - `/api/token-health` +- Combos e fallback: + - `/api/combos`, `/api/combos/metrics`, `/api/combos/test` + - Estratégias já implementadas: `priority`, `weighted`, `round-robin`, `random`, `least-used`, `cost-optimized` +- Observabilidade e custo: + - `/api/provider-metrics` + - `/api/telemetry/summary` + - `/api/usage/*` (analytics, call logs, request logs, proxy logs, budget) +- Segurança e governança: + - API key com permissões de modelo (`api_keys.allowed_models`) + - middleware com JWT/API key para rotas de gestão + - sanitização/prompt injection guard no pipeline +- Camada de configuração para CLIs: + - `/api/cli-tools/*` já integra OpenClaw, Codex, Claude etc. + +### 3.3 Gap real + +O gap não é de capacidade de negócio, é de **protocolo/interface**: + +- Não existe hoje um **MCP server do OmniRoute**. +- Não existe hoje um **A2A endpoint** com Agent Card + task lifecycle. + +Isso reduz risco do projeto: o investimento é principalmente de encapsulamento, contrato e produto, não de reinvenção do core. + +--- + +## 4. Diagnóstico Técnico do Switchboard (estado atual) + +Com base no código (`/tmp/switchboard-research`): + +- Extensão VS Code com sidebar/webview, watcher de inbox e automações de workflow. +- MCP server embutido (`@modelcontextprotocol/sdk`) em `stdio`. +- Protocolo de coordenação em arquivos `.switchboard/*` + automação de terminal (`terminal.sendText`). +- Ferramentas MCP focadas em orquestração local (`start_workflow`, `send_message`, `check_inbox`, `run_in_terminal`, etc.). +- Forte foco em “local-first” e compliance ToS em providers sensíveis. + +### Leitura estratégica + +Switchboard resolve muito bem “**quem faz o trabalho e quando**”. +OmniRoute resolve “**qual modelo/provedor atenderá com menor custo/risco**”. + +A fusão cria uma camada de decisão completa: orquestração de agentes + inteligência de roteamento. + +--- + +## 5. Workstream A — Omni VS Code Extension (fork do Switchboard) + +### 5.1 Fork vs Greenfield + +### Recomendação + +**Fork + rebrand + redução de superfície inicial**. + +Motivos: + +- Tempo de entrega menor. +- Já existe runtime testado para workflows, terminal grid e inbox watcher. +- Já existe MCP interno que pode ser adaptado para cliente OmniRoute. + +### Cuidado + +Fork sem governança vira dívida. Definir desde o dia 1: + +- O que permanecerá compatível com upstream. +- O que será “Omni-only”. +- Estratégia de merge trimestral ou semestral. + +### 5.2 Arquitetura alvo da extensão + +### Módulos novos na extensão Omni + +1. **`OmniRouteClient`** + +- SDK local (TS) para chamadas ao OmniRoute. +- Suporte a auth via API key/JWT local. +- Retry com backoff e timeout curto. + +2. **`DecisionPanel` (sidebar)** + +- Health por provider/combos. +- Quota/usage por conexão e por sessão de trabalho. +- Custo estimado da execução atual. + +3. **`DispatchPolicyEngine`** + +- Antes de enviar tarefa a agente/modelo, consulta: + - quota disponível + - latência recente + - orçamento diário + - lockouts/circuit breakers +- Escolhe rota sugerida com explicação curta (“porque escolheu”). + +4. **`ContextualHints`** + +- Detecta tipo de tarefa (planejamento, refactor, teste, docs, bugfix urgente). +- Sugere combo/modelo por classe de tarefa. + +### 5.3 Funcionalidades criativas (além do baseline anterior) + +1. **Preflight de tarefa com score de risco** + +- Antes do dispatch: score de risco de custo/latência/falha. +- Usuário aprova ou força. + +2. **Dry-run de roteamento** + +- Simula o caminho de fallback sem executar a tarefa. +- Mostra “árvore de queda” provável. + +3. **Modo “Budget Guard” por sessão IDE** + +- Define teto de custo por sessão de trabalho. +- Auto-reduz agressividade de modelo ao chegar em limiar. + +4. **Mode packs por contexto** + +- “Ship fast”, “Cost saver”, “Quality first”, “Offline friendly”. +- Cada pack altera pesos do policy engine. + +5. **Checkpoint inteligente para handoff humano** + +- Quando confiança cai abaixo de threshold, extensão recomenda revisão humana antes de continuar cadeia. + +### 5.4 Segurança e compliance + +- Não armazenar segredo em texto no workspace. +- Integração com o modelo de API key existente do OmniRoute. +- Logs redigidos (sem tokens). +- Botão “kill switch” para automação de terminal. +- Limitar ações perigosas em ambiente não confiável. + +### 5.5 Esforço estimado (workstream A) + +- Base funcional (fork + provider Omni + painel mínimo): **10-14 dias úteis**. +- Funcionalidades avançadas de dispatch/packs: **+8-12 dias úteis**. +- Hardening + testes e packaging: **+5-8 dias úteis**. + +Total provável: **23-34 dias úteis**. + +--- + +## 6. Workstream B — MCP + A2A no OmniRoute + +### 6.1 Princípios + +1. Começar com ferramentas de alto valor e baixo risco. +2. Reusar endpoints existentes para reduzir manutenção. +3. Segurança por padrão (escopo mínimo + auditoria). +4. Contratos estáveis versionados (`v1alpha`, `v1`). + +### 6.2 MCP Server do OmniRoute (proposta) + +#### 6.2.1 Ferramentas essenciais (Fase 1) + +1. `omniroute_get_health` + +- Fonte: `/api/monitoring/health`, `/api/resilience`, `/api/rate-limits` + +2. `omniroute_list_combos` + +- Fonte: `/api/combos` + +3. `omniroute_get_combo_metrics` + +- Fonte: `/api/combos/metrics` + +4. `omniroute_switch_combo` + +- Fonte: atualização de configuração padrão (settings) + +5. `omniroute_check_quota` + +- Fonte: `/api/usage/[connectionId]` + token health + +6. `omniroute_route_request` + +- Wrapper controlado para envio em `/v1/chat/completions` e `/v1/responses` +- Inclui metadados de sessão/objetivo + +7. `omniroute_cost_report` + +- Fonte: `/api/usage/analytics`, `/api/usage/call-logs`, `/api/usage/budget` + +8. `omniroute_list_models_catalog` + +- Fonte: `/api/models/catalog` + `/v1/models` + +#### 6.2.2 Ferramentas avançadas (Fase 2+) + +9. `omniroute_simulate_route` (dry-run) +10. `omniroute_set_budget_guard` +11. `omniroute_set_resilience_profile` +12. `omniroute_test_combo` +13. `omniroute_get_provider_metrics` +14. `omniroute_get_proxy_path` +15. `omniroute_toggle_rate_limit_protection` +16. `omniroute_get_session_snapshot` + +#### 6.2.3 Contrato e segurança MCP + +- Transporte inicial: `stdio` para uso local e `streamable-http` opcional em ambiente gerenciado. +- Seguir práticas do MCP spec e security best practices: + - validação de origem para endpoints HTTP locais + - mitigação de DNS rebinding + - evitar token passthrough inseguro +- Controle de escopo por API key (`allowed_models` + escopos de ferramenta futuros). + +### 6.3 A2A Server no OmniRoute (proposta) + +#### 6.3.1 Contrato base + +- `GET /.well-known/agent.json` (Agent Card) +- JSON-RPC 2.0: + - `message/send` + - `message/stream` + - `tasks/get` + - `tasks/cancel` + - `tasks/pushNotification/set` e `.../get` + +#### 6.3.2 Papel do OmniRoute como agente A2A + +O OmniRoute não precisa virar “um agente que coda”; ele pode ser um **agente roteador especializado**: + +- Recebe tarefas com contexto e SLO. +- Resolve estratégia de execução (modelo/provider/combo). +- Retorna resultado + trilha de decisão + custos. + +#### 6.3.3 Extensões de valor + +1. `routing_explanation` em cada task result. +2. `cost_envelope` (estimado vs real). +3. `resilience_trace` (fallbacks acionados). +4. `policy_verdict` (por que permitiu/bloqueou rota). + +### 6.4 Auto-Combo Engine autogerenciado (proposta criativa) + +Objetivo: transformar combo estático em sistema adaptativo. + +#### Score sugerido + +`score = wq*quota + wh*health + wc*cost_inv + wl*latency_inv + wt*task_fit + ws*stability` + +Onde: + +- `quota`: capacidade residual de uso +- `health`: estado de circuito/erro recente +- `cost_inv`: inverso do custo (mais barato, maior score) +- `latency_inv`: inverso da latência p95 +- `task_fit`: aderência ao tipo de tarefa +- `stability`: variância de erro/latência + +#### Comportamentos + +1. **Self-healing:** exclui temporariamente perfis/modelos degradados. +2. **Bandit exploration controlado:** pequena exploração para evitar overfitting. +3. **Policy cap:** nunca viola limites de custo/compliance definidos. +4. **Fallback determinístico em modo incidente:** quando risco alto, prioriza previsibilidade. + +### 6.5 Persistência necessária (mínima) + +Novas tabelas (SQLite) sugeridas: + +- `mcp_tool_audit` +- `a2a_tasks` +- `a2a_task_events` +- `routing_decisions` +- `combo_adaptation_state` + +Observação: o projeto já tem base madura de migração versionada em `src/lib/db/migrationRunner.ts`, o que facilita evolução segura de schema. + +### 6.6 Esforço estimado (workstream B) + +- MCP essencial + auth + auditoria: **10-15 dias úteis**. +- A2A core + task lifecycle + streaming: **12-18 dias úteis**. +- Auto-Combo Engine inicial (regras + score + guardrails): **10-16 dias úteis**. +- Hardening/perf/testes: **7-12 dias úteis**. + +Total provável: **39-61 dias úteis**. + +--- + +## 7. Arquitetura Integrada (extensão + OmniRoute) + +Fluxo recomendado: + +1. Usuário/agente na extensão cria tarefa. +2. Extensão consulta MCP do OmniRoute (`health`, `quota`, `combos`). +3. `DispatchPolicyEngine` define rota sugerida. +4. Extensão envia requisição via `omniroute_route_request`. +5. OmniRoute executa com fallback/telemetria. +6. Resultado retorna com: + +- output +- custo +- rota usada +- eventos de fallback + +7. Extensão atualiza painel e histórico da sessão. + +Resultado prático: UX de “agente inteligente” sem esconder governança operacional. + +--- + +## 8. Benchmark competitivo (o que já existe e onde diferenciar) + +### 8.1 LiteLLM + +Ponto forte: gateway consolidado com endpoints amplos e narrativa de AI Hub, incluindo A2A e MCP. + +Implicação para OmniRoute: + +- Não competir só por “tem MCP/A2A”. +- Diferenciar por profundidade em: + - combos multi-estratégia + - integração direta com CLIs de coding + - governança de quota e fallback por assinatura/OAuth + API key + +### 8.2 Microsoft MCP Gateway + +Ponto forte: camada enterprise de gateway MCP com roteamento stateful por sessão e control plane. + +Implicação: + +- Validar que “MCP Gateway dedicado” virou categoria real. +- Oportunidade OmniRoute: foco dev-first e LLM routing prático, sem dependência K8s para casos locais. + +### 8.3 Kong MCP (mcp-konnect + MCP Registry) + +Ponto forte: governança e integração com ecossistema API management. + +Implicação: + +- Mercado começou a institucionalizar descoberta/governança de MCP. +- Oportunidade OmniRoute: ser forte no fluxo de engenharia (IDE/CLI/task routing), não só no plano corporativo de APIs. + +### 8.4 Switchboard + +Ponto forte: orquestração local e workflow de agentes com baixo atrito. + +Implicação: + +- Excelente base para fork da extensão Omni. +- Mas sem camada avançada de roteamento multi-provider como a do OmniRoute. + +### 8.5 OpenClaw + +Ponto forte: failover modelado, rotação de perfis de auth e fallback de modelo bem documentados. + +Implicação: + +- Boa referência para o design de “auto-managed combos”. +- Evitar claims não comprovadas; focar no que é claro: `auth.order`, rotação por perfil, fallback model-aware. + +--- + +## 9. Prós e Contras da Estratégia Completa + +### Prós + +1. Diferenciação forte no ecossistema de agentes/IDEs. +2. Reaproveitamento alto de capacidades já existentes do OmniRoute. +3. Melhor retenção: gateway + extensão + protocolo = maior lock-in positivo por valor. +4. Base para produto enterprise (auditoria, política, orçamento, observabilidade). + +### Contras + +1. Complexidade de produto aumenta muito (2 superfícies simultâneas). +2. Risco de manutenção de fork do Switchboard. +3. A2A ainda evolui rápido; risco de churn de contrato. +4. Requer governança clara para não misturar automação agressiva com compliance sensível. + +--- + +## 10. Registro de Riscos e Mitigações + +1. **Risco:** instabilidade de protocolo (MCP/A2A evoluindo). + +- Mitigação: versionar internamente (`v1alpha`) e usar adaptadores. + +2. **Risco:** extensão virar “monolito de features”. + +- Mitigação: plugin architecture interna e feature flags por módulo. + +3. **Risco:** regressão de segurança ao expor tools operacionais. + +- Mitigação: RBAC por API key + auditoria + deny-by-default. + +4. **Risco:** custo operacional de observabilidade. + +- Mitigação: métricas com retenção curta por default e export opcional. + +5. **Risco:** acoplamento excessivo com upstream Switchboard. + +- Mitigação: definir core próprio Omni para decisão de roteamento; upstream só para orquestração/UI base. + +--- + +## 11. KPI de sucesso + +1. **Confiabilidade de execução** + +- sucesso de task em primeira tentativa +- taxa de fallback efetivo + +2. **Eficiência econômica** + +- custo médio por tarefa +- custo evitado por auto-combo + +3. **Qualidade de experiência** + +- latência p95 end-to-end +- tempo de decisão de rota + +4. **Operabilidade** + +- MTTR de incidentes de provider +- taxa de uso de dry-run/simulação + +5. **Adoção** + +- sessões ativas na extensão +- tasks via MCP/A2A por dia + +--- + +## 12. Plano de execução sugerido + +### Fase 0 — Contratos (3-5 dias) + +- Definir contratos MCP e A2A mínimos. +- Definir escopos de autorização por ferramenta. +- Definir formatos de audit log. + +### Fase 1 — MCP essencial (10-15 dias) + +- Entregar 8 ferramentas essenciais. +- Integrar com endpoints existentes. +- Publicar documentação e exemplos. + +### Fase 2 — Extensão Omni MVP (10-14 dias) + +- Fork Switchboard + rebrand. +- OmniRoute provider-first. +- Painel de health/quota/custo básico. + +### Fase 3 — A2A core (12-18 dias) + +- Agent Card + lifecycle de tasks + streaming. +- `routing_explanation` e `cost_envelope` no resultado. + +### Fase 4 — Auto-Combo v1 (10-16 dias) + +- Score adaptativo + guardrails + simulação. +- Métricas de melhoria contínua. + +--- + +## 13. Decisão recomendada (objetiva) + +Se a prioridade é **time-to-value**, comece por: + +1. MCP essencial no OmniRoute. +2. Extensão Omni forkada em modo “thin client” (consome MCP + APIs já existentes). + +Se a prioridade é **diferenciação estrutural de médio prazo**, em seguida: + +3. A2A server. +4. Auto-Combo autogerenciado. + +Essa ordem reduz risco e cria valor progressivo sem bloquear as ambições maiores. + +--- + +## 14. Apêndice — Evidências objetivas do código atual + +- `src/lib/localDb.ts` mantém contrato de re-export (boa base para evoluções sem quebrar consumidores). +- `open-sse/services/combo.ts` já implementa múltiplas estratégias de combo e fallback com métricas. +- `src/app/api/monitoring/health/route.ts`, `src/app/api/resilience/route.ts`, `src/app/api/rate-limits/route.ts` já expõem estado operacional útil para MCP tools. +- `src/app/api/usage/*` e `src/lib/usageAnalytics.ts` já entregam base para relatórios de custo/sessão. +- `src/app/api/cli-tools/*` mostra maturidade de integração com ecossistema de coding agents (incluindo OpenClaw e Codex). diff --git a/open-sse/mcp-server/audit.ts b/open-sse/mcp-server/audit.ts new file mode 100644 index 0000000000..0c606c4106 --- /dev/null +++ b/open-sse/mcp-server/audit.ts @@ -0,0 +1,150 @@ +/** + * MCP Audit Logger — Records all MCP tool invocations for security and observability. + * + * Logs are written to the `mcp_tool_audit` SQLite table. + * Input data is hashed (SHA-256) to avoid storing sensitive prompts. + * Output is truncated to 200 chars for summary. + */ + +import { hashInput, summarizeOutput } from "./schemas/audit.ts"; + +// ============ Database Connection ============ + +let db: any = null; + +/** + * Lazy-load the database connection. + * Uses the same SQLite database as the main OmniRoute app. + */ +async function getDb(): Promise { + if (db) return db; + + try { + // Try importing the db module from the main app + const { homedir } = await import("node:os"); + const { join } = await import("node:path"); + const { existsSync } = await import("node:fs"); + + const dbPath = process.env.DATA_DIR + ? join(process.env.DATA_DIR, "storage.sqlite") + : join(homedir(), ".omniroute", "storage.sqlite"); + + if (!existsSync(dbPath)) { + console.error(`[MCP Audit] Database not found at ${dbPath} — audit logging disabled`); + return null; + } + + const Database = (await import("better-sqlite3")).default; + db = new Database(dbPath); + return db; + } catch (err) { + console.error("[MCP Audit] Failed to connect to database:", err); + return null; + } +} + +// ============ Audit Logger ============ + +/** + * Log a tool invocation to the mcp_tool_audit table. + * + * Security: Input is hashed, never stored in clear text. + * Output is truncated to a summary. + */ +export async function logToolCall( + toolName: string, + input: unknown, + output: unknown, + durationMs: number, + success: boolean, + errorCode?: string +): Promise { + try { + const database = await getDb(); + if (!database) return; // Audit disabled if no DB + + const inputHash = await hashInput(input); + const outputSummary = summarizeOutput(output); + const apiKeyId = process.env.OMNIROUTE_API_KEY_ID || null; + + database + .prepare( + `INSERT INTO mcp_tool_audit (tool_name, input_hash, output_summary, duration_ms, api_key_id, success, error_code) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run( + toolName, + inputHash, + outputSummary, + durationMs, + apiKeyId, + success ? 1 : 0, + errorCode || null + ); + } catch (err) { + // Never let audit failure break tool execution + console.error("[MCP Audit] Failed to log:", err); + } +} + +/** + * Get recent audit entries (for dashboard/monitoring). + */ +export async function getRecentAuditEntries(limit = 50): Promise { + try { + const database = await getDb(); + if (!database) return []; + + return database + .prepare("SELECT * FROM mcp_tool_audit ORDER BY created_at DESC LIMIT ?") + .all(limit); + } catch { + return []; + } +} + +/** + * Get audit stats for monitoring. + */ +export async function getAuditStats(): Promise<{ + totalCalls: number; + successRate: number; + avgDurationMs: number; + topTools: Array<{ tool: string; count: number }>; +}> { + try { + const database = await getDb(); + if (!database) return { totalCalls: 0, successRate: 0, avgDurationMs: 0, topTools: [] }; + + const stats = database + .prepare( + `SELECT + COUNT(*) as total, + AVG(CASE WHEN success = 1 THEN 1.0 ELSE 0.0 END) as successRate, + AVG(duration_ms) as avgDuration + FROM mcp_tool_audit + WHERE created_at > datetime('now', '-24 hours')` + ) + .get() as any; + + const topTools = database + .prepare( + `SELECT tool_name as tool, COUNT(*) as count + FROM mcp_tool_audit + WHERE created_at > datetime('now', '-24 hours') + GROUP BY tool_name + ORDER BY count DESC + LIMIT 10` + ) + .all() as any[]; + + return { + totalCalls: stats?.total || 0, + successRate: stats?.successRate || 0, + avgDurationMs: stats?.avgDuration || 0, + topTools: topTools || [], + }; + } catch { + return { totalCalls: 0, successRate: 0, avgDurationMs: 0, topTools: [] }; + } +} diff --git a/open-sse/mcp-server/index.ts b/open-sse/mcp-server/index.ts new file mode 100644 index 0000000000..85b958972f --- /dev/null +++ b/open-sse/mcp-server/index.ts @@ -0,0 +1,6 @@ +/** + * OmniRoute MCP Server — barrel export. + */ +export { createMcpServer, startMcpStdio } from "./server.ts"; +export { logToolCall, getRecentAuditEntries, getAuditStats } from "./audit.ts"; +export * from "./schemas/index.ts"; diff --git a/open-sse/mcp-server/schemas/a2a.ts b/open-sse/mcp-server/schemas/a2a.ts new file mode 100644 index 0000000000..9be4d50eb4 --- /dev/null +++ b/open-sse/mcp-server/schemas/a2a.ts @@ -0,0 +1,203 @@ +/** + * A2A (Agent-to-Agent) Schemas — Contracts for OmniRoute A2A Server. + * + * Defines the Agent Card structure, Task lifecycle, Message format, + * and all A2A protocol types conforming to A2A Protocol v0.3. + */ + +import { z } from "zod"; + +// ============ Agent Card Schema ============ + +export const AgentSkillSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + tags: z.array(z.string()), + examples: z.array(z.string()).optional(), +}); + +export const AgentCardSchema = z.object({ + name: z.string(), + description: z.string(), + url: z.string().url(), + version: z.string(), + capabilities: z.object({ + streaming: z.boolean(), + pushNotifications: z.boolean(), + }), + skills: z.array(AgentSkillSchema), + authentication: z.object({ + schemes: z.array(z.string()), + apiKeyHeader: z.string().optional(), + }), +}); + +export type AgentCard = z.infer; +export type AgentSkill = z.infer; + +// ============ Task Schema ============ + +export const TaskStateEnum = z.enum(["submitted", "working", "completed", "failed", "cancelled"]); + +export type TaskState = z.infer; + +export const TaskInputSchema = z.object({ + messages: z + .array( + z.object({ + role: z.string(), + content: z.string(), + }) + ) + .optional(), + model: z.string().optional(), + combo: z.string().optional(), + budget: z.number().optional(), + role: z + .enum(["coding", "review", "planning", "analysis", "debugging", "documentation"]) + .optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const CostEnvelopeSchema = z.object({ + estimated: z.number(), + actual: z.number(), + currency: z.string().default("USD"), +}); + +export const ResilienceTraceEventSchema = z.object({ + event: z.string(), + provider: z.string().optional(), + reason: z.string().optional(), + timestamp: z.string(), +}); + +export const PolicyVerdictSchema = z.object({ + allowed: z.boolean(), + reason: z.string(), + restrictions: z.array(z.string()).optional(), +}); + +export const TaskOutputSchema = z.object({ + response: z + .object({ + content: z.string(), + model: z.string(), + tokens: z.object({ + prompt: z.number(), + completion: z.number(), + }), + }) + .optional(), + routingExplanation: z.string().optional(), + costEnvelope: CostEnvelopeSchema.optional(), + resilienceTrace: z.array(ResilienceTraceEventSchema).optional(), + policyVerdict: PolicyVerdictSchema.optional(), +}); + +export const TaskSchema = z.object({ + id: z.string().uuid(), + state: TaskStateEnum, + skillId: z.string(), + input: TaskInputSchema.optional(), + output: TaskOutputSchema.optional(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + completedAt: z.string().datetime().nullable().optional(), + expiresAt: z.string().datetime().nullable().optional(), +}); + +export type Task = z.infer; +export type TaskInput = z.infer; +export type TaskOutput = z.infer; +export type CostEnvelope = z.infer; +export type ResilienceTraceEvent = z.infer; +export type PolicyVerdict = z.infer; + +// ============ JSON-RPC 2.0 Schemas ============ + +export const JsonRpcRequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + method: z.enum(["message/send", "message/stream", "tasks/get", "tasks/cancel"]), + params: z.record(z.unknown()), + id: z.union([z.string(), z.number()]), +}); + +export const JsonRpcResponseSchema = z.object({ + jsonrpc: z.literal("2.0"), + result: z.unknown().optional(), + error: z + .object({ + code: z.number(), + message: z.string(), + data: z.unknown().optional(), + }) + .optional(), + id: z.union([z.string(), z.number()]).nullable(), +}); + +export type JsonRpcRequest = z.infer; +export type JsonRpcResponse = z.infer; + +// ============ Message Schemas ============ + +export const MessageSendParamsSchema = z.object({ + task: z + .object({ + skillId: z.string(), + }) + .optional(), + message: z.object({ + role: z.string().default("user"), + content: z.string(), + metadata: z.record(z.unknown()).optional(), + }), + config: z + .object({ + model: z.string().optional(), + combo: z.string().optional(), + budget: z.number().optional(), + taskRole: z + .enum(["coding", "review", "planning", "analysis", "debugging", "documentation"]) + .optional(), + }) + .optional(), +}); + +export const TasksGetParamsSchema = z.object({ + taskId: z.string().uuid(), +}); + +export const TasksCancelParamsSchema = z.object({ + taskId: z.string().uuid(), +}); + +export type MessageSendParams = z.infer; +export type TasksGetParams = z.infer; +export type TasksCancelParams = z.infer; + +// ============ SSE Event Types ============ + +export const A2A_SSE_EVENTS = { + TASK_STATUS: "task.status", + TASK_ARTIFACT: "task.artifact", + TASK_CHUNK: "task.chunk", + TASK_COMPLETE: "task.complete", + TASK_ERROR: "task.error", + HEARTBEAT: "heartbeat", +} as const; + +// ============ A2A Error Codes ============ + +export const A2A_ERROR_CODES = { + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + TASK_NOT_FOUND: -32001, + TASK_ALREADY_COMPLETED: -32002, + UNAUTHORIZED: -32003, + BUDGET_EXCEEDED: -32004, + PROVIDER_UNAVAILABLE: -32005, +} as const; diff --git a/open-sse/mcp-server/schemas/audit.ts b/open-sse/mcp-server/schemas/audit.ts new file mode 100644 index 0000000000..22f2946a68 --- /dev/null +++ b/open-sse/mcp-server/schemas/audit.ts @@ -0,0 +1,121 @@ +/** + * MCP/A2A Audit Types — Interfaces for audit log entries. + * + * These types define the format of audit log entries stored in the + * `mcp_tool_audit` and `a2a_task_events` tables. + * + * Security: Input data is never stored in clear text. Only SHA-256 hashes + * of input and truncated output summaries are persisted. + */ + +// ============ MCP Audit Entry ============ + +export interface McpAuditEntry { + /** ISO 8601 timestamp */ + timestamp: string; + /** MCP tool name that was invoked */ + toolName: string; + /** SHA-256 hash of the serialized input (never stores raw data) */ + inputHash: string; + /** Truncated first 200 chars of the output, or response type */ + outputSummary: string; + /** Execution duration in milliseconds */ + durationMs: number; + /** API key ID used for the invocation (null for anonymous/stdio) */ + apiKeyId: string | null; + /** Whether the tool execution succeeded */ + success: boolean; + /** Error code if execution failed */ + errorCode?: string; + /** Error message summary (truncated, no sensitive data) */ + errorMessage?: string; +} + +// ============ A2A Task Event ============ + +export interface A2aTaskEvent { + /** ISO 8601 timestamp */ + timestamp: string; + /** Task ID this event belongs to */ + taskId: string; + /** Type of event */ + eventType: + | "task_created" + | "task_working" + | "task_completed" + | "task_failed" + | "task_cancelled" + | "task_expired" + | "provider_selected" + | "fallback_triggered" + | "budget_check" + | "quota_check" + | "streaming_started" + | "streaming_ended"; + /** Event-specific data (JSON-serialized) */ + data?: Record; +} + +// ============ Routing Decision Log ============ + +export interface RoutingDecisionLog { + /** Unique request identifier */ + requestId: string; + /** Type of task (coding, review, etc.) */ + taskType: string | null; + /** Combo used for routing */ + comboId: string | null; + /** Provider selected by the routing engine */ + providerSelected: string; + /** Model selected */ + modelSelected: string; + /** Composite score from the scoring function */ + score: number; + /** Breakdown of scoring factors */ + factors: RoutingFactor[]; + /** Number of fallbacks triggered during execution */ + fallbacksTriggered: number; + /** Whether the request succeeded */ + success: boolean; + /** Total latency in milliseconds */ + latencyMs: number; + /** Actual cost in USD */ + cost: number; + /** Source: 'api' | 'mcp' | 'a2a' */ + source: "api" | "mcp" | "a2a"; +} + +export interface RoutingFactor { + /** Factor name (quota, health, cost, latency, task_fit, stability) */ + name: string; + /** Raw factor value [0..1] */ + value: number; + /** Weight applied to this factor */ + weight: number; + /** Weighted contribution (value × weight) */ + contribution: number; +} + +// ============ Audit Helpers ============ + +/** + * Create a SHA-256 hash of input data for audit logging. + * This ensures we never store raw prompts/data in audit logs. + */ +export async function hashInput(input: unknown): Promise { + const data = JSON.stringify(input); + const encoder = new TextEncoder(); + const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(data)); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Truncate output to a summary string for audit logging. + */ +export function summarizeOutput(output: unknown, maxLength = 200): string { + if (output === null || output === undefined) return "(null)"; + const str = typeof output === "string" ? output : JSON.stringify(output); + if (str.length <= maxLength) return str; + return str.slice(0, maxLength) + "…"; +} diff --git a/open-sse/mcp-server/schemas/index.ts b/open-sse/mcp-server/schemas/index.ts new file mode 100644 index 0000000000..99a947f3ff --- /dev/null +++ b/open-sse/mcp-server/schemas/index.ts @@ -0,0 +1,107 @@ +/** + * MCP Server Schemas — barrel export for all contract definitions. + */ + +// Tool schemas & registry +export { + type McpToolDefinition, + type AuditLevel, + MCP_TOOLS, + MCP_ESSENTIAL_TOOLS, + MCP_ADVANCED_TOOLS, + MCP_TOOL_MAP, + // Phase 1: Essential tool schemas + getHealthInput, + getHealthOutput, + getHealthTool, + listCombosInput, + listCombosOutput, + listCombosTool, + getComboMetricsInput, + getComboMetricsOutput, + getComboMetricsTool, + switchComboInput, + switchComboOutput, + switchComboTool, + checkQuotaInput, + checkQuotaOutput, + checkQuotaTool, + routeRequestInput, + routeRequestOutput, + routeRequestTool, + costReportInput, + costReportOutput, + costReportTool, + listModelsCatalogInput, + listModelsCatalogOutput, + listModelsCatalogTool, + // Phase 2: Advanced tool schemas + simulateRouteInput, + simulateRouteOutput, + simulateRouteTool, + setBudgetGuardInput, + setBudgetGuardOutput, + setBudgetGuardTool, + setResilienceProfileInput, + setResilienceProfileOutput, + setResilienceProfileTool, + testComboInput, + testComboOutput, + testComboTool, + getProviderMetricsInput, + getProviderMetricsOutput, + getProviderMetricsTool, + bestComboForTaskInput, + bestComboForTaskOutput, + bestComboForTaskTool, + explainRouteInput, + explainRouteOutput, + explainRouteTool, + getSessionSnapshotInput, + getSessionSnapshotOutput, + getSessionSnapshotTool, +} from "./tools.ts"; + +// A2A schemas +export { + AgentCardSchema, + AgentSkillSchema, + TaskStateEnum, + TaskInputSchema, + TaskOutputSchema, + TaskSchema, + CostEnvelopeSchema, + ResilienceTraceEventSchema, + PolicyVerdictSchema, + JsonRpcRequestSchema, + JsonRpcResponseSchema, + MessageSendParamsSchema, + TasksGetParamsSchema, + TasksCancelParamsSchema, + A2A_SSE_EVENTS, + A2A_ERROR_CODES, + type AgentCard, + type AgentSkill, + type Task, + type TaskState, + type TaskInput, + type TaskOutput, + type CostEnvelope, + type ResilienceTraceEvent, + type PolicyVerdict, + type JsonRpcRequest, + type JsonRpcResponse, + type MessageSendParams, + type TasksGetParams, + type TasksCancelParams, +} from "./a2a.ts"; + +// Audit types +export { + type McpAuditEntry, + type A2aTaskEvent, + type RoutingDecisionLog, + type RoutingFactor, + hashInput, + summarizeOutput, +} from "./audit.ts"; diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts new file mode 100644 index 0000000000..bfc6ab9644 --- /dev/null +++ b/open-sse/mcp-server/schemas/tools.ts @@ -0,0 +1,750 @@ +/** + * MCP Tool Schemas — Contracts for all 16 OmniRoute MCP tools. + * + * Defines input/output Zod schemas, descriptions, scopes, and audit levels + * for both essential (Phase 1) and advanced (Phase 3) MCP tools. + * + * Each tool wraps existing OmniRoute API endpoints and exposes them through + * the Model Context Protocol, enabling AI agents in IDEs (VS Code, Cursor, + * Copilot, Claude Desktop) to intelligently query gateway state. + */ + +import { z } from "zod"; + +// ============ Shared Types ============ + +export type AuditLevel = "none" | "basic" | "full"; + +export interface McpToolDefinition { + /** Tool name (MCP identifier) */ + name: string; + /** Human-readable description for AI agents */ + description: string; + /** Zod schema for input validation */ + inputSchema: TInput; + /** Zod schema for output validation */ + outputSchema: TOutput; + /** Required API key scopes */ + scopes: readonly string[]; + /** Audit logging level */ + auditLevel: AuditLevel; + /** Phase: 1 = essential, 2 = advanced */ + phase: 1 | 2; + /** Source endpoints on OmniRoute that this tool wraps */ + sourceEndpoints: readonly string[]; +} + +// ============ Phase 1: Essential Tools (8) ============ + +// --- Tool 1: omniroute_get_health --- +export const getHealthInput = z.object({}).describe("No parameters required"); + +export const getHealthOutput = z.object({ + uptime: z.string(), + version: z.string(), + memoryUsage: z.object({ + heapUsed: z.number(), + heapTotal: z.number(), + }), + circuitBreakers: z.array( + z.object({ + provider: z.string(), + state: z.enum(["CLOSED", "OPEN", "HALF_OPEN"]), + failureCount: z.number(), + lastFailure: z.string().nullable(), + }) + ), + rateLimits: z.array( + z.object({ + provider: z.string(), + rpm: z.number(), + currentUsage: z.number(), + isLimited: z.boolean(), + }) + ), + cacheStats: z + .object({ + hits: z.number(), + misses: z.number(), + hitRate: z.number(), + }) + .optional(), +}); + +export const getHealthTool: McpToolDefinition = { + name: "omniroute_get_health", + description: + "Returns the current health status of OmniRoute including uptime, memory usage, circuit breaker states for all providers, rate limit status, and cache statistics.", + inputSchema: getHealthInput, + outputSchema: getHealthOutput, + scopes: ["read:health"], + auditLevel: "basic", + phase: 1, + sourceEndpoints: ["/api/monitoring/health", "/api/resilience", "/api/rate-limits"], +}; + +// --- Tool 2: omniroute_list_combos --- +export const listCombosInput = z.object({ + includeMetrics: z + .boolean() + .optional() + .describe("Include request count, success rate, latency, and cost metrics per combo"), +}); + +export const listCombosOutput = z.object({ + combos: z.array( + z.object({ + id: z.string(), + name: z.string(), + models: z.array( + z.object({ + provider: z.string(), + model: z.string(), + priority: z.number(), + }) + ), + strategy: z.enum([ + "priority", + "weighted", + "round-robin", + "random", + "least-used", + "cost-optimized", + "auto", + ]), + enabled: z.boolean(), + metrics: z + .object({ + requestCount: z.number(), + successRate: z.number(), + avgLatencyMs: z.number(), + totalCost: z.number(), + }) + .optional(), + }) + ), +}); + +export const listCombosTool: McpToolDefinition = { + name: "omniroute_list_combos", + description: + "Lists all configured combos (model chains) with their strategies and optionally includes performance metrics. Combos define how requests are routed across multiple providers.", + inputSchema: listCombosInput, + outputSchema: listCombosOutput, + scopes: ["read:combos"], + auditLevel: "basic", + phase: 1, + sourceEndpoints: ["/api/combos", "/api/combos/metrics"], +}; + +// --- Tool 3: omniroute_get_combo_metrics --- +export const getComboMetricsInput = z.object({ + comboId: z.string().describe("ID of the combo to get metrics for"), +}); + +export const getComboMetricsOutput = z.object({ + requests: z.number(), + successRate: z.number(), + avgLatency: z.number(), + costTotal: z.number(), + fallbackCount: z.number(), + byProvider: z.array( + z.object({ + provider: z.string(), + requests: z.number(), + successRate: z.number(), + avgLatency: z.number(), + }) + ), +}); + +export const getComboMetricsTool: McpToolDefinition< + typeof getComboMetricsInput, + typeof getComboMetricsOutput +> = { + name: "omniroute_get_combo_metrics", + description: + "Returns detailed performance metrics for a specific combo including request count, success rate, average latency, total cost, and per-provider breakdowns.", + inputSchema: getComboMetricsInput, + outputSchema: getComboMetricsOutput, + scopes: ["read:combos"], + auditLevel: "basic", + phase: 1, + sourceEndpoints: ["/api/combos/metrics"], +}; + +// --- Tool 4: omniroute_switch_combo --- +export const switchComboInput = z.object({ + comboId: z.string().describe("ID of the combo to activate/deactivate"), + active: z.boolean().describe("Whether to enable or disable the combo"), +}); + +export const switchComboOutput = z.object({ + success: z.boolean(), + combo: z.object({ + id: z.string(), + name: z.string(), + enabled: z.boolean(), + }), +}); + +export const switchComboTool: McpToolDefinition = + { + name: "omniroute_switch_combo", + description: + "Activates or deactivates a combo. When deactivated, requests will not be routed through this combo. Use to toggle between different routing strategies.", + inputSchema: switchComboInput, + outputSchema: switchComboOutput, + scopes: ["write:combos"], + auditLevel: "full", + phase: 1, + sourceEndpoints: ["/api/combos"], + }; + +// --- Tool 5: omniroute_check_quota --- +export const checkQuotaInput = z.object({ + provider: z + .string() + .optional() + .describe( + "Filter by provider name (e.g., 'claude', 'gemini'). If omitted, returns all providers." + ), + connectionId: z.string().optional().describe("Filter by specific connection ID"), +}); + +export const checkQuotaOutput = z.object({ + providers: z.array( + z.object({ + name: z.string(), + provider: z.string(), + connectionId: z.string(), + quotaUsed: z.number(), + quotaTotal: z.number().nullable(), + percentRemaining: z.number(), + resetAt: z.string().nullable(), + tokenStatus: z.enum(["valid", "expiring", "expired", "refreshing"]), + }) + ), +}); + +export const checkQuotaTool: McpToolDefinition = { + name: "omniroute_check_quota", + description: + "Checks the remaining API quota for one or all providers. Returns quota used/total, percentage remaining, reset time, and token health status.", + inputSchema: checkQuotaInput, + outputSchema: checkQuotaOutput, + scopes: ["read:quota"], + auditLevel: "basic", + phase: 1, + sourceEndpoints: ["/api/usage/[connectionId]", "/api/token-health"], +}; + +// --- Tool 6: omniroute_route_request --- +export const routeRequestInput = z.object({ + model: z.string().describe("Model identifier (e.g., 'claude-sonnet-4', 'gpt-4o')"), + messages: z + .array( + z.object({ + role: z.string(), + content: z.string(), + }) + ) + .describe("Chat messages in OpenAI format"), + combo: z.string().optional().describe("Specific combo to route through"), + budget: z.number().optional().describe("Maximum cost in USD for this request"), + role: z + .enum(["coding", "review", "planning", "analysis"]) + .optional() + .describe("Task role hint for intelligent routing"), + stream: z.boolean().optional().default(false).describe("Whether to stream the response"), +}); + +export const routeRequestOutput = z.object({ + response: z.object({ + content: z.string(), + model: z.string(), + tokens: z.object({ + prompt: z.number(), + completion: z.number(), + }), + }), + routing: z.object({ + provider: z.string(), + combo: z.string().nullable(), + fallbacksTriggered: z.number(), + cost: z.number(), + latencyMs: z.number(), + routingExplanation: z.string(), + }), +}); + +export const routeRequestTool: McpToolDefinition< + typeof routeRequestInput, + typeof routeRequestOutput +> = { + name: "omniroute_route_request", + description: + "Sends a chat completion request through OmniRoute's intelligent routing pipeline. Supports combo selection, budget limits, and task role hints for optimal provider matching.", + inputSchema: routeRequestInput, + outputSchema: routeRequestOutput, + scopes: ["execute:completions"], + auditLevel: "full", + phase: 1, + sourceEndpoints: ["/v1/chat/completions", "/v1/responses"], +}; + +// --- Tool 7: omniroute_cost_report --- +export const costReportInput = z.object({ + period: z + .enum(["session", "day", "week", "month"]) + .optional() + .default("session") + .describe("Time period for the cost report"), +}); + +export const costReportOutput = z.object({ + period: z.string(), + totalCost: z.number(), + requestCount: z.number(), + tokenCount: z.object({ + prompt: z.number(), + completion: z.number(), + }), + byProvider: z.array( + z.object({ + name: z.string(), + cost: z.number(), + requests: z.number(), + }) + ), + byModel: z.array( + z.object({ + model: z.string(), + cost: z.number(), + requests: z.number(), + }) + ), + budget: z.object({ + limit: z.number().nullable(), + remaining: z.number().nullable(), + }), +}); + +export const costReportTool: McpToolDefinition = { + name: "omniroute_cost_report", + description: + "Generates a cost report for the specified period showing total cost, request count, token usage, and breakdowns by provider and model. Also shows budget status if configured.", + inputSchema: costReportInput, + outputSchema: costReportOutput, + scopes: ["read:usage"], + auditLevel: "basic", + phase: 1, + sourceEndpoints: ["/api/usage/analytics", "/api/usage/budget"], +}; + +// --- Tool 8: omniroute_list_models_catalog --- +export const listModelsCatalogInput = z.object({ + provider: z.string().optional().describe("Filter by provider name"), + capability: z + .enum(["chat", "embedding", "image", "audio", "video", "rerank", "moderation"]) + .optional() + .describe("Filter by model capability"), +}); + +export const listModelsCatalogOutput = z.object({ + models: z.array( + z.object({ + id: z.string(), + provider: z.string(), + capabilities: z.array(z.string()), + status: z.enum(["available", "degraded", "unavailable"]), + pricing: z + .object({ + inputPerMillion: z.number().nullable(), + outputPerMillion: z.number().nullable(), + }) + .optional(), + }) + ), +}); + +export const listModelsCatalogTool: McpToolDefinition< + typeof listModelsCatalogInput, + typeof listModelsCatalogOutput +> = { + name: "omniroute_list_models_catalog", + description: + "Lists all available AI models across all providers with their capabilities, current status, and pricing information.", + inputSchema: listModelsCatalogInput, + outputSchema: listModelsCatalogOutput, + scopes: ["read:models"], + auditLevel: "none", + phase: 1, + sourceEndpoints: ["/api/models/catalog", "/v1/models"], +}; + +// ============ Phase 2: Advanced Tools (8) ============ + +// --- Tool 9: omniroute_simulate_route --- +export const simulateRouteInput = z.object({ + model: z.string().describe("Target model for simulation"), + promptTokenEstimate: z.number().describe("Estimated prompt token count"), + combo: z.string().optional().describe("Specific combo to simulate (default: active combo)"), +}); + +export const simulateRouteOutput = z.object({ + simulatedPath: z.array( + z.object({ + provider: z.string(), + model: z.string(), + probability: z.number(), + estimatedCost: z.number(), + healthStatus: z.enum(["CLOSED", "OPEN", "HALF_OPEN"]), + quotaAvailable: z.number(), + }) + ), + fallbackTree: z.object({ + primary: z.string(), + fallbacks: z.array(z.string()), + worstCaseCost: z.number(), + bestCaseCost: z.number(), + }), +}); + +export const simulateRouteTool: McpToolDefinition< + typeof simulateRouteInput, + typeof simulateRouteOutput +> = { + name: "omniroute_simulate_route", + description: + "Simulates (dry-run) the routing path a request would take without actually executing it. Shows the fallback tree, provider probabilities, estimated costs, and health status.", + inputSchema: simulateRouteInput, + outputSchema: simulateRouteOutput, + scopes: ["read:health", "read:combos"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/combos", "/api/monitoring/health", "/api/resilience"], +}; + +// --- Tool 10: omniroute_set_budget_guard --- +export const setBudgetGuardInput = z.object({ + maxCost: z.number().describe("Maximum cost in USD for this session"), + action: z.enum(["degrade", "block", "alert"]).describe("Action when budget is exceeded"), + degradeToTier: z + .enum(["cheap", "free"]) + .optional() + .describe("If action=degrade, which tier to fall back to"), +}); + +export const setBudgetGuardOutput = z.object({ + sessionId: z.string(), + budgetTotal: z.number(), + budgetSpent: z.number(), + budgetRemaining: z.number(), + action: z.string(), + status: z.enum(["active", "warning", "exceeded"]), +}); + +export const setBudgetGuardTool: McpToolDefinition< + typeof setBudgetGuardInput, + typeof setBudgetGuardOutput +> = { + name: "omniroute_set_budget_guard", + description: + "Sets a budget guard that limits spending for the current session. When the budget is reached, it can degrade to cheaper models, block requests, or send alerts.", + inputSchema: setBudgetGuardInput, + outputSchema: setBudgetGuardOutput, + scopes: ["write:budget"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/usage/budget"], +}; + +// --- Tool 11: omniroute_set_resilience_profile --- +export const setResilienceProfileInput = z.object({ + profile: z + .enum(["aggressive", "balanced", "conservative"]) + .describe("Resilience profile to apply"), +}); + +export const setResilienceProfileOutput = z.object({ + applied: z.boolean(), + settings: z.object({ + circuitBreakerThreshold: z.number(), + retryCount: z.number(), + timeoutMs: z.number(), + fallbackDepth: z.number(), + }), +}); + +export const setResilienceProfileTool: McpToolDefinition< + typeof setResilienceProfileInput, + typeof setResilienceProfileOutput +> = { + name: "omniroute_set_resilience_profile", + description: + "Applies a resilience profile that adjusts circuit breaker thresholds, retry counts, timeouts, and fallback depth. 'aggressive' = fast fail, 'conservative' = max retries.", + inputSchema: setResilienceProfileInput, + outputSchema: setResilienceProfileOutput, + scopes: ["write:resilience"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/resilience"], +}; + +// --- Tool 12: omniroute_test_combo --- +export const testComboInput = z.object({ + comboId: z.string().describe("ID of the combo to test"), + testPrompt: z.string().max(500).describe("Short test prompt (max 500 chars)"), +}); + +export const testComboOutput = z.object({ + results: z.array( + z.object({ + provider: z.string(), + model: z.string(), + success: z.boolean(), + latencyMs: z.number(), + cost: z.number(), + tokenCount: z.number(), + error: z.string().optional(), + }) + ), + summary: z.object({ + totalProviders: z.number(), + successful: z.number(), + fastestProvider: z.string(), + cheapestProvider: z.string(), + }), +}); + +export const testComboTool: McpToolDefinition = { + name: "omniroute_test_combo", + description: + "Tests a combo by sending a short test prompt to each provider in the combo and reporting individual results including latency, cost, and success status.", + inputSchema: testComboInput, + outputSchema: testComboOutput, + scopes: ["execute:completions", "read:combos"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/combos/test", "/v1/chat/completions"], +}; + +// --- Tool 13: omniroute_get_provider_metrics --- +export const getProviderMetricsInput = z.object({ + provider: z.string().describe("Provider name (e.g., 'claude', 'gemini-cli', 'codex')"), +}); + +export const getProviderMetricsOutput = z.object({ + provider: z.string(), + successRate: z.number(), + requestCount: z.number(), + avgLatencyMs: z.number(), + p50LatencyMs: z.number(), + p95LatencyMs: z.number(), + p99LatencyMs: z.number(), + errorRate: z.number(), + lastError: z + .object({ + message: z.string(), + timestamp: z.string(), + }) + .nullable(), + circuitBreakerState: z.enum(["CLOSED", "OPEN", "HALF_OPEN"]), + quotaInfo: z.object({ + used: z.number(), + total: z.number().nullable(), + resetAt: z.string().nullable(), + }), +}); + +export const getProviderMetricsTool: McpToolDefinition< + typeof getProviderMetricsInput, + typeof getProviderMetricsOutput +> = { + name: "omniroute_get_provider_metrics", + description: + "Returns detailed performance metrics for a specific provider including success/error rates, latency percentiles (p50/p95/p99), circuit breaker state, and quota information.", + inputSchema: getProviderMetricsInput, + outputSchema: getProviderMetricsOutput, + scopes: ["read:health"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/provider-metrics", "/api/resilience"], +}; + +// --- Tool 14: omniroute_best_combo_for_task --- +export const bestComboForTaskInput = z.object({ + taskType: z + .enum(["coding", "review", "planning", "analysis", "debugging", "documentation"]) + .describe("Type of task to find the best combo for"), + budgetConstraint: z.number().optional().describe("Maximum cost in USD"), + latencyConstraint: z.number().optional().describe("Maximum acceptable latency in ms"), +}); + +export const bestComboForTaskOutput = z.object({ + recommendedCombo: z.object({ + id: z.string(), + name: z.string(), + reason: z.string(), + }), + alternatives: z.array( + z.object({ + id: z.string(), + name: z.string(), + tradeoff: z.string(), + }) + ), + freeAlternative: z + .object({ + id: z.string(), + name: z.string(), + }) + .nullable(), +}); + +export const bestComboForTaskTool: McpToolDefinition< + typeof bestComboForTaskInput, + typeof bestComboForTaskOutput +> = { + name: "omniroute_best_combo_for_task", + description: + "Recommends the best combo for a given task type (coding, review, planning, etc.) considering budget and latency constraints. Also suggests alternatives and free options.", + inputSchema: bestComboForTaskInput, + outputSchema: bestComboForTaskOutput, + scopes: ["read:combos", "read:health"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/combos", "/api/combos/metrics", "/api/monitoring/health"], +}; + +// --- Tool 15: omniroute_explain_route --- +export const explainRouteInput = z.object({ + requestId: z.string().describe("Request ID from the X-Request-Id header"), +}); + +export const explainRouteOutput = z.object({ + requestId: z.string(), + decision: z.object({ + comboUsed: z.string(), + providerSelected: z.string(), + modelUsed: z.string(), + score: z.number(), + factors: z.array( + z.object({ + name: z.string(), + value: z.number(), + weight: z.number(), + contribution: z.number(), + }) + ), + fallbacksTriggered: z.array( + z.object({ + provider: z.string(), + reason: z.string(), + }) + ), + costActual: z.number(), + latencyActual: z.number(), + }), +}); + +export const explainRouteTool: McpToolDefinition< + typeof explainRouteInput, + typeof explainRouteOutput +> = { + name: "omniroute_explain_route", + description: + "Explains why a specific request was routed to a particular provider. Shows the scoring factors, weights, fallbacks triggered, actual cost, and latency.", + inputSchema: explainRouteInput, + outputSchema: explainRouteOutput, + scopes: ["read:health", "read:usage"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: [], +}; + +// --- Tool 16: omniroute_get_session_snapshot --- +export const getSessionSnapshotInput = z.object({}).describe("No parameters required"); + +export const getSessionSnapshotOutput = z.object({ + sessionStart: z.string(), + duration: z.string(), + requestCount: z.number(), + costTotal: z.number(), + tokenCount: z.object({ + prompt: z.number(), + completion: z.number(), + }), + topModels: z.array( + z.object({ + model: z.string(), + count: z.number(), + }) + ), + topProviders: z.array( + z.object({ + provider: z.string(), + count: z.number(), + }) + ), + errors: z.number(), + fallbacks: z.number(), + budgetGuard: z + .object({ + active: z.boolean(), + remaining: z.number(), + }) + .nullable(), +}); + +export const getSessionSnapshotTool: McpToolDefinition< + typeof getSessionSnapshotInput, + typeof getSessionSnapshotOutput +> = { + name: "omniroute_get_session_snapshot", + description: + "Returns a snapshot of the current working session including duration, request count, total cost, top models/providers used, error count, and budget guard status.", + inputSchema: getSessionSnapshotInput, + outputSchema: getSessionSnapshotOutput, + scopes: ["read:usage"], + auditLevel: "none", + phase: 2, + sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"], +}; + +// ============ Tool Registry ============ + +/** All MCP tool definitions, ordered by phase then name */ +export const MCP_TOOLS = [ + // Phase 1: Essential + getHealthTool, + listCombosTool, + getComboMetricsTool, + switchComboTool, + checkQuotaTool, + routeRequestTool, + costReportTool, + listModelsCatalogTool, + // Phase 2: Advanced + simulateRouteTool, + setBudgetGuardTool, + setResilienceProfileTool, + testComboTool, + getProviderMetricsTool, + bestComboForTaskTool, + explainRouteTool, + getSessionSnapshotTool, +] as const; + +/** Essential tools only (Phase 1) */ +export const MCP_ESSENTIAL_TOOLS = MCP_TOOLS.filter((t) => t.phase === 1); + +/** Advanced tools only (Phase 2) */ +export const MCP_ADVANCED_TOOLS = MCP_TOOLS.filter((t) => t.phase === 2); + +/** Map of tool name → tool definition */ +export const MCP_TOOL_MAP = Object.fromEntries(MCP_TOOLS.map((t) => [t.name, t])) as Record< + string, + (typeof MCP_TOOLS)[number] +>; diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts new file mode 100644 index 0000000000..202378fad9 --- /dev/null +++ b/open-sse/mcp-server/server.ts @@ -0,0 +1,434 @@ +/** + * OmniRoute MCP Server — Model Context Protocol server exposing + * OmniRoute gateway intelligence as tools for AI agents. + * + * Supports two transports: + * 1. stdio — for IDE integration (VS Code, Cursor, Claude Desktop) + * 2. HTTP — for remote/programmatic access + * + * Tools wrap existing OmniRoute API endpoints and add intelligence + * such as routing simulation, budget guards, and session snapshots. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { + getHealthInput, + listCombosInput, + getComboMetricsInput, + switchComboInput, + checkQuotaInput, + routeRequestInput, + costReportInput, + listModelsCatalogInput, + MCP_ESSENTIAL_TOOLS, +} from "./schemas/tools.ts"; + +import { logToolCall } from "./audit.ts"; + +// ============ Configuration ============ + +const OMNIROUTE_BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128"; +const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || ""; + +/** + * Internal fetch helper that calls OmniRoute API endpoints. + */ +async function omniRouteFetch(path: string, options: RequestInit = {}): Promise { + const url = `${OMNIROUTE_BASE_URL}${path}`; + const headers: Record = { + "Content-Type": "application/json", + ...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}), + ...((options.headers as Record) || {}), + }; + + const response = await fetch(url, { ...options, headers, signal: AbortSignal.timeout(10000) }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error(`OmniRoute API error [${response.status}]: ${errorText}`); + } + + return response.json(); +} + +// ============ Tool Handlers ============ + +async function handleGetHealth() { + const start = Date.now(); + try { + const [healthRaw, resilienceRaw, rateLimitsRaw] = await Promise.allSettled([ + omniRouteFetch("/api/monitoring/health"), + omniRouteFetch("/api/resilience"), + omniRouteFetch("/api/rate-limits"), + ]); + + const health = + healthRaw.status === "fulfilled" ? (healthRaw.value as Record) : {}; + const resilience = + resilienceRaw.status === "fulfilled" ? (resilienceRaw.value as Record) : {}; + const rateLimits = + rateLimitsRaw.status === "fulfilled" ? (rateLimitsRaw.value as Record) : {}; + + const result = { + uptime: String((health as any)?.uptime || "unknown"), + version: String((health as any)?.version || "unknown"), + memoryUsage: (health as any)?.memoryUsage || { heapUsed: 0, heapTotal: 0 }, + circuitBreakers: Array.isArray((resilience as any)?.circuitBreakers) + ? (resilience as any).circuitBreakers + : [], + rateLimits: Array.isArray((rateLimits as any)?.limits) ? (rateLimits as any).limits : [], + cacheStats: (health as any)?.cacheStats || undefined, + }; + + await logToolCall("omniroute_get_health", {}, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_get_health", {}, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + +async function handleListCombos(args: { includeMetrics?: boolean }) { + const start = Date.now(); + try { + const combos = (await omniRouteFetch("/api/combos")) as any; + let metrics: Record = {}; + if (args.includeMetrics) { + metrics = (await omniRouteFetch("/api/combos/metrics").catch(() => ({}))) as Record< + string, + unknown + >; + } + + const result = { + combos: Array.isArray(combos) + ? combos.map((c: any) => ({ + id: c.id, + name: c.name, + models: c.models || c.data?.models || [], + strategy: c.strategy || c.data?.strategy || "priority", + enabled: c.enabled !== false, + ...(args.includeMetrics ? { metrics: (metrics as any)?.[c.id] || null } : {}), + })) + : [], + }; + + await logToolCall("omniroute_list_combos", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_list_combos", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + +async function handleGetComboMetrics(args: { comboId: string }) { + const start = Date.now(); + try { + const result = await omniRouteFetch( + `/api/combos/metrics?comboId=${encodeURIComponent(args.comboId)}` + ); + await logToolCall("omniroute_get_combo_metrics", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_get_combo_metrics", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + +async function handleSwitchCombo(args: { comboId: string; active: boolean }) { + const start = Date.now(); + try { + const result = await omniRouteFetch(`/api/combos/${encodeURIComponent(args.comboId)}`, { + method: "PATCH", + body: JSON.stringify({ enabled: args.active }), + }); + await logToolCall("omniroute_switch_combo", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_switch_combo", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + +async function handleCheckQuota(args: { provider?: string; connectionId?: string }) { + const start = Date.now(); + try { + let path = "/api/usage/quota"; + if (args.connectionId) path += `?connectionId=${encodeURIComponent(args.connectionId)}`; + else if (args.provider) path += `?provider=${encodeURIComponent(args.provider)}`; + + const raw = (await omniRouteFetch(path)) as any; + const result = { + providers: Array.isArray(raw?.providers) ? raw.providers : Array.isArray(raw) ? raw : [], + }; + + await logToolCall("omniroute_check_quota", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_check_quota", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + +async function handleRouteRequest(args: { + model: string; + messages: Array<{ role: string; content: string }>; + combo?: string; + budget?: number; + role?: string; + stream?: boolean; +}) { + const start = Date.now(); + try { + const body: Record = { + model: args.model, + messages: args.messages, + stream: false, // MCP tool always returns non-streaming + }; + if (args.combo) { + body["x-combo"] = args.combo; + } + + const raw = (await omniRouteFetch("/v1/chat/completions", { + method: "POST", + body: JSON.stringify(body), + })) as any; + + const result = { + response: { + content: raw?.choices?.[0]?.message?.content || "", + model: raw?.model || args.model, + tokens: { + prompt: raw?.usage?.prompt_tokens || 0, + completion: raw?.usage?.completion_tokens || 0, + }, + }, + routing: { + provider: raw?.provider || "unknown", + combo: raw?.combo || null, + fallbacksTriggered: raw?.fallbacksTriggered || 0, + cost: raw?.cost || 0, + latencyMs: Date.now() - start, + routingExplanation: raw?.routingExplanation || "Request routed through primary provider", + }, + }; + + await logToolCall( + "omniroute_route_request", + { model: args.model, messageCount: args.messages.length }, + result.routing, + Date.now() - start, + true + ); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall( + "omniroute_route_request", + { model: args.model }, + null, + Date.now() - start, + false, + msg + ); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + +async function handleCostReport(args: { period?: string }) { + const start = Date.now(); + try { + const period = args.period || "session"; + const raw = (await omniRouteFetch( + `/api/usage/analytics?period=${encodeURIComponent(period)}` + )) as any; + + const result = { + period, + totalCost: raw?.totalCost || 0, + requestCount: raw?.requestCount || 0, + tokenCount: raw?.tokenCount || { prompt: 0, completion: 0 }, + byProvider: raw?.byProvider || [], + byModel: raw?.byModel || [], + budget: raw?.budget || { limit: null, remaining: null }, + }; + + await logToolCall("omniroute_cost_report", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_cost_report", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + +async function handleListModelsCatalog(args: { provider?: string; capability?: string }) { + const start = Date.now(); + try { + let path = "/v1/models"; + const params = new URLSearchParams(); + if (args.provider) params.set("provider", args.provider); + if (args.capability) params.set("capability", args.capability); + if (params.toString()) path += `?${params.toString()}`; + + const raw = (await omniRouteFetch(path)) as any; + const result = { + models: Array.isArray(raw?.data) + ? raw.data.map((m: any) => ({ + id: m.id, + provider: m.owned_by || m.provider || "unknown", + capabilities: m.capabilities || ["chat"], + status: m.status || "available", + pricing: m.pricing || undefined, + })) + : [], + }; + + await logToolCall( + "omniroute_list_models_catalog", + args, + { modelCount: result.models.length }, + Date.now() - start, + true + ); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_list_models_catalog", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + +// ============ MCP Server Setup ============ + +/** + * Create and configure the OmniRoute MCP Server with all essential tools. + */ +export function createMcpServer(): McpServer { + const server = new McpServer({ + name: "omniroute", + version: process.env.npm_package_version || "1.8.1", + }); + + // Register essential tools + server.tool( + "omniroute_get_health", + "Returns OmniRoute health status including uptime, memory, circuit breakers, rate limits, and cache stats", + {}, + handleGetHealth + ); + + server.tool( + "omniroute_list_combos", + "Lists all configured combos (model chains) with strategies and optional metrics", + { includeMetrics: { type: "boolean", description: "Include performance metrics per combo" } }, + (args) => handleListCombos(args as any) + ); + + server.tool( + "omniroute_get_combo_metrics", + "Returns detailed performance metrics for a specific combo", + { comboId: { type: "string", description: "ID of the combo to get metrics for" } }, + (args) => handleGetComboMetrics(args as any) + ); + + server.tool( + "omniroute_switch_combo", + "Activates or deactivates a combo for routing", + { + comboId: { type: "string", description: "ID of the combo" }, + active: { type: "boolean", description: "Whether to enable or disable" }, + }, + (args) => handleSwitchCombo(args as any) + ); + + server.tool( + "omniroute_check_quota", + "Checks remaining API quota for one or all providers", + { + provider: { type: "string", description: "Filter by provider name (optional)" }, + connectionId: { type: "string", description: "Filter by connection ID (optional)" }, + }, + (args) => handleCheckQuota(args as any) + ); + + server.tool( + "omniroute_route_request", + "Sends a chat completion request through OmniRoute intelligent routing", + { + model: { type: "string", description: "Model identifier" }, + messages: { + type: "array", + items: { + type: "object", + properties: { role: { type: "string" }, content: { type: "string" } }, + }, + description: "Chat messages", + }, + combo: { type: "string", description: "Specific combo to route through (optional)" }, + budget: { type: "number", description: "Max cost in USD (optional)" }, + role: { + type: "string", + description: "Task role hint: coding, review, planning, analysis (optional)", + }, + }, + (args) => handleRouteRequest(args as any) + ); + + server.tool( + "omniroute_cost_report", + "Generates a cost report for the specified period", + { + period: { + type: "string", + description: "Time period: session, day, week, month (default: session)", + }, + }, + (args) => handleCostReport(args as any) + ); + + server.tool( + "omniroute_list_models_catalog", + "Lists all available AI models across providers with capabilities and pricing", + { + provider: { type: "string", description: "Filter by provider name (optional)" }, + capability: { + type: "string", + description: "Filter by capability: chat, embedding, image (optional)", + }, + }, + (args) => handleListModelsCatalog(args as any) + ); + + return server; +} + +// ============ Main Entry Point (stdio) ============ + +/** + * Start the MCP server with stdio transport. + * Called when `omniroute --mcp` is used. + */ +export async function startMcpStdio(): Promise { + const server = createMcpServer(); + const transport = new StdioServerTransport(); + + console.error("[MCP] OmniRoute MCP Server starting (stdio transport)..."); + await server.connect(transport); + console.error("[MCP] OmniRoute MCP Server connected and ready."); +} + +// If this file is run directly, start stdio server +if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/"))) { + startMcpStdio().catch((err) => { + console.error("[MCP] Fatal error:", err); + process.exit(1); + }); +} diff --git a/package-lock.json b/package-lock.json index 7eb34fd874..a6a6bec2df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "omniroute", - "version": "1.8.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "1.8.0", + "version": "1.8.1", "hasInstallScript": true, "license": "MIT", "workspaces": [ "open-sse" ], "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "@monaco-editor/react": "^4.7.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", @@ -1068,6 +1069,18 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@hono/node-server": { + "version": "1.19.10", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz", + "integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1636,6 +1649,68 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@monaco-editor/loader": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", @@ -3730,6 +3805,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -4661,6 +4775,23 @@ "node": ">=6.6.0" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -4683,7 +4814,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5851,6 +5981,27 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -5903,6 +6054,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -5913,7 +6091,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5966,6 +6143,22 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6524,6 +6717,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hono": { + "version": "4.12.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.4.tgz", + "integrity": "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -7306,7 +7508,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -7421,6 +7622,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -8354,7 +8561,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8675,7 +8881,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8789,6 +8994,15 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkijs": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz", @@ -9307,6 +9521,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -9753,7 +9976,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -9766,7 +9988,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10909,7 +11130,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -11267,6 +11487,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/package.json b/package.json index 71dc239895..dab66d1c36 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "prepare": "husky" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "@monaco-editor/react": "^4.7.0", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", diff --git a/src/lib/db/migrations/002_mcp_a2a_tables.sql b/src/lib/db/migrations/002_mcp_a2a_tables.sql new file mode 100644 index 0000000000..4ace15d4c9 --- /dev/null +++ b/src/lib/db/migrations/002_mcp_a2a_tables.sql @@ -0,0 +1,95 @@ +-- 002_mcp_a2a_tables.sql +-- Tables for MCP Server audit, A2A task lifecycle, +-- routing decision tracking, and Auto-Combo adaptation state. + +-- ============ MCP Tool Audit ============ +-- Tracks every MCP tool invocation for security audit and observability. +CREATE TABLE IF NOT EXISTS mcp_tool_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool_name TEXT NOT NULL, + input_hash TEXT, + output_summary TEXT, + duration_ms INTEGER, + api_key_id TEXT, + success INTEGER DEFAULT 1, + error_code TEXT, + created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_mta_tool ON mcp_tool_audit(tool_name); +CREATE INDEX IF NOT EXISTS idx_mta_created ON mcp_tool_audit(created_at); +CREATE INDEX IF NOT EXISTS idx_mta_apikey ON mcp_tool_audit(api_key_id); + +-- ============ A2A Tasks ============ +-- Stores A2A task lifecycle (submitted → working → completed/failed/cancelled). +CREATE TABLE IF NOT EXISTS a2a_tasks ( + id TEXT PRIMARY KEY, + state TEXT NOT NULL DEFAULT 'submitted', + skill_id TEXT, + input_json TEXT, + output_json TEXT, + cost_estimated REAL, + cost_actual REAL, + routing_explanation TEXT, + resilience_trace TEXT, + policy_verdict TEXT, + api_key_id TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + completed_at TEXT, + expires_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_a2a_state ON a2a_tasks(state); +CREATE INDEX IF NOT EXISTS idx_a2a_skill ON a2a_tasks(skill_id); +CREATE INDEX IF NOT EXISTS idx_a2a_created ON a2a_tasks(created_at); + +-- ============ A2A Task Events ============ +-- Event log for each A2A task (state transitions, errors, fallbacks). +CREATE TABLE IF NOT EXISTS a2a_task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL REFERENCES a2a_tasks(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + data_json TEXT, + created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_a2ae_task ON a2a_task_events(task_id); +CREATE INDEX IF NOT EXISTS idx_a2ae_type ON a2a_task_events(event_type); + +-- ============ Routing Decisions ============ +-- Records every routing decision for explainability and learning. +CREATE TABLE IF NOT EXISTS routing_decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT, + task_type TEXT, + combo_id TEXT, + provider_selected TEXT, + model_selected TEXT, + score REAL, + factors_json TEXT, + fallbacks_triggered INTEGER DEFAULT 0, + success INTEGER DEFAULT 1, + latency_ms INTEGER, + cost REAL, + source TEXT DEFAULT 'api', + created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_rd_request ON routing_decisions(request_id); +CREATE INDEX IF NOT EXISTS idx_rd_combo ON routing_decisions(combo_id); +CREATE INDEX IF NOT EXISTS idx_rd_provider ON routing_decisions(provider_selected); +CREATE INDEX IF NOT EXISTS idx_rd_created ON routing_decisions(created_at); + +-- ============ Combo Adaptation State ============ +-- Persisted learning state for Auto-Combo scoring engine. +CREATE TABLE IF NOT EXISTS combo_adaptation_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + combo_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + learned_score REAL DEFAULT 0.5, + request_count INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + avg_latency_ms REAL, + last_failure_at TEXT, + excluded_until TEXT, + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(combo_id, provider_id) +); +CREATE INDEX IF NOT EXISTS idx_cas_combo ON combo_adaptation_state(combo_id); diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 1d2400dd25..d031dc9877 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -1,3 +1,4 @@ // Shared Constants - Export all export * from "./colors"; export * from "./config"; +export * from "./mcpScopes"; diff --git a/src/shared/constants/mcpScopes.ts b/src/shared/constants/mcpScopes.ts new file mode 100644 index 0000000000..afd4929c64 --- /dev/null +++ b/src/shared/constants/mcpScopes.ts @@ -0,0 +1,100 @@ +/** + * MCP Authorization Scopes — Defines permission scopes for each MCP tool. + * + * Each tool requires specific scopes to execute. API keys can be configured + * with a subset of scopes to limit tool access (least-privilege). + */ + +// ============ Scope Definitions ============ + +/** All available MCP scopes */ +export const MCP_SCOPE_LIST = [ + "read:health", + "read:combos", + "write:combos", + "read:quota", + "read:usage", + "read:models", + "execute:completions", + "write:budget", + "write:resilience", +] as const; + +export type McpScope = (typeof MCP_SCOPE_LIST)[number]; + +// ============ Tool → Scope Mapping ============ + +/** Maps each MCP tool to its required scopes */ +export const MCP_TOOL_SCOPES: Record = { + // Phase 1: Essential Tools + omniroute_get_health: ["read:health"], + omniroute_list_combos: ["read:combos"], + omniroute_get_combo_metrics: ["read:combos"], + omniroute_switch_combo: ["write:combos"], + omniroute_check_quota: ["read:quota"], + omniroute_route_request: ["execute:completions"], + omniroute_cost_report: ["read:usage"], + omniroute_list_models_catalog: ["read:models"], + + // Phase 2: Advanced Tools + omniroute_simulate_route: ["read:health", "read:combos"], + omniroute_set_budget_guard: ["write:budget"], + omniroute_set_resilience_profile: ["write:resilience"], + omniroute_test_combo: ["execute:completions", "read:combos"], + omniroute_get_provider_metrics: ["read:health"], + omniroute_best_combo_for_task: ["read:combos", "read:health"], + omniroute_explain_route: ["read:health", "read:usage"], + omniroute_get_session_snapshot: ["read:usage"], +} as const; + +// ============ Scope Groups ============ + +/** Preset scope bundles for common use cases */ +export const MCP_SCOPE_PRESETS = { + /** Read-only access to all health, combo, quota, and usage data */ + readonly: [ + "read:health", + "read:combos", + "read:quota", + "read:usage", + "read:models", + ] as const satisfies readonly McpScope[], + + /** Full access including writes and execution */ + full: [...MCP_SCOPE_LIST] as McpScope[], + + /** Monitoring only — health and metrics */ + monitor: ["read:health", "read:quota", "read:usage"] as const satisfies readonly McpScope[], + + /** Agent — can execute completions and read state */ + agent: [ + "read:health", + "read:combos", + "read:quota", + "read:usage", + "read:models", + "execute:completions", + ] as const satisfies readonly McpScope[], +} as const; + +// ============ Helpers ============ + +/** + * Check if a set of granted scopes satisfies the required scopes for a tool. + */ +export function hasRequiredScopes(grantedScopes: readonly string[], toolName: string): boolean { + const required = MCP_TOOL_SCOPES[toolName]; + if (!required) return false; + const granted = new Set(grantedScopes); + return required.every((scope) => granted.has(scope)); +} + +/** + * Get the list of missing scopes for a tool given granted scopes. + */ +export function getMissingScopes(grantedScopes: readonly string[], toolName: string): string[] { + const required = MCP_TOOL_SCOPES[toolName]; + if (!required) return []; + const granted = new Set(grantedScopes); + return required.filter((scope) => !granted.has(scope)); +}