feat: add MCP server mode with --mcp flag for IDE integration

Add stdio-based MCP server support to OmniRoute CLI, enabling AI agents
in VS Code, Cursor, Claude Desktop, and Copilot to interact with
OmniRoute tools (health, combos, quota, routing). Update help text,
gitignore vscode-extension subproject, and include MCP/A2A strategy report.
This commit is contained in:
diegosouzapw
2026-03-03 17:42:24 -03:00
parent 70465ada4d
commit 7eb45b2e19
15 changed files with 2793 additions and 10 deletions

3
.gitignore vendored
View File

@@ -113,3 +113,6 @@ electron/package-lock.json
electron/dist-electron/
electron/node_modules/
icon.iconset/
# VS Code Extension (independent Git repo)
vscode-extension/

View File

@@ -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 <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;

View File

@@ -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).

View File

@@ -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<any> {
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<void> {
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<unknown[]> {
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: [] };
}
}

View File

@@ -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";

View File

@@ -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<typeof AgentCardSchema>;
export type AgentSkill = z.infer<typeof AgentSkillSchema>;
// ============ Task Schema ============
export const TaskStateEnum = z.enum(["submitted", "working", "completed", "failed", "cancelled"]);
export type TaskState = z.infer<typeof TaskStateEnum>;
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<typeof TaskSchema>;
export type TaskInput = z.infer<typeof TaskInputSchema>;
export type TaskOutput = z.infer<typeof TaskOutputSchema>;
export type CostEnvelope = z.infer<typeof CostEnvelopeSchema>;
export type ResilienceTraceEvent = z.infer<typeof ResilienceTraceEventSchema>;
export type PolicyVerdict = z.infer<typeof PolicyVerdictSchema>;
// ============ 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<typeof JsonRpcRequestSchema>;
export type JsonRpcResponse = z.infer<typeof JsonRpcResponseSchema>;
// ============ 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<typeof MessageSendParamsSchema>;
export type TasksGetParams = z.infer<typeof TasksGetParamsSchema>;
export type TasksCancelParams = z.infer<typeof TasksCancelParamsSchema>;
// ============ 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;

View File

@@ -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<string, unknown>;
}
// ============ 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<string> {
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) + "…";
}

View File

@@ -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";

View File

@@ -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<TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny> {
/** 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<typeof getHealthInput, typeof getHealthOutput> = {
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<typeof listCombosInput, typeof listCombosOutput> = {
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<typeof switchComboInput, typeof switchComboOutput> =
{
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<typeof checkQuotaInput, typeof checkQuotaOutput> = {
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<typeof costReportInput, typeof costReportOutput> = {
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<typeof testComboInput, typeof testComboOutput> = {
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]
>;

View File

@@ -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<unknown> {
const url = `${OMNIROUTE_BASE_URL}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}),
...((options.headers as Record<string, string>) || {}),
};
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<string, unknown>) : {};
const resilience =
resilienceRaw.status === "fulfilled" ? (resilienceRaw.value as Record<string, unknown>) : {};
const rateLimits =
rateLimitsRaw.status === "fulfilled" ? (rateLimitsRaw.value as Record<string, unknown>) : {};
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<string, unknown> = {};
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<string, unknown> = {
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<void> {
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);
});
}

249
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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);

View File

@@ -1,3 +1,4 @@
// Shared Constants - Export all
export * from "./colors";
export * from "./config";
export * from "./mcpScopes";

View File

@@ -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<string, readonly McpScope[]> = {
// 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));
}