Some checks failed
CI/CD / lint (pull_request) Successful in 1m13s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 2m35s
CI/CD / smoke (pull_request) Failing after 2m0s
CI/CD / build (pull_request) Successful in 4m24s
CI/CD / publish (pull_request) Has been skipped
A cached project router outlives the caller's bearer token. syncUpstreams only created a McpdUpstream (which binds the token at construction) for server names not already registered, so after a token rotation every pre-rotation upstream kept posting the old bearer: six attached, healthy servers 401'd on tools/list forever — no error anywhere the client could see — while the two servers attached after the rotation worked. - discovery.ts: rebuild every upstream on every sync, so it always carries the current caller token and the current server id (also fixes a stale serverId after delete+recreate under the same name). - router.ts: record per-server discovery failures with their messages and expose them (getToolDiscoveryFailures). - gate.ts: a partial catalog is now loud — initialize instructions and the begin_session response name every attached server that contributed no tools and the error that silenced it. - tests: regression tests for token rebinding and serverId refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1255 lines
49 KiB
TypeScript
1255 lines
49 KiB
TypeScript
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types.js';
|
|
import type { LlmProcessor } from './llm/processor.js';
|
|
import { ResponsePaginator } from './llm/pagination.js';
|
|
import type { McpdClient } from './http/mcpd-client.js';
|
|
import type { PromptIndexEntry } from './gate/tag-matcher.js';
|
|
import { LinkResolver } from './services/link-resolver.js';
|
|
import type { LLMProvider, CacheProvider, Section } from './proxymodel/types.js';
|
|
import { executePipeline } from './proxymodel/executor.js';
|
|
import { currentCorrelationId, currentScope } from './request-context.js';
|
|
import { getProxyModel } from './proxymodel/loader.js';
|
|
import type { ProxyModelPlugin, PluginSessionContext } from './proxymodel/plugin.js';
|
|
import { PluginContextImpl, type PluginContextDeps } from './proxymodel/plugin-context.js';
|
|
import type { AuditCollector } from './audit/collector.js';
|
|
import { pauseQueue } from './proxymodel/pause-queue.js';
|
|
|
|
export interface RouteContext {
|
|
sessionId?: string;
|
|
/** Correlation ID for traffic inspection (links upstream calls to client request) */
|
|
correlationId?: string;
|
|
}
|
|
|
|
type ListCacheEntry =
|
|
| { kind: 'ok'; result: unknown; fetchedAt: number }
|
|
| { kind: 'err'; message: string; fetchedAt: number };
|
|
|
|
/**
|
|
* Routes MCP requests to the appropriate upstream server.
|
|
*
|
|
* The proxy presents a unified MCP interface to clients. Tools, resources,
|
|
* and prompts from all upstreams are merged under namespaced prefixes
|
|
* (e.g., "slack/send_message").
|
|
*
|
|
* Routing is done by name prefix: "servername/toolname" -> upstream "servername".
|
|
*
|
|
* With no plugin attached, the router is a transparent MCP proxy — all requests
|
|
* flow through unchanged. Plugins (gate, content-pipeline, etc.) add optional
|
|
* interception on top.
|
|
*/
|
|
export class McpRouter {
|
|
private upstreams = new Map<string, UpstreamConnection>();
|
|
private toolToServer = new Map<string, string>();
|
|
private resourceToServer = new Map<string, string>();
|
|
private promptToServer = new Map<string, string>();
|
|
private notificationHandler: ((notification: JsonRpcNotification) => void) | null = null;
|
|
private llmProcessor: LlmProcessor | null = null;
|
|
private instructions: string | null = null;
|
|
private mcpdClient: McpdClient | null = null;
|
|
private projectName: string | null = null;
|
|
private mcpctlResourceContents = new Map<string, string>();
|
|
private paginator: ResponsePaginator | null = null;
|
|
private linkResolver: LinkResolver | null = null;
|
|
private pendingNotifications = new Map<string, JsonRpcNotification[]>();
|
|
|
|
// ProxyModel pipeline services (used by plugin context)
|
|
private proxyModelName: string | null = null;
|
|
private proxyModelLlm: LLMProvider | null = null;
|
|
private proxyModelCache: CacheProvider | null = null;
|
|
private auditCollector: AuditCollector | null = null;
|
|
private serverProxyModels = new Map<string, { name: string; llm: LLMProvider; cache: CacheProvider }>();
|
|
/** Per-server tool-call deadline overrides, in ms, keyed by server name. */
|
|
private serverDeadlines = new Map<string, number>();
|
|
|
|
// Prompt and system prompt caches (used by plugin context)
|
|
private cachedPromptIndex: PromptIndexEntry[] | null = null;
|
|
private promptIndexFetchedAt = 0;
|
|
private readonly PROMPT_INDEX_TTL_MS = 60_000;
|
|
private systemPromptCache = new Map<string, { content: string; fetchedAt: number }>();
|
|
private readonly SYSTEM_PROMPT_TTL_MS = 300_000; // 5 minutes
|
|
|
|
// Plugin system — when set, plugin hooks are dispatched for interception
|
|
private plugin: ProxyModelPlugin | null = null;
|
|
private pluginContexts = new Map<string, PluginContextImpl>();
|
|
|
|
// Per-server discovery cache. Keyed `${serverName}:${method}`. Prevents every client
|
|
// `tools/list` from re-hitting slow/dead upstreams and absorbs negative results so one
|
|
// dead server only stalls the first POST, not every subsequent one.
|
|
private listCache = new Map<string, ListCacheEntry>();
|
|
/** Per-server failures from the most recent discoverTools run. */
|
|
private toolDiscoveryFailures: Array<{ server: string; message: string }> = [];
|
|
private readonly LIST_CACHE_POSITIVE_TTL_MS = 30_000;
|
|
private readonly LIST_CACHE_NEGATIVE_TTL_MS = 30_000;
|
|
|
|
/** Optional callback for traffic inspection — called after each upstream call completes. */
|
|
onUpstreamCall: ((info: { upstream: string; method?: string; request: unknown; response: unknown; durationMs: number; correlationId?: string }) => void) | null = null;
|
|
|
|
setPaginator(paginator: ResponsePaginator): void {
|
|
this.paginator = paginator;
|
|
}
|
|
|
|
setProxyModel(name: string, llm: LLMProvider, cache: CacheProvider): void {
|
|
this.proxyModelName = name;
|
|
this.proxyModelLlm = llm;
|
|
this.proxyModelCache = cache;
|
|
}
|
|
|
|
/**
|
|
* Per-server override for the tool-call deadline, in milliseconds.
|
|
*
|
|
* Sourced from the server resource's `toolCallTimeoutSeconds`. Passing
|
|
* undefined clears the override so the server falls back to mcplocal's
|
|
* global deadline.
|
|
*/
|
|
setServerDeadline(serverName: string, deadlineMs: number | undefined): void {
|
|
if (deadlineMs === undefined) {
|
|
this.serverDeadlines.delete(serverName);
|
|
return;
|
|
}
|
|
this.serverDeadlines.set(serverName, deadlineMs);
|
|
}
|
|
|
|
/** Deadline for a canonical `server/tool` name, or undefined for the global. */
|
|
getToolCallDeadlineMs(canonicalToolName?: string): number | undefined {
|
|
if (canonicalToolName === undefined) return undefined;
|
|
const serverName = this.toolToServer.get(canonicalToolName)
|
|
?? canonicalToolName.split('/')[0];
|
|
return serverName === undefined ? undefined : this.serverDeadlines.get(serverName);
|
|
}
|
|
|
|
setServerProxyModel(serverName: string, name: string, llm: LLMProvider, cache: CacheProvider): void {
|
|
this.serverProxyModels.set(serverName, { name, llm, cache });
|
|
}
|
|
|
|
private getProxyModelForServer(serverName: string): { name: string; llm: LLMProvider; cache: CacheProvider } | null {
|
|
const serverOverride = this.serverProxyModels.get(serverName);
|
|
if (serverOverride) return serverOverride;
|
|
if (this.proxyModelName && this.proxyModelLlm && this.proxyModelCache) {
|
|
return { name: this.proxyModelName, llm: this.proxyModelLlm, cache: this.proxyModelCache };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
setAuditCollector(collector: AuditCollector): void {
|
|
this.auditCollector = collector;
|
|
}
|
|
|
|
getAuditCollector(): AuditCollector | null {
|
|
return this.auditCollector;
|
|
}
|
|
|
|
setLlmProcessor(processor: LlmProcessor): void {
|
|
this.llmProcessor = processor;
|
|
}
|
|
|
|
setInstructions(instructions: string): void {
|
|
this.instructions = instructions;
|
|
}
|
|
|
|
setPromptConfig(mcpdClient: McpdClient, projectName: string): void {
|
|
this.mcpdClient = mcpdClient;
|
|
this.projectName = projectName;
|
|
this.linkResolver = new LinkResolver(mcpdClient);
|
|
}
|
|
|
|
/** Return a bound system prompt fetcher for external consumers (e.g. ResponsePaginator). */
|
|
getSystemPromptFn(): (name: string, fallback: string) => Promise<string> {
|
|
return (name, fallback) => this.getSystemPrompt(name, fallback);
|
|
}
|
|
|
|
/** Set the plugin for this router. When set, plugin hooks are dispatched. */
|
|
setPlugin(plugin: ProxyModelPlugin): void {
|
|
this.plugin = plugin;
|
|
}
|
|
|
|
/** Get or create a plugin session context for the given session ID. */
|
|
private async getOrCreatePluginContext(sessionId: string): Promise<PluginContextImpl> {
|
|
let ctx = this.pluginContexts.get(sessionId);
|
|
if (ctx) return ctx;
|
|
|
|
const deps: PluginContextDeps = {
|
|
sessionId,
|
|
projectName: this.projectName ?? 'unknown',
|
|
llm: this.proxyModelLlm ?? { complete: async () => '', available: () => false },
|
|
cache: this.proxyModelCache ?? { getOrCompute: async (_k, fn) => fn(), hash: () => '', get: async () => null, set: async () => {} },
|
|
log: {
|
|
debug: (msg: string) => console.debug(`[plugin:${sessionId.slice(0, 8)}] ${msg}`),
|
|
info: (msg: string) => console.info(`[plugin:${sessionId.slice(0, 8)}] ${msg}`),
|
|
warn: (msg: string) => console.warn(`[plugin:${sessionId.slice(0, 8)}] ${msg}`),
|
|
error: (msg: string) => console.error(`[plugin:${sessionId.slice(0, 8)}] ${msg}`),
|
|
},
|
|
discoverTools: () => this.discoverTools(),
|
|
getToolDiscoveryFailures: () => this.getToolDiscoveryFailures(),
|
|
routeToUpstream: (request) => this.routeNamespacedCall(request, 'name', this.toolToServer),
|
|
fetchPromptIndex: () => this.fetchPromptIndex(),
|
|
getSystemPrompt: (name, fallback) => this.getSystemPrompt(name, fallback),
|
|
processContent: async (toolName, content, contentType) => {
|
|
const serverName = this.toolToServer.get(toolName);
|
|
const pmConfig = serverName ? this.getProxyModelForServer(serverName) : null;
|
|
const effectiveName = pmConfig?.name ?? this.proxyModelName;
|
|
const effectiveLlm = pmConfig?.llm ?? this.proxyModelLlm;
|
|
const effectiveCache = pmConfig?.cache ?? this.proxyModelCache;
|
|
const proxyModelDef = effectiveName ? await getProxyModel(effectiveName) : null;
|
|
if (!proxyModelDef || !effectiveLlm || !effectiveCache) {
|
|
return { content };
|
|
}
|
|
const result = await executePipeline({
|
|
content,
|
|
contentType,
|
|
sourceName: toolName,
|
|
projectName: this.projectName ?? 'unknown',
|
|
sessionId,
|
|
proxyModel: proxyModelDef,
|
|
llm: effectiveLlm,
|
|
cache: effectiveCache,
|
|
getSystemPrompt: (name, fallback) => this.getSystemPrompt(name, fallback),
|
|
...(this.auditCollector ? { auditCollector: this.auditCollector } : {}),
|
|
...(serverName !== undefined ? { serverName } : {}),
|
|
// processContent is a per-session closure with no RouteContext, so
|
|
// the id comes from the request scope. Without this,
|
|
// ExecuteOptions.correlationId was dead code and every
|
|
// stage_execution / pipeline_execution row landed unjoinable.
|
|
...(currentCorrelationId() !== undefined ? { correlationId: currentCorrelationId()! } : {}),
|
|
});
|
|
|
|
// Pause queue: if paused, hold the result until released/edited/dropped.
|
|
// This wait is deliberately unbounded — it ends when a human operator
|
|
// acts — so the request deadline stops its clock rather than killing
|
|
// it, and re-arms with whatever was left once the operator releases.
|
|
if (pauseQueue.paused) {
|
|
const enqueue = (): Promise<string> => pauseQueue.enqueue({
|
|
sessionId,
|
|
projectName: this.projectName ?? 'unknown',
|
|
contentType,
|
|
sourceName: toolName,
|
|
original: content,
|
|
transformed: result.content,
|
|
});
|
|
const deadline = currentScope()?.deadline;
|
|
const pausedContent = deadline ? await deadline.suspended(enqueue) : await enqueue();
|
|
return { ...result, content: pausedContent };
|
|
}
|
|
|
|
return result;
|
|
},
|
|
queueNotification: (notification) => this.queueNotification(sessionId, notification),
|
|
postToMcpd: async (path, body) => {
|
|
if (!this.mcpdClient) throw new Error('mcpd client not configured');
|
|
return this.mcpdClient.post(path, body);
|
|
},
|
|
getFromMcpd: async (path) => {
|
|
if (!this.mcpdClient) throw new Error('mcpd client not configured');
|
|
return this.mcpdClient.get(path);
|
|
},
|
|
...(this.auditCollector ? { auditCollector: this.auditCollector } : {}),
|
|
};
|
|
|
|
ctx = new PluginContextImpl(deps);
|
|
this.pluginContexts.set(sessionId, ctx);
|
|
|
|
// Initialize the session via plugin hook (registers virtual tools, etc.)
|
|
if (this.plugin?.onSessionCreate) {
|
|
await this.plugin.onSessionCreate(ctx);
|
|
}
|
|
|
|
return ctx;
|
|
}
|
|
|
|
addUpstream(connection: UpstreamConnection): void {
|
|
this.upstreams.set(connection.name, connection);
|
|
this.invalidateListCache(connection.name);
|
|
if (this.notificationHandler && connection.onNotification) {
|
|
const serverName = connection.name;
|
|
const handler = this.notificationHandler;
|
|
connection.onNotification((notification) => {
|
|
handler({
|
|
...notification,
|
|
params: {
|
|
...notification.params,
|
|
_source: serverName,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
removeUpstream(name: string): void {
|
|
this.upstreams.delete(name);
|
|
this.invalidateListCache(name);
|
|
for (const map of [this.toolToServer, this.resourceToServer, this.promptToServer]) {
|
|
for (const [key, server] of map) {
|
|
if (server === name) {
|
|
map.delete(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Drop all discovery-cache entries for a server (called on register / remove). */
|
|
private invalidateListCache(serverName: string): void {
|
|
const prefix = `${serverName}:`;
|
|
for (const key of this.listCache.keys()) {
|
|
if (key.startsWith(prefix)) this.listCache.delete(key);
|
|
}
|
|
}
|
|
|
|
private getListCacheEntry(serverName: string, method: string): ListCacheEntry | null {
|
|
const entry = this.listCache.get(`${serverName}:${method}`);
|
|
if (!entry) return null;
|
|
const ttl = entry.kind === 'ok' ? this.LIST_CACHE_POSITIVE_TTL_MS : this.LIST_CACHE_NEGATIVE_TTL_MS;
|
|
if (Date.now() - entry.fetchedAt >= ttl) return null;
|
|
return entry;
|
|
}
|
|
|
|
private setListCacheEntry(serverName: string, method: string, entry: ListCacheEntry): void {
|
|
this.listCache.set(`${serverName}:${method}`, entry);
|
|
}
|
|
|
|
setNotificationHandler(handler: (notification: JsonRpcNotification) => void): void {
|
|
this.notificationHandler = handler;
|
|
// Wire to all existing upstreams
|
|
for (const [serverName, upstream] of this.upstreams) {
|
|
if (upstream.onNotification) {
|
|
upstream.onNotification((notification) => {
|
|
handler({
|
|
...notification,
|
|
params: {
|
|
...notification.params,
|
|
_source: serverName,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Discover tools from all upstreams by calling tools/list on each.
|
|
* Per-server results are cached (positive + negative) to absorb slow upstreams
|
|
* and prevent repeated 30s timeouts on every client `tools/list`.
|
|
*/
|
|
async discoverTools(correlationId?: string): Promise<Array<{ name: string; description?: string; inputSchema?: unknown }>> {
|
|
const allTools: Array<{ name: string; description?: string; inputSchema?: unknown }> = [];
|
|
const started = Date.now();
|
|
let cachedCount = 0;
|
|
let freshCount = 0;
|
|
const failed: Array<{ server: string; message: string }> = [];
|
|
|
|
// Discover tools from all servers in parallel so one slow server doesn't block the rest
|
|
const entries = [...this.upstreams.entries()];
|
|
const results = await Promise.allSettled(
|
|
entries.map(async ([serverName, upstream]) => {
|
|
const cached = this.getListCacheEntry(serverName, 'tools/list');
|
|
if (cached) {
|
|
return { serverName, upstream, cached };
|
|
}
|
|
const req = {
|
|
jsonrpc: '2.0' as const,
|
|
id: `discover-tools-${serverName}`,
|
|
method: 'tools/list',
|
|
};
|
|
let response: JsonRpcResponse;
|
|
if (this.onUpstreamCall) {
|
|
const start = performance.now();
|
|
response = await upstream.send(req);
|
|
const durationMs = Math.round(performance.now() - start);
|
|
this.onUpstreamCall({ upstream: serverName, method: req.method, request: req, response, durationMs, ...(correlationId ? { correlationId } : {}) });
|
|
} else {
|
|
response = await upstream.send(req);
|
|
}
|
|
return { serverName, upstream, response };
|
|
}),
|
|
);
|
|
|
|
for (const result of results) {
|
|
if (result.status === 'rejected') {
|
|
console.warn(`[discoverTools] ${(result.reason as Error).message ?? 'unknown error'}`);
|
|
continue;
|
|
}
|
|
const { serverName, upstream } = result.value;
|
|
|
|
let response: JsonRpcResponse | null = null;
|
|
if ('cached' in result.value) {
|
|
const cached = result.value.cached;
|
|
if (cached.kind === 'err') {
|
|
cachedCount++;
|
|
failed.push({ server: serverName, message: cached.message });
|
|
continue;
|
|
}
|
|
response = { jsonrpc: '2.0', id: `cached-${serverName}`, result: cached.result };
|
|
cachedCount++;
|
|
} else {
|
|
response = result.value.response;
|
|
freshCount++;
|
|
if (response.error) {
|
|
const message = (response.error as { message?: string }).message ?? 'unknown error';
|
|
this.setListCacheEntry(serverName, 'tools/list', { kind: 'err', message, fetchedAt: Date.now() });
|
|
console.warn(`[discoverTools] ${serverName}: ${message}`);
|
|
failed.push({ server: serverName, message });
|
|
continue;
|
|
}
|
|
if (response.result !== undefined) {
|
|
this.setListCacheEntry(serverName, 'tools/list', { kind: 'ok', result: response.result, fetchedAt: Date.now() });
|
|
}
|
|
}
|
|
|
|
if (response.result && typeof response.result === 'object' && 'tools' in response.result) {
|
|
const tools = (response.result as { tools: Array<{ name: string; description?: string; inputSchema?: unknown }> }).tools;
|
|
for (const tool of tools) {
|
|
const namespacedName = `${serverName}/${tool.name}`;
|
|
this.toolToServer.set(namespacedName, serverName);
|
|
// Enrich description with server context if available
|
|
const entry: { name: string; description?: string; inputSchema?: unknown } = {
|
|
...tool,
|
|
name: namespacedName,
|
|
};
|
|
if (upstream.description && tool.description) {
|
|
entry.description = `[${upstream.description}] ${tool.description}`;
|
|
} else if (upstream.description) {
|
|
entry.description = `[${upstream.description}]`;
|
|
}
|
|
// If neither upstream.description nor tool.description, keep tool.description (may be undefined — that's fine, just don't set it)
|
|
allTools.push(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.toolDiscoveryFailures = failed;
|
|
|
|
if (entries.length > 0) {
|
|
const elapsed = Date.now() - started;
|
|
const project = this.projectName ? ` project=${this.projectName}` : '';
|
|
const failedStr = failed.length > 0 ? ` failed=[${failed.map((f) => f.server).join(',')}]` : '';
|
|
console.info(`[discoverTools]${project} fresh=${freshCount} cached=${cachedCount}${failedStr} elapsed=${elapsed}ms`);
|
|
}
|
|
|
|
return allTools;
|
|
}
|
|
|
|
/**
|
|
* Servers that contributed no tools on the most recent `discoverTools` run,
|
|
* with the error that silenced them. A partial catalog must never look like
|
|
* a healthy one: the gate reads this to warn the client in-band.
|
|
*/
|
|
getToolDiscoveryFailures(): Array<{ server: string; message: string }> {
|
|
return this.toolDiscoveryFailures;
|
|
}
|
|
|
|
/**
|
|
* Discover resources from all upstreams by calling resources/list on each.
|
|
* Shares the per-server list cache with `discoverTools`.
|
|
*/
|
|
async discoverResources(correlationId?: string): Promise<Array<{ uri: string; name?: string; description?: string; mimeType?: string }>> {
|
|
const allResources: Array<{ uri: string; name?: string; description?: string; mimeType?: string }> = [];
|
|
|
|
// Discover resources from all servers in parallel
|
|
const entries = [...this.upstreams.entries()];
|
|
const results = await Promise.allSettled(
|
|
entries.map(async ([serverName, upstream]) => {
|
|
const cached = this.getListCacheEntry(serverName, 'resources/list');
|
|
if (cached) return { serverName, cached };
|
|
const req = {
|
|
jsonrpc: '2.0' as const,
|
|
id: `discover-resources-${serverName}`,
|
|
method: 'resources/list',
|
|
};
|
|
let response: JsonRpcResponse;
|
|
if (this.onUpstreamCall) {
|
|
const start = performance.now();
|
|
response = await upstream.send(req);
|
|
const durationMs = Math.round(performance.now() - start);
|
|
this.onUpstreamCall({ upstream: serverName, method: req.method, request: req, response, durationMs, ...(correlationId ? { correlationId } : {}) });
|
|
} else {
|
|
response = await upstream.send(req);
|
|
}
|
|
return { serverName, response };
|
|
}),
|
|
);
|
|
|
|
for (const result of results) {
|
|
if (result.status === 'rejected') continue;
|
|
const { serverName } = result.value;
|
|
|
|
let response: JsonRpcResponse | null = null;
|
|
if ('cached' in result.value) {
|
|
const cached = result.value.cached;
|
|
if (cached.kind === 'err') continue;
|
|
response = { jsonrpc: '2.0', id: `cached-${serverName}`, result: cached.result };
|
|
} else {
|
|
response = result.value.response;
|
|
if (response.error) {
|
|
const message = (response.error as { message?: string }).message ?? 'unknown error';
|
|
this.setListCacheEntry(serverName, 'resources/list', { kind: 'err', message, fetchedAt: Date.now() });
|
|
continue;
|
|
}
|
|
if (response.result !== undefined) {
|
|
this.setListCacheEntry(serverName, 'resources/list', { kind: 'ok', result: response.result, fetchedAt: Date.now() });
|
|
}
|
|
}
|
|
|
|
if (response.result && typeof response.result === 'object' && 'resources' in response.result) {
|
|
const resources = (response.result as { resources: Array<{ uri: string; name?: string; description?: string; mimeType?: string }> }).resources;
|
|
for (const resource of resources) {
|
|
const namespacedUri = `${serverName}://${resource.uri}`;
|
|
this.resourceToServer.set(namespacedUri, serverName);
|
|
allResources.push({
|
|
...resource,
|
|
uri: namespacedUri,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return allResources;
|
|
}
|
|
|
|
/**
|
|
* Discover prompts from all upstreams by calling prompts/list on each.
|
|
*/
|
|
async discoverPrompts(correlationId?: string): Promise<Array<{ name: string; description?: string; arguments?: unknown[] }>> {
|
|
const allPrompts: Array<{ name: string; description?: string; arguments?: unknown[] }> = [];
|
|
|
|
for (const [serverName, upstream] of this.upstreams) {
|
|
try {
|
|
const req = {
|
|
jsonrpc: '2.0' as const,
|
|
id: `discover-prompts-${serverName}`,
|
|
method: 'prompts/list',
|
|
};
|
|
let response: JsonRpcResponse;
|
|
if (this.onUpstreamCall) {
|
|
const start = performance.now();
|
|
response = await upstream.send(req);
|
|
const durationMs = Math.round(performance.now() - start);
|
|
this.onUpstreamCall({ upstream: serverName, method: req.method, request: req, response, durationMs, ...(correlationId ? { correlationId } : {}) });
|
|
} else {
|
|
response = await upstream.send(req);
|
|
}
|
|
|
|
if (response.result && typeof response.result === 'object' && 'prompts' in response.result) {
|
|
const prompts = (response.result as { prompts: Array<{ name: string; description?: string; arguments?: unknown[] }> }).prompts;
|
|
for (const prompt of prompts) {
|
|
const namespacedName = `${serverName}/${prompt.name}`;
|
|
this.promptToServer.set(namespacedName, serverName);
|
|
allPrompts.push({
|
|
...prompt,
|
|
name: namespacedName,
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
// Server may be unavailable; skip its prompts
|
|
}
|
|
}
|
|
|
|
return allPrompts;
|
|
}
|
|
|
|
/**
|
|
* Route a namespaced call to the correct upstream, stripping the namespace prefix.
|
|
*/
|
|
private async routeNamespacedCall(
|
|
request: JsonRpcRequest,
|
|
nameField: string,
|
|
routingMap: Map<string, string>,
|
|
): Promise<JsonRpcResponse> {
|
|
const params = request.params as Record<string, unknown> | undefined;
|
|
const name = params?.[nameField] as string | undefined;
|
|
if (!name) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
error: { code: -32602, message: `Missing ${nameField} in params` },
|
|
};
|
|
}
|
|
|
|
const serverName = routingMap.get(name);
|
|
if (!serverName) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
error: { code: -32601, message: `Unknown ${nameField}: ${name}` },
|
|
};
|
|
}
|
|
|
|
const upstream = this.upstreams.get(serverName);
|
|
if (!upstream || !upstream.isAlive()) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
error: { code: -32603, message: `Upstream '${serverName}' is not available` },
|
|
};
|
|
}
|
|
|
|
// Strip the namespace prefix
|
|
const originalName = nameField === 'uri'
|
|
? name.slice(`${serverName}://`.length)
|
|
: name.slice(serverName.length + 1);
|
|
|
|
const upstreamRequest: JsonRpcRequest = {
|
|
...request,
|
|
params: {
|
|
...params,
|
|
[nameField]: originalName,
|
|
},
|
|
};
|
|
|
|
if (this.onUpstreamCall) {
|
|
const start = performance.now();
|
|
const response = await upstream.send(upstreamRequest);
|
|
const durationMs = Math.round(performance.now() - start);
|
|
this.onUpstreamCall({ upstream: serverName, method: request.method, request: upstreamRequest, response, durationMs });
|
|
return response;
|
|
}
|
|
|
|
return upstream.send(upstreamRequest);
|
|
}
|
|
|
|
/**
|
|
* Route a generic request. Handles protocol-level methods locally,
|
|
* delegates tool/resource/prompt calls to upstreams.
|
|
*
|
|
* When a plugin is set, plugin hooks are dispatched at each stage.
|
|
* Without a plugin, the router is a transparent MCP proxy.
|
|
*/
|
|
async route(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
|
|
switch (request.method) {
|
|
case 'initialize': {
|
|
if (this.plugin && context?.sessionId) {
|
|
const ctx = await this.getOrCreatePluginContext(context.sessionId);
|
|
|
|
let instructions = this.instructions ?? '';
|
|
if (this.plugin.onInitialize) {
|
|
const hookResult = await this.plugin.onInitialize(request, ctx);
|
|
if (hookResult?.instructions) {
|
|
instructions = instructions ? `${instructions}\n${hookResult.instructions}` : hookResult.instructions;
|
|
}
|
|
}
|
|
|
|
const result: Record<string, unknown> = {
|
|
protocolVersion: '2024-11-05',
|
|
serverInfo: { name: 'mcpctl-proxy', version: '0.0.1' },
|
|
capabilities: { tools: { listChanged: true }, resources: {}, prompts: {} },
|
|
};
|
|
if (instructions) result['instructions'] = instructions;
|
|
return { jsonrpc: '2.0', id: request.id, result };
|
|
}
|
|
|
|
// No plugin: transparent proxy — return basic init response
|
|
const result: Record<string, unknown> = {
|
|
protocolVersion: '2024-11-05',
|
|
serverInfo: { name: 'mcpctl-proxy', version: '0.0.1' },
|
|
capabilities: { tools: { listChanged: true }, resources: {}, prompts: {} },
|
|
};
|
|
if (this.instructions) result['instructions'] = this.instructions;
|
|
return { jsonrpc: '2.0', id: request.id, result };
|
|
}
|
|
|
|
case 'tools/list': {
|
|
if (this.plugin && context?.sessionId) {
|
|
const ctx = await this.getOrCreatePluginContext(context.sessionId);
|
|
let tools = await this.discoverTools(context?.correlationId);
|
|
|
|
if (this.plugin.onToolsList) {
|
|
tools = await this.plugin.onToolsList(tools, ctx);
|
|
}
|
|
|
|
return { jsonrpc: '2.0', id: request.id, result: { tools } };
|
|
}
|
|
|
|
// No plugin: return upstream tools only
|
|
const tools = await this.discoverTools(context?.correlationId);
|
|
return { jsonrpc: '2.0', id: request.id, result: { tools } };
|
|
}
|
|
|
|
case 'tools/call':
|
|
return this.routeToolCall(request, context);
|
|
|
|
case 'resources/list': {
|
|
if (this.plugin?.onResourcesList && context?.sessionId) {
|
|
const ctx = await this.getOrCreatePluginContext(context.sessionId);
|
|
const resources = await this.discoverResources(context?.correlationId);
|
|
const filtered = await this.plugin.onResourcesList(resources, ctx);
|
|
return { jsonrpc: '2.0', id: request.id, result: { resources: filtered } };
|
|
}
|
|
|
|
const resources = await this.discoverResources(context?.correlationId);
|
|
// Append mcpctl prompt resources
|
|
const mcpdResources: Array<{ uri: string; name: string; description: string; mimeType: string }> = [];
|
|
if (this.mcpdClient && this.projectName) {
|
|
try {
|
|
const sessionParam = context?.sessionId ? `?session=${encodeURIComponent(context.sessionId)}` : '';
|
|
const visible = await this.mcpdClient.get<Array<{ name: string; content: string; type: string }>>(
|
|
`/api/v1/projects/${encodeURIComponent(this.projectName)}/prompts/visible${sessionParam}`,
|
|
);
|
|
this.mcpctlResourceContents.clear();
|
|
for (const p of visible) {
|
|
const uri = `mcpctl://prompts/${p.name}`;
|
|
const res = {
|
|
uri,
|
|
name: p.name,
|
|
description: p.type === 'promptrequest' ? `[Pending proposal] ${p.name}` : `[Approved prompt] ${p.name}`,
|
|
mimeType: 'text/plain',
|
|
};
|
|
resources.push(res);
|
|
mcpdResources.push(res);
|
|
this.mcpctlResourceContents.set(uri, p.content);
|
|
}
|
|
} catch {
|
|
// Prompt resources are optional — don't fail discovery
|
|
}
|
|
}
|
|
// Emit upstream event for mcpd-sourced resources so provenance view shows them
|
|
if (mcpdResources.length > 0 && this.onUpstreamCall) {
|
|
const mcpdResponse = { result: { resources: mcpdResources } };
|
|
this.onUpstreamCall({
|
|
upstream: 'mcpd',
|
|
method: 'resources/list',
|
|
request: { jsonrpc: '2.0', id: request.id, method: 'resources/list' },
|
|
response: mcpdResponse,
|
|
durationMs: 0,
|
|
...(context?.correlationId ? { correlationId: context.correlationId } : {}),
|
|
});
|
|
}
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
result: { resources },
|
|
};
|
|
}
|
|
|
|
case 'resources/read': {
|
|
const params = request.params as Record<string, unknown> | undefined;
|
|
const uri = params?.['uri'] as string | undefined;
|
|
if (uri?.startsWith('mcpctl://prompts/') && this.mcpdClient && this.projectName) {
|
|
const promptName = uri.slice('mcpctl://prompts/'.length);
|
|
try {
|
|
const sessionParam = context?.sessionId ? `?session=${encodeURIComponent(context.sessionId)}` : '';
|
|
const visible = await this.mcpdClient.get<Array<{ name: string; content: string; type: string }>>(
|
|
`/api/v1/projects/${encodeURIComponent(this.projectName)}/prompts/visible${sessionParam}`,
|
|
);
|
|
const found = visible.find((p) => p.name === promptName);
|
|
if (found) {
|
|
this.mcpctlResourceContents.set(uri, found.content);
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
result: {
|
|
contents: [{ uri, mimeType: 'text/plain', text: found.content }],
|
|
},
|
|
};
|
|
}
|
|
} catch {
|
|
// Fall through to cache
|
|
}
|
|
// Fallback to cache if mcpd is unreachable
|
|
const cached = this.mcpctlResourceContents.get(uri);
|
|
if (cached !== undefined) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
result: {
|
|
contents: [{ uri, mimeType: 'text/plain', text: cached }],
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
error: { code: -32602, message: `Resource not found: ${uri}` },
|
|
};
|
|
}
|
|
if (uri?.startsWith('mcpctl://')) {
|
|
const content = this.mcpctlResourceContents.get(uri);
|
|
if (content !== undefined) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
result: {
|
|
contents: [{ uri, mimeType: 'text/plain', text: content }],
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
error: { code: -32602, message: `Resource not found: ${uri}` },
|
|
};
|
|
}
|
|
return this.routeNamespacedCall(request, 'uri', this.resourceToServer);
|
|
}
|
|
|
|
case 'resources/subscribe':
|
|
case 'resources/unsubscribe':
|
|
return this.routeNamespacedCall(request, 'uri', this.resourceToServer);
|
|
|
|
case 'prompts/list': {
|
|
if (this.plugin?.onPromptsList && context?.sessionId) {
|
|
const ctx = await this.getOrCreatePluginContext(context.sessionId);
|
|
const upstreamPrompts = await this.discoverPrompts(context?.correlationId);
|
|
const filtered = await this.plugin.onPromptsList(upstreamPrompts, ctx);
|
|
return { jsonrpc: '2.0', id: request.id, result: { prompts: filtered } };
|
|
}
|
|
|
|
const upstreamPrompts = await this.discoverPrompts(context?.correlationId);
|
|
// Include mcpctl-managed prompts from mcpd alongside upstream prompts
|
|
const managedIndex = await this.fetchPromptIndex();
|
|
const managedPrompts = managedIndex.map((p) => ({
|
|
name: `mcpctl/${p.name}`,
|
|
description: p.summary ?? `Priority ${p.priority} prompt`,
|
|
}));
|
|
// Emit upstream event for mcpd-sourced prompts so provenance view shows them
|
|
if (managedPrompts.length > 0 && this.onUpstreamCall) {
|
|
const mcpdResponse = { result: { prompts: managedPrompts } };
|
|
this.onUpstreamCall({
|
|
upstream: 'mcpd',
|
|
method: 'prompts/list',
|
|
request: { jsonrpc: '2.0', id: request.id, method: 'prompts/list' },
|
|
response: mcpdResponse,
|
|
durationMs: 0,
|
|
...(context?.correlationId ? { correlationId: context.correlationId } : {}),
|
|
});
|
|
}
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
result: { prompts: [...upstreamPrompts, ...managedPrompts] },
|
|
};
|
|
}
|
|
|
|
case 'prompts/get': {
|
|
const promptName = (request.params as Record<string, unknown> | undefined)?.name as string | undefined;
|
|
|
|
// Plugin hook: onPromptGet (section drill-down, etc.)
|
|
if (this.plugin?.onPromptGet && context?.sessionId && promptName) {
|
|
const ctx = await this.getOrCreatePluginContext(context.sessionId);
|
|
const intercepted = await this.plugin.onPromptGet(promptName, request, ctx);
|
|
if (intercepted) return intercepted;
|
|
}
|
|
|
|
let response: JsonRpcResponse;
|
|
if (promptName?.startsWith('mcpctl/')) {
|
|
const shortName = promptName.slice('mcpctl/'.length);
|
|
const managedIndex = await this.fetchPromptIndex();
|
|
const entry = managedIndex.find((p) => p.name === shortName);
|
|
if (!entry) {
|
|
return { jsonrpc: '2.0', id: request.id, error: { code: -32601, message: `Unknown name: ${promptName}` } };
|
|
}
|
|
response = {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
result: {
|
|
prompt: {
|
|
name: promptName,
|
|
description: entry.summary ?? `Priority ${entry.priority} prompt`,
|
|
},
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: { type: 'text', text: entry.content || '(empty)' },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
} else {
|
|
response = await this.routeNamespacedCall(request, 'name', this.promptToServer);
|
|
}
|
|
|
|
// Post-process: run large prompt content through the pipeline for section-split
|
|
if (!response.error && this.plugin && context?.sessionId) {
|
|
const ctx = await this.getOrCreatePluginContext(context.sessionId);
|
|
const text = extractPromptText(response);
|
|
if (text && text.length > 2000) {
|
|
try {
|
|
const result = await ctx.processContent(promptName ?? 'prompt', text, 'prompt');
|
|
if (result.sections && result.sections.length > 0) {
|
|
response = injectSectionsIntoPromptResponse(response, result.content, result.sections, ctx);
|
|
} else if (result.content !== text) {
|
|
response = replacePromptText(response, result.content);
|
|
}
|
|
} catch {
|
|
// Pipeline failed — return original
|
|
}
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
// Handle MCP notifications (no response expected, but return empty result if called as request)
|
|
case 'notifications/initialized':
|
|
case 'notifications/cancelled':
|
|
case 'notifications/progress':
|
|
case 'notifications/roots/list_changed':
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
result: {},
|
|
};
|
|
|
|
default:
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
error: { code: -32601, message: `Method not found: ${request.method}` },
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Route a tools/call request.
|
|
*
|
|
* Plugin path: virtual tools → onToolCallBefore → upstream → onToolCallAfter.
|
|
* No-plugin path: pagination → LLM processing → upstream.
|
|
*/
|
|
private async routeToolCall(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
|
|
const params = request.params as Record<string, unknown> | undefined;
|
|
const toolName = params?.['name'] as string | undefined;
|
|
const toolArgs = (params?.['arguments'] ?? {}) as Record<string, unknown>;
|
|
const startMs = Date.now();
|
|
|
|
const emitTrace = (response: JsonRpcResponse, serverName?: string): void => {
|
|
if (this.auditCollector && context?.sessionId && toolName) {
|
|
const durationMs = Date.now() - startMs;
|
|
const resultSize = JSON.stringify(response.result ?? response.error ?? {}).length;
|
|
const resolved = serverName ?? this.toolToServer.get(toolName);
|
|
const base = {
|
|
timestamp: new Date().toISOString(),
|
|
sessionId: context.sessionId,
|
|
eventKind: 'tool_call_trace' as const,
|
|
source: 'mcplocal' as const,
|
|
verified: true,
|
|
// Already in scope and previously dropped, which left every durable
|
|
// tool_call_trace row unjoinable to the rest of its request.
|
|
...(context.correlationId !== undefined ? { correlationId: context.correlationId } : {}),
|
|
payload: {
|
|
toolName,
|
|
argKeys: Object.keys(toolArgs).join(', '),
|
|
resultSizeBytes: resultSize,
|
|
durationMs,
|
|
error: response.error?.message ?? null,
|
|
},
|
|
};
|
|
this.auditCollector.emit(resolved ? { ...base, serverName: resolved } : base);
|
|
}
|
|
};
|
|
|
|
// Plugin path
|
|
if (this.plugin && context?.sessionId) {
|
|
const ctx = await this.getOrCreatePluginContext(context.sessionId);
|
|
|
|
// Check virtual tools first (registered by plugin via ctx.registerTool)
|
|
const virtualTool = ctx.virtualTools.get(toolName ?? '');
|
|
if (virtualTool) {
|
|
try {
|
|
const result = await virtualTool.handler(toolArgs, ctx);
|
|
const resp = { jsonrpc: '2.0' as const, id: request.id, result: result as JsonRpcResponse['result'] };
|
|
emitTrace(resp, 'virtual');
|
|
return resp;
|
|
} catch (err) {
|
|
const code = (err as { code?: number }).code ?? -32603;
|
|
const resp = { jsonrpc: '2.0' as const, id: request.id, error: { code, message: err instanceof Error ? err.message : String(err) } };
|
|
emitTrace(resp, 'virtual');
|
|
return resp;
|
|
}
|
|
}
|
|
|
|
// onToolCallBefore — can intercept and return a response directly
|
|
if (this.plugin.onToolCallBefore) {
|
|
const intercepted = await this.plugin.onToolCallBefore(toolName ?? '', toolArgs, request, ctx);
|
|
if (intercepted) {
|
|
// Intercepts were previously invisible in tool_call_trace, so every
|
|
// call made while the session was gated produced no trace at all.
|
|
emitTrace(intercepted, 'plugin');
|
|
return intercepted;
|
|
}
|
|
}
|
|
|
|
// Route to upstream
|
|
let response = await this.routeNamespacedCall(request, 'name', this.toolToServer);
|
|
|
|
// onToolCallAfter — can transform the response
|
|
if (this.plugin.onToolCallAfter && toolName) {
|
|
response = await this.plugin.onToolCallAfter(toolName, toolArgs, response, ctx);
|
|
}
|
|
|
|
emitTrace(response);
|
|
return response;
|
|
}
|
|
|
|
// No plugin: transparent proxy with pagination and LLM processing
|
|
|
|
// Intercept pagination page requests
|
|
if (this.paginator) {
|
|
const paginationReq = ResponsePaginator.extractPaginationParams(toolArgs);
|
|
if (paginationReq) {
|
|
const pageResult = this.paginator.getPage(paginationReq.resultId, paginationReq.page);
|
|
if (pageResult) {
|
|
return { jsonrpc: '2.0', id: request.id, result: pageResult };
|
|
}
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id: request.id,
|
|
result: {
|
|
content: [{
|
|
type: 'text',
|
|
text: 'Cached result not found (expired or invalid _resultId). Please re-call the tool without _resultId/_page to get a fresh result.',
|
|
}],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// If no LLM processor or tool shouldn't be processed, route directly
|
|
if (!this.llmProcessor || !toolName || !this.llmProcessor.shouldProcess('tools/call', toolName)) {
|
|
const response = await this.routeNamespacedCall(request, 'name', this.toolToServer);
|
|
const paginated = await this.maybePaginate(toolName, response);
|
|
emitTrace(paginated);
|
|
return paginated;
|
|
}
|
|
|
|
// Preprocess request params
|
|
const processed = await this.llmProcessor.preprocessRequest(toolName, toolArgs);
|
|
const processedRequest: JsonRpcRequest = processed.optimized
|
|
? { ...request, params: { ...params, arguments: processed.params } }
|
|
: request;
|
|
|
|
// Route to upstream
|
|
const response = await this.routeNamespacedCall(processedRequest, 'name', this.toolToServer);
|
|
|
|
// Try pagination
|
|
const paginated = await this.maybePaginate(toolName, response);
|
|
if (paginated !== response) {
|
|
emitTrace(paginated);
|
|
return paginated;
|
|
}
|
|
|
|
// Filter response
|
|
if (response.error) {
|
|
emitTrace(response);
|
|
return response;
|
|
}
|
|
const filtered = await this.llmProcessor.filterResponse(toolName, response);
|
|
if (filtered.filtered) {
|
|
const filteredResp = { ...response, result: filtered.result };
|
|
emitTrace(filteredResp);
|
|
return filteredResp;
|
|
}
|
|
emitTrace(response);
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Apply pagination to a tool response if it exceeds the size threshold.
|
|
*/
|
|
private async maybePaginate(toolName: string | undefined, response: JsonRpcResponse): Promise<JsonRpcResponse> {
|
|
if (!toolName || !this.paginator || response.error) return response;
|
|
const raw = JSON.stringify(response.result);
|
|
if (!this.paginator.shouldPaginate(raw)) return response;
|
|
const paginated = await this.paginator.paginate(toolName, raw);
|
|
if (!paginated) return response;
|
|
return { jsonrpc: '2.0', id: response.id, result: paginated };
|
|
}
|
|
|
|
// ── Prompt and system prompt fetchers (used by plugin context) ──
|
|
|
|
/**
|
|
* Fetch prompt index from mcpd with caching.
|
|
*/
|
|
private async fetchPromptIndex(): Promise<PromptIndexEntry[]> {
|
|
const now = Date.now();
|
|
if (this.cachedPromptIndex && (now - this.promptIndexFetchedAt) < this.PROMPT_INDEX_TTL_MS) {
|
|
return this.cachedPromptIndex;
|
|
}
|
|
|
|
if (!this.mcpdClient || !this.projectName) {
|
|
return [];
|
|
}
|
|
|
|
const index = await this.mcpdClient.get<Array<{
|
|
name: string;
|
|
priority: number;
|
|
summary: string | null;
|
|
chapters: string[] | null;
|
|
content?: string;
|
|
linkTarget?: string | null;
|
|
}>>(
|
|
`/api/v1/projects/${encodeURIComponent(this.projectName)}/prompts/visible`,
|
|
);
|
|
|
|
// Resolve linked prompts: fetch fresh content from linked MCP resources
|
|
const entries: PromptIndexEntry[] = [];
|
|
for (const p of index) {
|
|
let content = p.content ?? '';
|
|
if (p.linkTarget && this.linkResolver) {
|
|
try {
|
|
const resolution = await this.linkResolver.resolve(p.linkTarget);
|
|
if (resolution.status === 'alive' && resolution.content) {
|
|
content = resolution.content;
|
|
}
|
|
} catch {
|
|
// Keep static content as fallback
|
|
}
|
|
}
|
|
entries.push({
|
|
name: p.name,
|
|
priority: p.priority,
|
|
summary: p.summary,
|
|
chapters: p.chapters,
|
|
content,
|
|
});
|
|
}
|
|
|
|
this.cachedPromptIndex = entries;
|
|
this.promptIndexFetchedAt = now;
|
|
return this.cachedPromptIndex;
|
|
}
|
|
|
|
/**
|
|
* Fetch a system prompt from mcpctl-system project, with caching and fallback.
|
|
*/
|
|
private async getSystemPrompt(name: string, fallback: string): Promise<string> {
|
|
const now = Date.now();
|
|
const cached = this.systemPromptCache.get(name);
|
|
if (cached && (now - cached.fetchedAt) < this.SYSTEM_PROMPT_TTL_MS) {
|
|
return cached.content;
|
|
}
|
|
|
|
if (!this.mcpdClient) return fallback;
|
|
|
|
try {
|
|
const visible = await this.mcpdClient.get<Array<{ name: string; content: string }>>(
|
|
'/api/v1/projects/mcpctl-system/prompts/visible',
|
|
);
|
|
// Cache all system prompts from the response
|
|
for (const p of visible) {
|
|
this.systemPromptCache.set(p.name, { content: p.content, fetchedAt: now });
|
|
}
|
|
const found = visible.find((p) => p.name === name);
|
|
return found?.content ?? fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
// ── Notification queue ──
|
|
|
|
private queueNotification(sessionId: string | undefined, notification: JsonRpcNotification): void {
|
|
if (!sessionId) return;
|
|
const queue = this.pendingNotifications.get(sessionId) ?? [];
|
|
queue.push(notification);
|
|
this.pendingNotifications.set(sessionId, queue);
|
|
}
|
|
|
|
/** Consume and return any pending notifications for a session (e.g., tools/list_changed after ungating). */
|
|
consumeNotifications(sessionId: string): JsonRpcNotification[] {
|
|
const notifications = this.pendingNotifications.get(sessionId) ?? [];
|
|
this.pendingNotifications.delete(sessionId);
|
|
return notifications;
|
|
}
|
|
|
|
// ── Session cleanup ──
|
|
|
|
cleanupSession(sessionId: string): void {
|
|
const pluginCtx = this.pluginContexts.get(sessionId);
|
|
if (pluginCtx && this.plugin?.onSessionDestroy) {
|
|
void this.plugin.onSessionDestroy(pluginCtx);
|
|
}
|
|
this.pluginContexts.delete(sessionId);
|
|
this.pendingNotifications.delete(sessionId);
|
|
}
|
|
|
|
getUpstreamNames(): string[] {
|
|
return [...this.upstreams.keys()];
|
|
}
|
|
|
|
getProxyModelInfo(): { projectDefault: string | null; serverOverrides: Record<string, string> } {
|
|
const serverOverrides: Record<string, string> = {};
|
|
for (const [server, config] of this.serverProxyModels) {
|
|
serverOverrides[server] = config.name;
|
|
}
|
|
return { projectDefault: this.proxyModelName, serverOverrides };
|
|
}
|
|
|
|
async closeAll(): Promise<void> {
|
|
for (const upstream of this.upstreams.values()) {
|
|
await upstream.close();
|
|
}
|
|
this.upstreams.clear();
|
|
this.toolToServer.clear();
|
|
this.resourceToServer.clear();
|
|
this.promptToServer.clear();
|
|
this.pluginContexts.clear();
|
|
}
|
|
}
|
|
|
|
// ── Prompt section drill-down helpers ──
|
|
|
|
// Same key used by content-pipeline plugin so drill-down lookups work across tools and prompts
|
|
const SECTION_STORE_KEY = '_contentPipeline_sections';
|
|
const SECTION_TTL_MS = 300_000; // 5 minutes
|
|
|
|
interface SectionStoreEntry {
|
|
sections: Section[];
|
|
createdAt: number;
|
|
}
|
|
|
|
/** Extract text from all messages in a prompts/get response. */
|
|
function extractPromptText(response: JsonRpcResponse): string | null {
|
|
if (!response.result || typeof response.result !== 'object') return null;
|
|
const result = response.result as Record<string, unknown>;
|
|
const messages = result['messages'] as Array<{ role: string; content: unknown }> | undefined;
|
|
if (!Array.isArray(messages)) return null;
|
|
const texts: string[] = [];
|
|
for (const msg of messages) {
|
|
if (typeof msg.content === 'string') {
|
|
texts.push(msg.content);
|
|
} else if (msg.content && typeof msg.content === 'object') {
|
|
const c = msg.content as { type?: string; text?: string };
|
|
if (c.type === 'text' && c.text) texts.push(c.text);
|
|
}
|
|
}
|
|
return texts.length > 0 ? texts.join('\n') : null;
|
|
}
|
|
|
|
/** Replace prompt message text with processed content. */
|
|
function replacePromptText(response: JsonRpcResponse, newText: string): JsonRpcResponse {
|
|
const result = response.result as Record<string, unknown>;
|
|
const messages = result['messages'] as Array<{ role: string; content: unknown }>;
|
|
const newMessages = messages.map((msg) => {
|
|
if (typeof msg.content === 'string') {
|
|
return { ...msg, content: newText };
|
|
}
|
|
if (msg.content && typeof msg.content === 'object') {
|
|
return { ...msg, content: { type: 'text', text: newText } };
|
|
}
|
|
return msg;
|
|
});
|
|
return { ...response, result: { ...result, messages: newMessages } };
|
|
}
|
|
|
|
/** Inject sections TOC into prompt response and store sections for drill-down. */
|
|
function injectSectionsIntoPromptResponse(
|
|
response: JsonRpcResponse,
|
|
tocContent: string,
|
|
sections: Section[],
|
|
ctx: PluginSessionContext,
|
|
): JsonRpcResponse {
|
|
const resultId = `pm-${Date.now().toString(36)}`;
|
|
|
|
// Store sections (same store as tool results so drill-down works uniformly)
|
|
let store = ctx.state.get(SECTION_STORE_KEY) as Map<string, SectionStoreEntry> | undefined;
|
|
if (!store) {
|
|
store = new Map();
|
|
ctx.state.set(SECTION_STORE_KEY, store);
|
|
}
|
|
store.set(resultId, { sections, createdAt: Date.now() });
|
|
// Evict stale
|
|
const now = Date.now();
|
|
for (const [key, entry] of store) {
|
|
if (now - entry.createdAt > SECTION_TTL_MS) store.delete(key);
|
|
}
|
|
|
|
const text = `${tocContent}\n\nTo read one, request this prompt again with _resultId="${resultId}" and _section="<id>". Both parameters are required together.`;
|
|
return replacePromptText(response, text);
|
|
}
|