1371 lines
48 KiB
TypeScript
1371 lines
48 KiB
TypeScript
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 { registerToolSearchTool } from "./toolSearch/register.ts";
|
|
import {
|
|
MCP_TOOLS,
|
|
getHealthInput,
|
|
listCombosInput,
|
|
getComboMetricsInput,
|
|
switchComboInput,
|
|
checkQuotaInput,
|
|
routeRequestInput,
|
|
costReportInput,
|
|
listModelsCatalogInput,
|
|
webSearchInput,
|
|
webFetchInput,
|
|
simulateRouteInput,
|
|
setBudgetGuardInput,
|
|
setRoutingStrategyInput,
|
|
setResilienceProfileInput,
|
|
testComboInput,
|
|
getProviderMetricsInput,
|
|
bestComboForTaskInput,
|
|
explainRouteInput,
|
|
pickFastestModelInput,
|
|
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 { getMcpHttpAuthHeadersForInternalFetch } from "./httpAuthContext.ts";
|
|
import {
|
|
handleSimulateRoute,
|
|
handleSetBudgetGuard,
|
|
handleSetRoutingStrategy,
|
|
handleSetResilienceProfile,
|
|
handleTestCombo,
|
|
handleGetProviderMetrics,
|
|
handleBestComboForTask,
|
|
handleExplainRoute,
|
|
handleGetSessionSnapshot,
|
|
handleDbHealthCheck,
|
|
handleSyncPricing,
|
|
handleCacheStats,
|
|
handleCacheFlush,
|
|
handleOneproxyFetch,
|
|
handleOneproxyRotate,
|
|
handleOneproxyStats,
|
|
} from "./tools/advancedTools.ts";
|
|
import { handlePickFastestModel } from "./tools/pickFastestModel.ts";
|
|
import { memoryTools } from "./tools/memoryTools.ts";
|
|
import { skillTools } from "./tools/skillTools.ts";
|
|
import { agentSkillTools } from "./tools/agentSkillTools.ts";
|
|
import { githubSkillTools } from "./tools/githubSkillTools.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 { poolTools } from "./tools/poolTools.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 { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
|
|
import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts";
|
|
import { getMcpModelsCatalog } from "./catalog.ts";
|
|
export { getMcpModelsCatalog } from "./catalog.ts";
|
|
|
|
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 +
|
|
Object.keys(githubSkillTools).length +
|
|
Object.keys(poolTools).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;
|
|
};
|
|
|
|
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),
|
|
};
|
|
});
|
|
}
|
|
|
|
function getOmniRouteApiKey(): string {
|
|
return process.env.OMNIROUTE_API_KEY || "";
|
|
}
|
|
|
|
export 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",
|
|
// Static env key is only a fallback; the per-caller MCP identity forwarded via
|
|
// withMcpHttpAuthContext must win over it (#5819).
|
|
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
...getMcpHttpAuthHeadersForInternalFetch(),
|
|
...((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 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);
|
|
};
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
async function handleWebFetch(args: {
|
|
url: string;
|
|
provider?: "firecrawl" | "jina-reader" | "tavily-search" | "tinyfish";
|
|
format?: "markdown" | "html" | "links" | "screenshot";
|
|
include_metadata?: boolean;
|
|
depth?: number;
|
|
wait_for_selector?: string;
|
|
}) {
|
|
const start = Date.now();
|
|
try {
|
|
const body: Record<string, unknown> = {
|
|
url: args.url,
|
|
format: args.format ?? "markdown",
|
|
include_metadata: args.include_metadata ?? false,
|
|
};
|
|
if (args.provider) body.provider = args.provider;
|
|
if (args.depth !== undefined) body.depth = args.depth;
|
|
if (args.wait_for_selector) body.wait_for_selector = args.wait_for_selector;
|
|
|
|
const result = await omniRouteFetch("/v1/web/fetch", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(60000),
|
|
});
|
|
await logToolCall("omniroute_web_fetch", 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_fetch", args, null, Date.now() - start, false, msg);
|
|
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
|
}
|
|
}
|
|
|
|
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),
|
|
...Object.keys(poolTools),
|
|
...pluginTools.map((t) => t.name),
|
|
...gamificationTools.map((t) => t.name),
|
|
...obsidianTools.map((t) => t.name),
|
|
...notionTools.map((t) => t.name),
|
|
]);
|
|
|
|
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))
|
|
)
|
|
);
|
|
|
|
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_pick_fastest_model",
|
|
{
|
|
description: "Picks the fastest reliable provider-model pair from live telemetry.",
|
|
inputSchema: pickFastestModelInput,
|
|
},
|
|
withScopeEnforcement("omniroute_pick_fastest_model", (args) =>
|
|
handlePickFastestModel(pickFastestModelInput.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_web_fetch",
|
|
{
|
|
description:
|
|
"Fetches and extracts content from a URL using OmniRoute's web fetch gateway. Supports multiple providers (Firecrawl, Jina Reader, Tavily) with automatic failover. Returns the page content as markdown, HTML, links, or screenshot, along with metadata.",
|
|
inputSchema: webFetchInput,
|
|
},
|
|
withScopeEnforcement("omniroute_web_fetch", (args) => handleWebFetch(webFetchInput.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))
|
|
)
|
|
);
|
|
|
|
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))
|
|
)
|
|
);
|
|
|
|
registerToolSearchTool(server, withScopeEnforcement);
|
|
|
|
// ── 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, extra) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore - handler type lost through dynamic Object.values() access
|
|
const result = await toolDef.handler(parsedArgs, extra);
|
|
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, extra) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore - handler type lost through dynamic Object.values() access
|
|
const result = await toolDef.handler(parsedArgs, extra);
|
|
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, extra) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-expect-error - handler type lost through dynamic Object.values() access
|
|
const result = await toolDef.handler(parsedArgs, extra);
|
|
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 };
|
|
}
|
|
})
|
|
);
|
|
});
|
|
|
|
// ── GitHub Skill Tools ──────────────────────────
|
|
Object.values(githubSkillTools).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 };
|
|
}
|
|
},
|
|
toolDef.scopes
|
|
)
|
|
);
|
|
});
|
|
|
|
// ── 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, extra) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore: handler expected specific object
|
|
const result = await toolDef.handler(parsedArgs, extra);
|
|
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, extra) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore - handler type lost through dynamic Object.values() access
|
|
const result = await toolDef.handler(parsedArgs, extra);
|
|
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
|
|
)
|
|
);
|
|
});
|
|
|
|
// ── Web-Session Pool Tools (#3368 observability) ─
|
|
// Typed structurally (not `any`) — the shape is pinned by
|
|
// tests/unit/mcp-tool-collections-shape.test.ts, so the loop can stay strict.
|
|
Object.values(poolTools).forEach(
|
|
(toolDef: {
|
|
name: string;
|
|
description: string;
|
|
scopes: readonly string[];
|
|
inputSchema: { parse: (input: unknown) => unknown };
|
|
handler: (parsedArgs: unknown, extra?: unknown) => Promise<unknown>;
|
|
}) => {
|
|
server.registerTool(
|
|
toolDef.name,
|
|
{
|
|
description: toolDef.description,
|
|
// @ts-ignore: dynamic zod access
|
|
inputSchema: toolDef.inputSchema,
|
|
},
|
|
withScopeEnforcement(
|
|
toolDef.name,
|
|
async (args, extra) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
const result = await toolDef.handler(parsedArgs, extra);
|
|
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, extra) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore: handler expected specific object
|
|
const result = await toolDef.handler(parsedArgs, extra);
|
|
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, extra) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore: handler expected specific object
|
|
const result = await toolDef.handler(parsedArgs, extra);
|
|
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, extra) => {
|
|
try {
|
|
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
|
|
// @ts-ignore: handler expected specific object
|
|
const result = await toolDef.handler(parsedArgs, extra);
|
|
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);
|
|
});
|
|
}
|