mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
OmniRoute v3.8.29 — 115 commits since v3.8.28. Full CHANGELOG + 41 i18n mirrors. All content quality gates green (build, unit 8/8, vitest 188/188, PR test policy, quality gates extended, docs sync, quality ratchet). Remaining red CI checks are pre-existing release flakes (coverage-shard/integration/node-compat teardown), a new transitive undici advisory in electron devDeps, and a workflow-level CodeQL fail (0 open alerts). VPS-validated by the operator.
1468 lines
50 KiB
TypeScript
1468 lines
50 KiB
TypeScript
/**
|
|
* 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 {
|
|
getComboModelProvider,
|
|
getComboModelString,
|
|
getComboStepTarget,
|
|
} from "../../src/lib/combos/steps.ts";
|
|
|
|
import {
|
|
MCP_TOOLS,
|
|
getHealthInput,
|
|
listCombosInput,
|
|
getComboMetricsInput,
|
|
switchComboInput,
|
|
checkQuotaInput,
|
|
routeRequestInput,
|
|
costReportInput,
|
|
listModelsCatalogInput,
|
|
webSearchInput,
|
|
simulateRouteInput,
|
|
setBudgetGuardInput,
|
|
setRoutingStrategyInput,
|
|
setResilienceProfileInput,
|
|
testComboInput,
|
|
getProviderMetricsInput,
|
|
bestComboForTaskInput,
|
|
explainRouteInput,
|
|
getSessionSnapshotInput,
|
|
dbHealthCheckInput,
|
|
syncPricingInput,
|
|
cacheStatsInput,
|
|
cacheFlushInput,
|
|
oneproxyFetchInput,
|
|
oneproxyRotateInput,
|
|
oneproxyStatsInput,
|
|
} from "./schemas/tools.ts";
|
|
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";
|
|
|
|
import { z } from "zod";
|
|
import { closeAuditDb, logToolCall } from "./audit.ts";
|
|
import {
|
|
evaluateToolScopes,
|
|
resolveCallerScopeContext,
|
|
type McpToolExtraLike,
|
|
} from "./scopeEnforcement.ts";
|
|
|
|
import {
|
|
handleSimulateRoute,
|
|
handleSetBudgetGuard,
|
|
handleSetRoutingStrategy,
|
|
handleSetResilienceProfile,
|
|
handleTestCombo,
|
|
handleGetProviderMetrics,
|
|
handleBestComboForTask,
|
|
handleExplainRoute,
|
|
handleGetSessionSnapshot,
|
|
handleDbHealthCheck,
|
|
handleSyncPricing,
|
|
handleCacheStats,
|
|
handleCacheFlush,
|
|
handleOneproxyFetch,
|
|
handleOneproxyRotate,
|
|
handleOneproxyStats,
|
|
} from "./tools/advancedTools.ts";
|
|
import { memoryTools } from "./tools/memoryTools.ts";
|
|
import { skillTools } from "./tools/skillTools.ts";
|
|
import { agentSkillTools } from "./tools/agentSkillTools.ts";
|
|
import { skillRegistry } from "../../src/lib/skills/registry.ts";
|
|
import { skillExecutor } from "../../src/lib/skills/executor.ts";
|
|
import { pluginTools } from "./tools/pluginTools.ts";
|
|
import { compressionTools } from "./tools/compressionTools.ts";
|
|
import { gamificationTools } from "./tools/gamificationTools.ts";
|
|
import { notionTools } from "./tools/notionTools.ts";
|
|
import { obsidianTools } from "./tools/obsidianTools.ts";
|
|
import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts";
|
|
import { reduceToolManifest, readMcpToolProfileFromEnv } from "./toolCardinality.ts";
|
|
import { smartFilterText } from "../services/compression/engines/mcpAccessibility/index.ts";
|
|
import {
|
|
DEFAULT_MCP_ACCESSIBILITY_CONFIG,
|
|
clampMcpAccessibilityConfig,
|
|
type McpAccessibilityConfig,
|
|
} from "../services/compression/engines/mcpAccessibility/constants.ts";
|
|
import { getDbInstance } from "../../src/lib/db/core.ts";
|
|
import { getProviderConnections } from "../../src/lib/db/providers.ts";
|
|
import { getCodexRequestDefaults } from "../../src/lib/providers/requestDefaults.ts";
|
|
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
|
|
import { AI_PROVIDERS, NOAUTH_PROVIDERS } from "../../src/shared/constants/providers.ts";
|
|
import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts";
|
|
|
|
// ============ Configuration ============
|
|
|
|
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
|
|
const MCP_ENFORCE_SCOPES = process.env.OMNIROUTE_MCP_ENFORCE_SCOPES === "true";
|
|
const MCP_ALLOWED_SCOPES = new Set(
|
|
(process.env.OMNIROUTE_MCP_SCOPES || "")
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
);
|
|
const TOTAL_MCP_TOOL_COUNT =
|
|
MCP_TOOLS.length +
|
|
Object.keys(memoryTools).length +
|
|
Object.keys(skillTools).length +
|
|
Object.keys(agentSkillTools).length +
|
|
gamificationTools.length +
|
|
pluginTools.length +
|
|
notionTools.length +
|
|
obsidianTools.length;
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
function readMcpDescriptionCompressionEnabled(): boolean {
|
|
try {
|
|
const row = getDbInstance()
|
|
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
|
|
.get("compression", "mcpDescriptionCompressionEnabled") as { value?: string } | undefined;
|
|
if (!row?.value) return true;
|
|
return JSON.parse(row.value) !== false;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function readMcpAccessibilityConfig(): McpAccessibilityConfig {
|
|
try {
|
|
const row = getDbInstance()
|
|
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
|
|
.get("compression", "mcpAccessibility") as { value?: string } | undefined;
|
|
if (!row?.value) return { ...DEFAULT_MCP_ACCESSIBILITY_CONFIG };
|
|
// clampMcpAccessibilityConfig bounds every field (and folds in the non-object guard), so a
|
|
// persisted out-of-range maxTextChars can't make smartFilterText truncate the whole text.
|
|
return clampMcpAccessibilityConfig(JSON.parse(row.value));
|
|
} catch {
|
|
return { ...DEFAULT_MCP_ACCESSIBILITY_CONFIG };
|
|
}
|
|
}
|
|
|
|
type TextToolResult = {
|
|
content: Array<{ type: "text"; text: string }>;
|
|
isError?: boolean;
|
|
};
|
|
|
|
type McpCatalogStatus = "available" | "degraded" | "unavailable";
|
|
|
|
type McpCatalogResponse = {
|
|
models: Array<{
|
|
id: string;
|
|
provider: string;
|
|
capabilities: string[];
|
|
status: McpCatalogStatus;
|
|
thinkingEffort?: string;
|
|
pricing?: unknown;
|
|
}>;
|
|
source: string;
|
|
warning?: string;
|
|
};
|
|
|
|
type ProviderConnectionLike = {
|
|
id?: string;
|
|
provider?: string;
|
|
isActive?: boolean;
|
|
providerSpecificData?: unknown;
|
|
};
|
|
|
|
function toRecord(value: unknown): JsonRecord {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
|
}
|
|
|
|
function toArray(value: unknown): unknown[] {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function toString(value: unknown, fallback = ""): string {
|
|
return typeof value === "string" ? value : fallback;
|
|
}
|
|
|
|
function toNumber(value: unknown, fallback = 0): number {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function toStringArray(value: unknown, fallback: string[] = []): string[] {
|
|
const values = toArray(value).filter((entry): entry is string => typeof entry === "string");
|
|
return values.length > 0 ? values : fallback;
|
|
}
|
|
|
|
function normalizeComboModels(
|
|
rawModels: unknown
|
|
): Array<{ provider: string; model: string; priority: number }> {
|
|
return toArray(rawModels).map((rawModel, index) => {
|
|
const modelRecord = toRecord(rawModel);
|
|
const modelString = getComboModelString(rawModel);
|
|
const target = getComboStepTarget(rawModel);
|
|
const provider =
|
|
getComboModelProvider(rawModel) ||
|
|
(modelString ? "unknown" : target ? "combo" : toString(modelRecord.provider, "unknown"));
|
|
|
|
return {
|
|
provider,
|
|
model: modelString || target || toString(modelRecord.model, "unknown"),
|
|
priority: toNumber(modelRecord.priority, index + 1),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Internal fetch helper that calls OmniRoute API endpoints.
|
|
*/
|
|
function getOmniRouteApiKey(): string {
|
|
return process.env.OMNIROUTE_API_KEY || "";
|
|
}
|
|
|
|
async function omniRouteFetch(path: string, options: RequestInit = {}): Promise<unknown> {
|
|
const url = `${OMNIROUTE_BASE_URL}${path}`;
|
|
const apiKey = getOmniRouteApiKey();
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
...((options.headers as Record<string, string>) || {}),
|
|
};
|
|
|
|
const signal = options.signal || AbortSignal.timeout(10000);
|
|
const response = await fetch(url, { ...options, headers, signal });
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => "Unknown error");
|
|
throw new Error(`OmniRoute API error [${response.status}]: ${errorText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
function buildProviderAliasMap(): Record<string, string> {
|
|
const aliasMap: Record<string, string> = {};
|
|
|
|
for (const provider of Object.values(AI_PROVIDERS)) {
|
|
if (!provider?.id) continue;
|
|
aliasMap[provider.id] = provider.id;
|
|
if (typeof provider.alias === "string" && provider.alias.length > 0) {
|
|
aliasMap[provider.alias] = provider.id;
|
|
}
|
|
}
|
|
|
|
for (const provider of Object.values(NOAUTH_PROVIDERS)) {
|
|
if (!provider?.id) continue;
|
|
aliasMap[provider.id] = provider.id;
|
|
if ("alias" in provider && typeof provider.alias === "string" && provider.alias.length > 0) {
|
|
aliasMap[provider.alias] = provider.id;
|
|
}
|
|
}
|
|
|
|
return aliasMap;
|
|
}
|
|
|
|
function normalizeCapability(value: string): string {
|
|
switch (value) {
|
|
case "embeddings":
|
|
return "embedding";
|
|
case "images":
|
|
return "image";
|
|
case "videos":
|
|
return "video";
|
|
case "moderations":
|
|
return "moderation";
|
|
case "chat-completions":
|
|
return "chat";
|
|
default:
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function getCatalogModelCapabilities(model: JsonRecord): string[] {
|
|
if (Array.isArray(model.capabilities) && model.capabilities.length > 0) {
|
|
return toStringArray(model.capabilities, ["chat"]).map(normalizeCapability);
|
|
}
|
|
|
|
if (Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0) {
|
|
return toStringArray(model.supportedEndpoints, ["chat"]).map(normalizeCapability);
|
|
}
|
|
|
|
const type = toString(model.type);
|
|
if (type) return [normalizeCapability(type)];
|
|
|
|
return ["chat"];
|
|
}
|
|
|
|
function normalizeCatalogStatus(model: JsonRecord, source: string, warning?: string): McpCatalogStatus {
|
|
const explicitStatus = toString(model.status);
|
|
if (explicitStatus === "available" || explicitStatus === "degraded" || explicitStatus === "unavailable") {
|
|
return explicitStatus;
|
|
}
|
|
|
|
if (warning || source === "local_catalog") return "degraded";
|
|
return "available";
|
|
}
|
|
|
|
function getConnectionThinkingEffort(connection: ProviderConnectionLike): string | undefined {
|
|
const provider = typeof connection.provider === "string" ? connection.provider : null;
|
|
const providerSpecificData = toRecord(connection.providerSpecificData);
|
|
|
|
if (provider === "codex") {
|
|
return getCodexRequestDefaults(providerSpecificData).reasoningEffort || "medium";
|
|
}
|
|
|
|
const rawThinkingEffort = toString(providerSpecificData.thinkingEffort);
|
|
return rawThinkingEffort || undefined;
|
|
}
|
|
|
|
function normalizeProviderModelRecord(
|
|
rawModel: unknown,
|
|
fallbackProvider: string,
|
|
source: string,
|
|
warning?: string,
|
|
thinkingEffort?: string
|
|
) {
|
|
const model = toRecord(rawModel);
|
|
const id = toString(model.id, "");
|
|
|
|
return {
|
|
id,
|
|
provider: toString(model.owned_by, toString(model.provider, fallbackProvider)),
|
|
capabilities: getCatalogModelCapabilities(model),
|
|
status: normalizeCatalogStatus(model, source, warning),
|
|
...(thinkingEffort ? { thinkingEffort } : {}),
|
|
pricing: model.pricing,
|
|
};
|
|
}
|
|
|
|
export async function getMcpModelsCatalog(
|
|
args: { provider?: string; capability?: string },
|
|
deps: {
|
|
fetchJson?: (path: string) => Promise<unknown>;
|
|
listProviderConnections?: () => Promise<ProviderConnectionLike[]>;
|
|
} = {}
|
|
): Promise<McpCatalogResponse> {
|
|
const fetchJson = deps.fetchJson ?? ((path: string) => omniRouteFetch(path));
|
|
const listProviderConnections = deps.listProviderConnections ?? getProviderConnections;
|
|
const aliasMap = buildProviderAliasMap();
|
|
const normalizeProviderId = (value: string) => aliasMap[value] || value;
|
|
const requestedProvider = args.provider ? normalizeProviderId(args.provider) : null;
|
|
const requestedCapability = args.capability ? normalizeCapability(args.capability) : null;
|
|
|
|
let connections = await listProviderConnections();
|
|
connections = Array.isArray(connections) ? connections : [];
|
|
|
|
const activeConnections = connections.filter((connection) => {
|
|
const provider = typeof connection?.provider === "string" ? normalizeProviderId(connection.provider) : null;
|
|
if (!provider || !connection?.id || connection.isActive === false) return false;
|
|
if (requestedProvider && provider !== requestedProvider) return false;
|
|
return true;
|
|
});
|
|
|
|
const requestSpecs = activeConnections.map((connection) => ({
|
|
provider: normalizeProviderId(String(connection.provider)),
|
|
path: `/api/providers/${encodeURIComponent(String(connection.id))}/models?excludeHidden=true`,
|
|
thinkingEffort: getConnectionThinkingEffort(connection),
|
|
}));
|
|
|
|
if (requestedProvider && requestSpecs.length === 0) {
|
|
const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some((provider) => provider.id === requestedProvider);
|
|
if (isNoAuthProvider) {
|
|
requestSpecs.push({
|
|
provider: requestedProvider,
|
|
path: `/api/v1/providers/${encodeURIComponent(requestedProvider)}/models`,
|
|
thinkingEffort: undefined,
|
|
});
|
|
} else {
|
|
return {
|
|
models: [],
|
|
source: "provider_connections",
|
|
warning: `No active connections found for provider '${requestedProvider}'.`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const collectedModels = new Map<string, McpCatalogResponse["models"][number]>();
|
|
const warnings = new Set<string>();
|
|
const sources = new Set<string>();
|
|
|
|
for (const spec of requestSpecs) {
|
|
const raw = toRecord(await fetchJson(spec.path));
|
|
const source = toString(raw.source, spec.path.startsWith("/api/providers/") ? "api" : "v1_catalog");
|
|
const warning = raw.warning ? String(raw.warning) : undefined;
|
|
if (warning) warnings.add(warning);
|
|
sources.add(source);
|
|
|
|
const rawModels = Array.isArray(raw.models)
|
|
? raw.models
|
|
: Array.isArray(raw.data)
|
|
? raw.data
|
|
: [];
|
|
|
|
for (const rawModel of rawModels) {
|
|
const normalized = normalizeProviderModelRecord(rawModel, spec.provider, source, warning);
|
|
if (spec.thinkingEffort && !normalized.thinkingEffort) {
|
|
normalized.thinkingEffort = spec.thinkingEffort;
|
|
}
|
|
if (!normalized.id) continue;
|
|
if (requestedCapability && !normalized.capabilities.includes(requestedCapability)) continue;
|
|
|
|
const key = `${normalized.provider}:${normalized.id}`;
|
|
if (!collectedModels.has(key)) {
|
|
collectedModels.set(key, normalized);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
models: [...collectedModels.values()],
|
|
source: sources.size === 1 ? [...sources][0] : "aggregated_provider_models",
|
|
...(warnings.size > 0 ? { warning: [...warnings].join(" | ") } : {}),
|
|
};
|
|
}
|
|
|
|
function withScopeEnforcement(
|
|
toolName: string,
|
|
handler: (args: unknown, extra?: McpToolExtraLike) => Promise<TextToolResult>,
|
|
toolScopes?: readonly string[]
|
|
) {
|
|
return async (args: unknown, extra?: McpToolExtraLike): Promise<TextToolResult> => {
|
|
const scopeContext = resolveCallerScopeContext(extra, Array.from(MCP_ALLOWED_SCOPES));
|
|
const scopeCheck = evaluateToolScopes(toolName, scopeContext.scopes, MCP_ENFORCE_SCOPES, toolScopes);
|
|
if (!scopeCheck.allowed) {
|
|
const missingScopes =
|
|
scopeCheck.missing.length > 0 ? scopeCheck.missing.join(", ") : "unavailable";
|
|
const reason = scopeCheck.reason || "scope_check_failed";
|
|
const msg =
|
|
`Insufficient MCP scopes for ${toolName}. ` +
|
|
`Missing: ${missingScopes}. ` +
|
|
`Caller=${scopeContext.callerId}, source=${scopeContext.source}.`;
|
|
const safeArgs = args && typeof args === "object" ? toRecord(args) : { rawArgs: args };
|
|
await logToolCall(
|
|
toolName,
|
|
{
|
|
...safeArgs,
|
|
_scopeCheck: {
|
|
callerId: scopeContext.callerId,
|
|
source: scopeContext.source,
|
|
required: scopeCheck.required,
|
|
provided: scopeCheck.provided,
|
|
missing: scopeCheck.missing,
|
|
},
|
|
},
|
|
null,
|
|
0,
|
|
false,
|
|
`scope_denied:${reason}`
|
|
);
|
|
return {
|
|
content: [{ type: "text" as const, text: `Error: ${msg}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
return handler(args, extra);
|
|
};
|
|
}
|
|
|
|
// ============ 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" ? toRecord(healthRaw.value) : {};
|
|
const resilience = resilienceRaw.status === "fulfilled" ? toRecord(resilienceRaw.value) : {};
|
|
const rateLimits = rateLimitsRaw.status === "fulfilled" ? toRecord(rateLimitsRaw.value) : {};
|
|
const memoryUsageRaw = toRecord(health.memoryUsage);
|
|
const cacheStatsRaw = toRecord(health.cacheStats);
|
|
const resilienceCircuitBreakers = toArray(resilience.circuitBreakers);
|
|
const rateLimitEntries = toArray(rateLimits.limits);
|
|
|
|
const result = {
|
|
uptime: toString(health.uptime, "unknown"),
|
|
version: toString(health.version, "unknown"),
|
|
memoryUsage: {
|
|
heapUsed: toNumber(memoryUsageRaw.heapUsed, 0),
|
|
heapTotal: toNumber(memoryUsageRaw.heapTotal, 0),
|
|
},
|
|
circuitBreakers: resilienceCircuitBreakers,
|
|
rateLimits: rateLimitEntries,
|
|
cacheStats:
|
|
Object.keys(cacheStatsRaw).length > 0
|
|
? {
|
|
hits: toNumber(cacheStatsRaw.hits, 0),
|
|
misses: toNumber(cacheStatsRaw.misses, 0),
|
|
hitRate: toNumber(cacheStatsRaw.hitRate, 0),
|
|
}
|
|
: undefined,
|
|
cryptography: health.cryptography
|
|
? {
|
|
status: toString(toRecord(health.cryptography).status, "missing_or_invalid"),
|
|
provider: toString(toRecord(health.cryptography).provider, "unknown"),
|
|
}
|
|
: 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 combosRaw = await omniRouteFetch("/api/combos");
|
|
const combosRecord = toRecord(combosRaw);
|
|
const combos = Array.isArray(combosRecord.combos)
|
|
? combosRecord.combos
|
|
: Array.isArray(combosRaw)
|
|
? combosRaw
|
|
: [];
|
|
let metrics: JsonRecord = {};
|
|
if (args.includeMetrics) {
|
|
metrics = toRecord(await omniRouteFetch("/api/combos/metrics").catch(() => ({})));
|
|
}
|
|
|
|
const result = {
|
|
combos: toArray(combos).map((rawCombo) => {
|
|
const combo = toRecord(rawCombo);
|
|
const comboData = toRecord(combo.data);
|
|
const comboId = toString(combo.id, "");
|
|
const modelsSource =
|
|
Array.isArray(combo.models) && combo.models.length > 0 ? combo.models : comboData.models;
|
|
return {
|
|
id: comboId,
|
|
name: toString(combo.name, comboId || "unnamed"),
|
|
models: normalizeComboModels(modelsSource),
|
|
strategy: toString(combo.strategy, toString(comboData.strategy, "priority")),
|
|
enabled: combo.enabled !== false,
|
|
...(args.includeMetrics ? { metrics: metrics[comboId] ?? 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: "PUT",
|
|
body: JSON.stringify({ isActive: 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 result = normalizeQuotaResponse(await omniRouteFetch(path), {
|
|
provider: args.provider || null,
|
|
connectionId: args.connectionId || null,
|
|
});
|
|
|
|
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 JsonRecord;
|
|
const choices = toArray(raw.choices);
|
|
const firstChoice = toRecord(choices[0]);
|
|
const firstMessage = toRecord(firstChoice.message);
|
|
const usage = toRecord(raw.usage);
|
|
|
|
const result = {
|
|
response: {
|
|
content: toString(firstMessage.content, ""),
|
|
model: toString(raw.model, args.model),
|
|
tokens: {
|
|
prompt: toNumber(usage.prompt_tokens, 0),
|
|
completion: toNumber(usage.completion_tokens, 0),
|
|
},
|
|
},
|
|
routing: {
|
|
provider: toString(raw.provider, "unknown"),
|
|
combo: raw.combo ?? null,
|
|
fallbacksTriggered: toNumber(raw.fallbacksTriggered, 0),
|
|
cost: toNumber(raw.cost, 0),
|
|
latencyMs: Date.now() - start,
|
|
routingExplanation: toString(
|
|
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 rangeMap: Record<string, string> = {
|
|
session: "1d",
|
|
day: "1d",
|
|
week: "7d",
|
|
month: "30d",
|
|
};
|
|
const range = rangeMap[period] || "30d";
|
|
const raw = toRecord(
|
|
await omniRouteFetch(`/api/usage/analytics?range=${encodeURIComponent(range)}`)
|
|
);
|
|
const tokenCount = toRecord(raw.tokenCount);
|
|
const budget = toRecord(raw.budget);
|
|
|
|
const result = {
|
|
period,
|
|
totalCost: toNumber(raw.totalCost, 0),
|
|
requestCount: toNumber(raw.requestCount, 0),
|
|
tokenCount: {
|
|
prompt: toNumber(tokenCount.prompt, 0),
|
|
completion: toNumber(tokenCount.completion, 0),
|
|
},
|
|
byProvider: toArray(raw.byProvider),
|
|
byModel: toArray(raw.byModel),
|
|
budget: {
|
|
limit: budget.limit ?? null,
|
|
remaining: budget.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 {
|
|
const result = await getMcpModelsCatalog(args);
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
async function handleWebSearch(args: {
|
|
query: string;
|
|
max_results?: number;
|
|
search_type?: "web" | "news";
|
|
provider?:
|
|
| "serper-search"
|
|
| "brave-search"
|
|
| "perplexity-search"
|
|
| "exa-search"
|
|
| "tavily-search"
|
|
| "google-pse-search"
|
|
| "linkup-search"
|
|
| "searchapi-search"
|
|
| "searxng-search";
|
|
}) {
|
|
const start = Date.now();
|
|
try {
|
|
const body: Record<string, unknown> = {
|
|
query: args.query,
|
|
max_results: args.max_results ?? 5,
|
|
search_type: args.search_type ?? "web",
|
|
};
|
|
if (args.provider) body.provider = args.provider;
|
|
|
|
const result = await omniRouteFetch("/v1/search", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(60000),
|
|
});
|
|
await logToolCall("omniroute_web_search", 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_web_search", 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",
|
|
});
|
|
const mcpDescriptionCompressionEnabled = readMcpDescriptionCompressionEnabled();
|
|
const mcpAccessibilityConfig = readMcpAccessibilityConfig();
|
|
// F4.3 tool-cardinality: opt-in tool profile (MCP_TOOL_DENY / MCP_TOOL_ALLOW). null = no filter.
|
|
const toolProfile = readMcpToolProfileFromEnv(process.env);
|
|
const registerTool = server.registerTool.bind(server);
|
|
server.registerTool = ((name: string, config: Record<string, unknown>, handler: unknown) => {
|
|
const metadata = compressMcpRegistryMetadata(config, {
|
|
enabled: mcpDescriptionCompressionEnabled,
|
|
});
|
|
const filteredHandler = mcpAccessibilityConfig.enabled
|
|
? async (args: unknown, extra?: unknown) => {
|
|
const result = await (handler as (a: unknown, e?: unknown) => Promise<TextToolResult>)(
|
|
args,
|
|
extra
|
|
);
|
|
if (Array.isArray(result?.content)) {
|
|
for (const block of result.content) {
|
|
if (block && block.type === "text" && typeof block.text === "string") {
|
|
block.text = smartFilterText(block.text, mcpAccessibilityConfig);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
: handler;
|
|
const registered = registerTool(name, metadata, filteredHandler as never);
|
|
if (toolProfile && reduceToolManifest([{ name, scopes: [] }], toolProfile).length === 0) {
|
|
// Denied by the cardinality profile: keep the registration valid but disable it so the tool
|
|
// is not announced in tools/list (token savings). The default profile never reaches here.
|
|
const disablable = registered as unknown as { disable?: () => void };
|
|
if (typeof disablable?.disable === "function") disablable.disable();
|
|
}
|
|
return registered;
|
|
}) as typeof server.registerTool;
|
|
const registerPrompt = server.registerPrompt.bind(server);
|
|
server.registerPrompt = ((name: string, config: Record<string, unknown>, handler: unknown) => {
|
|
const metadata = compressMcpRegistryMetadata(config, {
|
|
enabled: mcpDescriptionCompressionEnabled,
|
|
});
|
|
return registerPrompt(name, metadata as never, handler as never);
|
|
}) as typeof server.registerPrompt;
|
|
const registerResource = server.registerResource.bind(server);
|
|
server.registerResource = ((
|
|
name: string,
|
|
uriOrTemplate: unknown,
|
|
config: Record<string, unknown>,
|
|
readCallback: unknown
|
|
) => {
|
|
const metadata = compressMcpRegistryMetadata(config, {
|
|
enabled: mcpDescriptionCompressionEnabled,
|
|
});
|
|
return registerResource(name, uriOrTemplate as never, metadata as never, readCallback as never);
|
|
}) as typeof server.registerResource;
|
|
|
|
const RESERVED_MCP_NAMES = new Set([
|
|
...MCP_TOOLS.map((t) => t.name),
|
|
...Object.keys(memoryTools),
|
|
...Object.keys(skillTools),
|
|
...Object.keys(compressionTools),
|
|
...pluginTools.map((t) => t.name),
|
|
...gamificationTools.map((t) => t.name),
|
|
...obsidianTools.map((t) => t.name),
|
|
...notionTools.map((t) => t.name),
|
|
]);
|
|
|
|
// Register essential tools
|
|
server.registerTool(
|
|
"omniroute_get_health",
|
|
{
|
|
description:
|
|
"Returns OmniRoute health status including uptime, memory, circuit breakers, rate limits, and cache stats",
|
|
inputSchema: getHealthInput,
|
|
},
|
|
withScopeEnforcement("omniroute_get_health", async (args) => {
|
|
getHealthInput.parse(args ?? {});
|
|
return handleGetHealth();
|
|
})
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_list_combos",
|
|
{
|
|
description:
|
|
"Lists all configured combos (model chains) with strategies and optional metrics",
|
|
inputSchema: listCombosInput,
|
|
},
|
|
withScopeEnforcement("omniroute_list_combos", (args) =>
|
|
handleListCombos(listCombosInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_get_combo_metrics",
|
|
{
|
|
description: "Returns detailed performance metrics for a specific combo",
|
|
inputSchema: getComboMetricsInput,
|
|
},
|
|
withScopeEnforcement("omniroute_get_combo_metrics", (args) =>
|
|
handleGetComboMetrics(getComboMetricsInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_switch_combo",
|
|
{
|
|
description: "Activates or deactivates a combo for routing",
|
|
inputSchema: switchComboInput,
|
|
},
|
|
withScopeEnforcement("omniroute_switch_combo", (args) =>
|
|
handleSwitchCombo(switchComboInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_check_quota",
|
|
{
|
|
description: "Checks remaining API quota for one or all providers",
|
|
inputSchema: checkQuotaInput,
|
|
},
|
|
withScopeEnforcement("omniroute_check_quota", (args) =>
|
|
handleCheckQuota(checkQuotaInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_route_request",
|
|
{
|
|
description: "Sends a chat completion request through OmniRoute intelligent routing",
|
|
inputSchema: routeRequestInput,
|
|
},
|
|
withScopeEnforcement("omniroute_route_request", (args) =>
|
|
handleRouteRequest(routeRequestInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_cost_report",
|
|
{
|
|
description: "Generates a cost report for the specified period",
|
|
inputSchema: costReportInput,
|
|
},
|
|
withScopeEnforcement("omniroute_cost_report", (args) =>
|
|
handleCostReport(costReportInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_list_models_catalog",
|
|
{
|
|
description: "Lists all available AI models across providers with capabilities and pricing",
|
|
inputSchema: listModelsCatalogInput,
|
|
},
|
|
withScopeEnforcement("omniroute_list_models_catalog", (args) =>
|
|
handleListModelsCatalog(listModelsCatalogInput.parse(args))
|
|
)
|
|
);
|
|
|
|
// ── Advanced Tools (Phase 3) ──────────────────────────────
|
|
|
|
server.registerTool(
|
|
"omniroute_simulate_route",
|
|
{
|
|
description: "Simulates the routing path a request would take without executing it (dry-run)",
|
|
inputSchema: simulateRouteInput,
|
|
},
|
|
withScopeEnforcement("omniroute_simulate_route", (args) =>
|
|
handleSimulateRoute(simulateRouteInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_set_budget_guard",
|
|
{
|
|
description:
|
|
"Sets a session budget limit with configurable action when exceeded (degrade/block/alert)",
|
|
inputSchema: setBudgetGuardInput,
|
|
},
|
|
withScopeEnforcement("omniroute_set_budget_guard", (args) =>
|
|
handleSetBudgetGuard(setBudgetGuardInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_set_routing_strategy",
|
|
{
|
|
description:
|
|
"Updates combo routing strategy at runtime (priority/weighted/round-robin/auto/etc.)",
|
|
inputSchema: setRoutingStrategyInput,
|
|
},
|
|
withScopeEnforcement("omniroute_set_routing_strategy", (args) =>
|
|
handleSetRoutingStrategy(setRoutingStrategyInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_set_resilience_profile",
|
|
{
|
|
description:
|
|
"Applies a resilience profile controlling circuit breakers, retries, timeouts, and fallback depth",
|
|
inputSchema: setResilienceProfileInput,
|
|
},
|
|
withScopeEnforcement("omniroute_set_resilience_profile", (args) =>
|
|
handleSetResilienceProfile(setResilienceProfileInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_test_combo",
|
|
{
|
|
description:
|
|
"Tests each provider in a combo with a real prompt, reporting latency, cost, and success per provider",
|
|
inputSchema: testComboInput,
|
|
},
|
|
withScopeEnforcement("omniroute_test_combo", (args) =>
|
|
handleTestCombo(testComboInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_get_provider_metrics",
|
|
{
|
|
description:
|
|
"Returns detailed metrics for a specific provider including latency percentiles and circuit breaker state",
|
|
inputSchema: getProviderMetricsInput,
|
|
},
|
|
withScopeEnforcement("omniroute_get_provider_metrics", (args) =>
|
|
handleGetProviderMetrics(getProviderMetricsInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_best_combo_for_task",
|
|
{
|
|
description:
|
|
"Recommends the best combo for a task type based on provider fitness and constraints",
|
|
inputSchema: bestComboForTaskInput,
|
|
},
|
|
withScopeEnforcement("omniroute_best_combo_for_task", (args) =>
|
|
handleBestComboForTask(bestComboForTaskInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_explain_route",
|
|
{
|
|
description:
|
|
"Explains why a request was routed to a specific provider, showing scoring factors and fallbacks",
|
|
inputSchema: explainRouteInput,
|
|
},
|
|
withScopeEnforcement("omniroute_explain_route", (args) =>
|
|
handleExplainRoute(explainRouteInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_get_session_snapshot",
|
|
{
|
|
description:
|
|
"Returns a full snapshot of the current working session: cost, tokens, top models, errors, budget status",
|
|
inputSchema: getSessionSnapshotInput,
|
|
},
|
|
withScopeEnforcement("omniroute_get_session_snapshot", async (args) => {
|
|
getSessionSnapshotInput.parse(args ?? {});
|
|
return handleGetSessionSnapshot();
|
|
})
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_db_health_check",
|
|
{
|
|
description:
|
|
"Diagnoses or repairs OmniRoute database drift, including broken combo references and orphan quota/domain rows",
|
|
inputSchema: dbHealthCheckInput,
|
|
},
|
|
withScopeEnforcement("omniroute_db_health_check", (args) =>
|
|
handleDbHealthCheck(dbHealthCheckInput.parse(args ?? {}))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_sync_pricing",
|
|
{
|
|
description:
|
|
"Syncs pricing data from external sources (LiteLLM) into OmniRoute without overwriting user-set prices",
|
|
inputSchema: syncPricingInput,
|
|
},
|
|
withScopeEnforcement("omniroute_sync_pricing", (args) =>
|
|
handleSyncPricing(syncPricingInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_web_search",
|
|
{
|
|
description:
|
|
"Performs a web search using OmniRoute's search gateway. Supports multiple providers (Serper, Brave, Perplexity, Exa, Tavily) with automatic failover. Returns search results with titles, URLs, snippets, and position data.",
|
|
inputSchema: webSearchInput,
|
|
},
|
|
withScopeEnforcement("omniroute_web_search", (args) =>
|
|
handleWebSearch(webSearchInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_cache_stats",
|
|
{
|
|
description:
|
|
"Returns cache statistics including semantic cache hit rate, prompt cache metrics by provider, and idempotency layer stats.",
|
|
inputSchema: cacheStatsInput,
|
|
},
|
|
withScopeEnforcement("omniroute_cache_stats", () => handleCacheStats())
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_cache_flush",
|
|
{
|
|
description:
|
|
"Flush cache entries. Provide signature to invalidate a single entry, model to invalidate all entries for a model, or omit both to clear all.",
|
|
inputSchema: cacheFlushInput,
|
|
},
|
|
withScopeEnforcement("omniroute_cache_flush", (args) =>
|
|
handleCacheFlush(cacheFlushInput.parse(args))
|
|
)
|
|
);
|
|
|
|
// ── 1proxy Tools ──────────────────────────────
|
|
|
|
server.registerTool(
|
|
"omniroute_oneproxy_fetch",
|
|
{
|
|
description:
|
|
"Fetch free proxies from the 1proxy marketplace with optional filters for protocol, country, and quality. Returns validated proxies with quality scores.",
|
|
inputSchema: oneproxyFetchInput,
|
|
},
|
|
withScopeEnforcement("omniroute_oneproxy_fetch", (args) =>
|
|
handleOneproxyFetch(oneproxyFetchInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_oneproxy_rotate",
|
|
{
|
|
description:
|
|
"Get the next available free proxy from the 1proxy pool using the specified rotation strategy.",
|
|
inputSchema: oneproxyRotateInput,
|
|
},
|
|
withScopeEnforcement("omniroute_oneproxy_rotate", (args) =>
|
|
handleOneproxyRotate(oneproxyRotateInput.parse(args))
|
|
)
|
|
);
|
|
|
|
server.registerTool(
|
|
"omniroute_oneproxy_stats",
|
|
{
|
|
description:
|
|
"Returns 1proxy sync status and statistics: total proxies, average quality, sync history, and distribution by protocol and country.",
|
|
inputSchema: oneproxyStatsInput,
|
|
},
|
|
withScopeEnforcement("omniroute_oneproxy_stats", (args) =>
|
|
handleOneproxyStats(oneproxyStatsInput.parse(args))
|
|
)
|
|
);
|
|
|
|
// ── Memory Tools ──────────────────────────────
|
|
Object.values(memoryTools).forEach((toolDef: any) => {
|
|
server.registerTool(
|
|
toolDef.name,
|
|
{
|
|
description: toolDef.description,
|
|
// @ts-ignore: dynamic zod access
|
|
inputSchema: toolDef.inputSchema,
|
|
},
|
|
withScopeEnforcement(
|
|
toolDef.name,
|
|
async (args) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore - handler type lost through dynamic Object.values() access
|
|
const result = await toolDef.handler(parsedArgs);
|
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
|
}
|
|
},
|
|
toolDef.scopes
|
|
)
|
|
);
|
|
});
|
|
|
|
// ── Skill Tools ──────────────────────────────
|
|
Object.values(skillTools).forEach((toolDef: any) => {
|
|
server.registerTool(
|
|
toolDef.name,
|
|
{
|
|
description: toolDef.description,
|
|
// @ts-ignore: dynamic zod access
|
|
inputSchema: toolDef.inputSchema,
|
|
},
|
|
withScopeEnforcement(
|
|
toolDef.name,
|
|
async (args) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore - handler type lost through dynamic Object.values() access
|
|
const result = await toolDef.handler(parsedArgs);
|
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
|
}
|
|
},
|
|
toolDef.scopes
|
|
)
|
|
);
|
|
});
|
|
|
|
// ── Agent Skill Tools ─────────────────────────
|
|
Object.values(agentSkillTools).forEach((toolDef) => {
|
|
server.registerTool(
|
|
toolDef.name,
|
|
{
|
|
description: toolDef.description,
|
|
// @ts-ignore: dynamic zod access
|
|
inputSchema: toolDef.inputSchema,
|
|
},
|
|
withScopeEnforcement(toolDef.name, async (args) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-expect-error - handler type lost through dynamic Object.values() access
|
|
const result = await toolDef.handler(parsedArgs);
|
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
|
}
|
|
})
|
|
);
|
|
});
|
|
|
|
// ── Plugin Tools ──────────────────────────────
|
|
pluginTools.forEach((toolDef) => {
|
|
server.registerTool(
|
|
toolDef.name,
|
|
{
|
|
description: toolDef.description,
|
|
// @ts-ignore: dynamic zod access
|
|
inputSchema: toolDef.inputSchema,
|
|
},
|
|
withScopeEnforcement(
|
|
toolDef.name,
|
|
async (args) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore: handler expected specific object
|
|
const result = await toolDef.handler(parsedArgs);
|
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
|
}
|
|
},
|
|
toolDef.scopes
|
|
)
|
|
);
|
|
});
|
|
|
|
// ── Compression Tools ─────────────────────────
|
|
Object.values(compressionTools).forEach((toolDef: any) => {
|
|
server.registerTool(
|
|
toolDef.name,
|
|
{
|
|
description: toolDef.description,
|
|
// @ts-ignore: dynamic zod access
|
|
inputSchema: toolDef.inputSchema,
|
|
},
|
|
withScopeEnforcement(
|
|
toolDef.name,
|
|
async (args) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore - handler type lost through dynamic Object.values() access
|
|
const result = await toolDef.handler(parsedArgs);
|
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
|
}
|
|
},
|
|
toolDef.scopes
|
|
)
|
|
);
|
|
});
|
|
|
|
// ── Gamification Tools ────────────────────────
|
|
gamificationTools.forEach((toolDef) => {
|
|
server.registerTool(
|
|
toolDef.name,
|
|
{
|
|
description: toolDef.description,
|
|
// @ts-ignore: dynamic zod access
|
|
inputSchema: toolDef.inputSchema,
|
|
},
|
|
withScopeEnforcement(
|
|
toolDef.name,
|
|
async (args) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore: handler expected specific object
|
|
const result = await toolDef.handler(parsedArgs);
|
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
|
}
|
|
},
|
|
toolDef.scopes
|
|
)
|
|
);
|
|
});
|
|
|
|
// ── Notion Context Source Tools ───────────────
|
|
notionTools.forEach((toolDef) => {
|
|
server.registerTool(
|
|
toolDef.name,
|
|
{
|
|
description: toolDef.description,
|
|
// @ts-ignore: dynamic zod access
|
|
inputSchema: toolDef.inputSchema,
|
|
},
|
|
withScopeEnforcement(
|
|
toolDef.name,
|
|
async (args) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore: handler expected specific object
|
|
const result = await toolDef.handler(parsedArgs);
|
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
|
}
|
|
},
|
|
toolDef.scopes
|
|
)
|
|
);
|
|
});
|
|
|
|
// ── Obsidian Context Source Tools ─────────────
|
|
obsidianTools.forEach((toolDef) => {
|
|
server.registerTool(
|
|
toolDef.name,
|
|
{
|
|
description: toolDef.description,
|
|
// @ts-ignore: dynamic zod access
|
|
inputSchema: toolDef.inputSchema,
|
|
},
|
|
withScopeEnforcement(
|
|
toolDef.name,
|
|
async (args) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore: handler expected specific object
|
|
const result = await toolDef.handler(parsedArgs);
|
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
|
}
|
|
},
|
|
toolDef.scopes
|
|
)
|
|
);
|
|
});
|
|
|
|
// ── Dynamic Skill Tools (from skills table) ──
|
|
const skillToMcpToolName = (skill: { name: string }) => `skill_${skill.name.replace(/[^a-z0-9_-]/gi, "_")}`;
|
|
try {
|
|
const enabledSkills = skillRegistry.list().filter((s) => s.enabled);
|
|
for (const skill of enabledSkills) {
|
|
const toolName = skillToMcpToolName(skill);
|
|
if (RESERVED_MCP_NAMES.has(toolName)) continue;
|
|
|
|
server.registerTool(
|
|
toolName,
|
|
{
|
|
description: skill.description,
|
|
inputSchema: z.object({}).passthrough(),
|
|
},
|
|
withScopeEnforcement(
|
|
toolName,
|
|
async (args, extra) => {
|
|
const scopeContext = resolveCallerScopeContext(extra, Array.from(MCP_ALLOWED_SCOPES));
|
|
const apiKeyId = scopeContext.callerId || "mcp";
|
|
try {
|
|
const execution = await skillExecutor.execute(
|
|
skill.name,
|
|
(args ?? {}) as Record<string, unknown>,
|
|
{ apiKeyId }
|
|
);
|
|
return {
|
|
content: [
|
|
{ type: "text" as const, text: JSON.stringify(execution.output, null, 2) },
|
|
],
|
|
};
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
content: [{ type: "text" as const, text: `Error: ${msg}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
},
|
|
["execute:skills"]
|
|
)
|
|
);
|
|
}
|
|
} catch {
|
|
// Skills not loaded yet — skip dynamic registration until next reconnect
|
|
}
|
|
|
|
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();
|
|
const version = process.env.npm_package_version || "1.8.1";
|
|
const stopHeartbeat = startMcpHeartbeat({
|
|
version,
|
|
scopesEnforced: MCP_ENFORCE_SCOPES,
|
|
allowedScopes: Array.from(MCP_ALLOWED_SCOPES),
|
|
toolCount: TOTAL_MCP_TOOL_COUNT,
|
|
});
|
|
const stopHeartbeatOnce = () => {
|
|
stopHeartbeat();
|
|
};
|
|
process.once("exit", stopHeartbeatOnce);
|
|
process.once("SIGINT", stopHeartbeatOnce);
|
|
process.once("SIGTERM", stopHeartbeatOnce);
|
|
|
|
console.error("[MCP] OmniRoute MCP Server starting (stdio transport)...");
|
|
try {
|
|
await server.connect(transport);
|
|
console.error("[MCP] OmniRoute MCP Server connected and ready.");
|
|
} finally {
|
|
if (closeAuditDb()) {
|
|
console.error("[MCP] Audit database checkpointed and closed.");
|
|
}
|
|
stopHeartbeatOnce();
|
|
process.off("exit", stopHeartbeatOnce);
|
|
process.off("SIGINT", stopHeartbeatOnce);
|
|
process.off("SIGTERM", stopHeartbeatOnce);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
}
|