feat: mcpctl v0.0.1 — first public release
Comprehensive MCP server management with kubectl-style CLI. Key features in this release: - Declarative YAML apply/get round-trip with project cloning support - Gated sessions with prompt intelligence for Claude - Interactive MCP console with traffic inspector - Persistent STDIO connections for containerized servers - RBAC with name-scoped bindings - Shell completions (fish + bash) auto-generated - Rate-limit retry with exponential backoff in apply - Project-scoped prompt management - Credential scrubbing from git history Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mcpctl/mcplocal",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -60,26 +60,63 @@ export class TagMatcher {
|
||||
}
|
||||
|
||||
private computeScore(lowerTags: string[], prompt: PromptIndexEntry): number {
|
||||
// Priority 10 always included
|
||||
// Priority 10 always included at the top
|
||||
if (prompt.priority === 10) return Infinity;
|
||||
|
||||
if (lowerTags.length === 0) return 0;
|
||||
// Baseline score = priority (so all prompts compete for the byte budget)
|
||||
// Tag matches boost the score further (matchCount * priority on top)
|
||||
let boost = 0;
|
||||
if (lowerTags.length > 0) {
|
||||
const searchText = [
|
||||
prompt.name,
|
||||
prompt.summary ?? '',
|
||||
...(prompt.chapters ?? []),
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
const searchText = [
|
||||
prompt.name,
|
||||
prompt.summary ?? '',
|
||||
...(prompt.chapters ?? []),
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
let matchCount = 0;
|
||||
for (const tag of lowerTags) {
|
||||
if (searchText.includes(tag)) matchCount++;
|
||||
for (const tag of lowerTags) {
|
||||
if (searchText.includes(tag)) boost++;
|
||||
}
|
||||
boost *= prompt.priority;
|
||||
}
|
||||
|
||||
return matchCount * prompt.priority;
|
||||
return prompt.priority + boost;
|
||||
}
|
||||
}
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'the', 'a', 'an', 'is', 'to', 'for', 'of', 'and', 'or', 'in', 'on', 'at',
|
||||
'by', 'with', 'from', 'this', 'that', 'it', 'its', 'as', 'be', 'are', 'was',
|
||||
'were', 'been', 'has', 'have', 'had', 'do', 'does', 'did', 'but', 'not',
|
||||
'can', 'will', 'would', 'could', 'should', 'may', 'might', 'shall', 'must',
|
||||
'so', 'if', 'then', 'than', 'too', 'very', 'just', 'about', 'up', 'out',
|
||||
'no', 'yes', 'all', 'any', 'some', 'my', 'your', 'our', 'their', 'what',
|
||||
'which', 'who', 'how', 'when', 'where', 'why', 'want', 'need', 'get', 'set',
|
||||
'use', 'like', 'make', 'know', 'help', 'try',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Convert a natural-language description into keyword tags.
|
||||
* Splits on whitespace/punctuation, lowercases, filters stop words and short words, caps at 10.
|
||||
*/
|
||||
export function tokenizeDescription(description: string): string[] {
|
||||
const words = description
|
||||
.toLowerCase()
|
||||
.split(/[\s.,;:!?'"()\[\]{}<>|/\\@#$%^&*+=~`]+/)
|
||||
.map((w) => w.replace(/[^a-z0-9-]/g, ''))
|
||||
.filter((w) => w.length >= 3 && !STOP_WORDS.has(w));
|
||||
|
||||
// Deduplicate while preserving order
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const w of words) {
|
||||
if (!seen.has(w)) {
|
||||
seen.add(w);
|
||||
unique.push(w);
|
||||
}
|
||||
}
|
||||
return unique.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract keywords from a tool call for the intercept fallback path.
|
||||
* Pulls words from the tool name and string argument values.
|
||||
|
||||
82
src/mcplocal/src/http/inspect-endpoint.ts
Normal file
82
src/mcplocal/src/http/inspect-endpoint.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* SSE endpoint for the MCP traffic inspector.
|
||||
*
|
||||
* GET /inspect?project=X&session=Y
|
||||
*
|
||||
* Streams TrafficEvents as SSE data lines. On connect, sends a snapshot
|
||||
* of active sessions and recent buffered events, then streams live.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { TrafficCapture, TrafficFilter } from './traffic.js';
|
||||
|
||||
export function registerInspectEndpoint(app: FastifyInstance, capture: TrafficCapture): void {
|
||||
app.get<{
|
||||
Querystring: { project?: string; session?: string };
|
||||
}>('/inspect', async (request, reply) => {
|
||||
const filter: TrafficFilter = {
|
||||
project: request.query.project,
|
||||
session: request.query.session,
|
||||
};
|
||||
|
||||
// Set SSE headers
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||||
});
|
||||
|
||||
// Send active sessions snapshot
|
||||
const sessions = capture.getActiveSessions();
|
||||
const filteredSessions = filter.project
|
||||
? sessions.filter((s) => s.projectName === filter.project)
|
||||
: sessions;
|
||||
|
||||
reply.raw.write(`event: sessions\ndata: ${JSON.stringify(filteredSessions)}\n\n`);
|
||||
|
||||
// Send buffered events
|
||||
const buffered = capture.getBuffer(filter);
|
||||
for (const event of buffered) {
|
||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
|
||||
// Flush marker so client knows history is done
|
||||
reply.raw.write(`event: live\ndata: {}\n\n`);
|
||||
|
||||
// Subscribe to live events
|
||||
const matchesFilter = (e: { projectName: string; sessionId: string }): boolean => {
|
||||
if (filter.project && e.projectName !== filter.project) return false;
|
||||
if (filter.session && e.sessionId !== filter.session) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const unsubscribe = capture.subscribe((event) => {
|
||||
if (!matchesFilter(event)) return;
|
||||
try {
|
||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
} catch {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
// Keep-alive ping every 30s
|
||||
const keepAlive = setInterval(() => {
|
||||
try {
|
||||
reply.raw.write(': keepalive\n\n');
|
||||
} catch {
|
||||
clearInterval(keepAlive);
|
||||
unsubscribe();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// Cleanup on disconnect
|
||||
request.raw.on('close', () => {
|
||||
clearInterval(keepAlive);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
// Hijack so Fastify doesn't try to send its own response
|
||||
reply.hijack();
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { loadProjectLlmOverride } from './config.js';
|
||||
import type { McpdClient } from './mcpd-client.js';
|
||||
import type { ProviderRegistry } from '../providers/registry.js';
|
||||
import type { JsonRpcRequest } from '../types.js';
|
||||
import type { TrafficCapture } from './traffic.js';
|
||||
|
||||
interface ProjectCacheEntry {
|
||||
router: McpRouter;
|
||||
@@ -31,7 +32,7 @@ interface SessionEntry {
|
||||
|
||||
const CACHE_TTL_MS = 60_000; // 60 seconds
|
||||
|
||||
export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: McpdClient, providerRegistry?: ProviderRegistry | null): void {
|
||||
export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: McpdClient, providerRegistry?: ProviderRegistry | null, trafficCapture?: TrafficCapture | null): void {
|
||||
const projectCache = new Map<string, ProjectCacheEntry>();
|
||||
const sessions = new Map<string, SessionEntry>();
|
||||
|
||||
@@ -131,13 +132,88 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
sessions.set(id, { transport, projectName });
|
||||
trafficCapture?.emit({
|
||||
timestamp: new Date().toISOString(),
|
||||
projectName,
|
||||
sessionId: id,
|
||||
eventType: 'session_created',
|
||||
body: null,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Wire upstream call tracing into the router
|
||||
if (trafficCapture) {
|
||||
router.onUpstreamCall = (info) => {
|
||||
const sid = transport.sessionId ?? 'unknown';
|
||||
trafficCapture.emit({
|
||||
timestamp: new Date().toISOString(),
|
||||
projectName,
|
||||
sessionId: sid,
|
||||
eventType: 'upstream_request',
|
||||
method: info.method,
|
||||
upstreamName: info.upstream,
|
||||
body: info.request,
|
||||
});
|
||||
trafficCapture.emit({
|
||||
timestamp: new Date().toISOString(),
|
||||
projectName,
|
||||
sessionId: sid,
|
||||
eventType: 'upstream_response',
|
||||
method: info.method,
|
||||
upstreamName: info.upstream,
|
||||
body: info.response,
|
||||
durationMs: info.durationMs,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
transport.onmessage = async (message: JSONRPCMessage) => {
|
||||
if ('method' in message && 'id' in message) {
|
||||
const requestId = message.id as string | number;
|
||||
const sid = transport.sessionId ?? 'unknown';
|
||||
const method = (message as { method?: string }).method;
|
||||
|
||||
// Capture client request
|
||||
trafficCapture?.emit({
|
||||
timestamp: new Date().toISOString(),
|
||||
projectName,
|
||||
sessionId: sid,
|
||||
eventType: 'client_request',
|
||||
method,
|
||||
body: message,
|
||||
});
|
||||
|
||||
const ctx = transport.sessionId ? { sessionId: transport.sessionId } : undefined;
|
||||
const response = await router.route(message as unknown as JsonRpcRequest, ctx);
|
||||
|
||||
// Forward queued notifications BEFORE the response — the response send
|
||||
// closes the POST SSE stream, so notifications must go first.
|
||||
// relatedRequestId routes them onto the same SSE stream as the response.
|
||||
if (transport.sessionId) {
|
||||
for (const n of router.consumeNotifications(transport.sessionId)) {
|
||||
trafficCapture?.emit({
|
||||
timestamp: new Date().toISOString(),
|
||||
projectName,
|
||||
sessionId: sid,
|
||||
eventType: 'client_notification',
|
||||
method: (n as { method?: string }).method,
|
||||
body: n,
|
||||
});
|
||||
await transport.send(n as unknown as JSONRPCMessage, { relatedRequestId: requestId });
|
||||
}
|
||||
}
|
||||
|
||||
// Capture client response
|
||||
trafficCapture?.emit({
|
||||
timestamp: new Date().toISOString(),
|
||||
projectName,
|
||||
sessionId: sid,
|
||||
eventType: 'client_response',
|
||||
method,
|
||||
body: response,
|
||||
});
|
||||
|
||||
await transport.send(response as unknown as JSONRPCMessage);
|
||||
}
|
||||
};
|
||||
@@ -145,6 +221,13 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
||||
transport.onclose = () => {
|
||||
const id = transport.sessionId;
|
||||
if (id) {
|
||||
trafficCapture?.emit({
|
||||
timestamp: new Date().toISOString(),
|
||||
projectName,
|
||||
sessionId: id,
|
||||
eventType: 'session_closed',
|
||||
body: null,
|
||||
});
|
||||
sessions.delete(id);
|
||||
router.cleanupSession(id);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { McpdClient } from './mcpd-client.js';
|
||||
import { registerProxyRoutes } from './routes/proxy.js';
|
||||
import { registerMcpEndpoint } from './mcp-endpoint.js';
|
||||
import { registerProjectMcpEndpoint } from './project-mcp-endpoint.js';
|
||||
import { registerInspectEndpoint } from './inspect-endpoint.js';
|
||||
import { TrafficCapture } from './traffic.js';
|
||||
import type { McpRouter } from '../router.js';
|
||||
import type { HealthMonitor } from '../health.js';
|
||||
import type { TieredHealthMonitor } from '../health/tiered.js';
|
||||
@@ -181,11 +183,15 @@ export async function createHttpServer(
|
||||
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
||||
registerProxyRoutes(app, mcpdClient);
|
||||
|
||||
// Traffic inspector
|
||||
const trafficCapture = new TrafficCapture();
|
||||
registerInspectEndpoint(app, trafficCapture);
|
||||
|
||||
// Streamable HTTP MCP protocol endpoint at /mcp
|
||||
registerMcpEndpoint(app, deps.router);
|
||||
|
||||
// Project-scoped MCP endpoint at /projects/:projectName/mcp
|
||||
registerProjectMcpEndpoint(app, mcpdClient, deps.providerRegistry);
|
||||
registerProjectMcpEndpoint(app, mcpdClient, deps.providerRegistry, trafficCapture);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
116
src/mcplocal/src/http/traffic.ts
Normal file
116
src/mcplocal/src/http/traffic.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Traffic capture for the MCP inspector.
|
||||
*
|
||||
* Records all MCP traffic flowing through mcplocal — both client-facing
|
||||
* messages and internal upstream routing. Events are stored in a ring
|
||||
* buffer and streamed to SSE subscribers in real-time.
|
||||
*/
|
||||
|
||||
export type TrafficEventType =
|
||||
| 'client_request'
|
||||
| 'client_response'
|
||||
| 'client_notification'
|
||||
| 'upstream_request'
|
||||
| 'upstream_response'
|
||||
| 'session_created'
|
||||
| 'session_closed';
|
||||
|
||||
export interface TrafficEvent {
|
||||
timestamp: string;
|
||||
projectName: string;
|
||||
sessionId: string;
|
||||
eventType: TrafficEventType;
|
||||
method?: string | undefined;
|
||||
upstreamName?: string | undefined;
|
||||
body: unknown;
|
||||
durationMs?: number | undefined;
|
||||
}
|
||||
|
||||
export interface ActiveSession {
|
||||
sessionId: string;
|
||||
projectName: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export interface TrafficFilter {
|
||||
project?: string | undefined;
|
||||
session?: string | undefined;
|
||||
}
|
||||
|
||||
type Listener = (event: TrafficEvent) => void;
|
||||
|
||||
const DEFAULT_MAX_BUFFER = 5000;
|
||||
|
||||
export class TrafficCapture {
|
||||
private listeners = new Set<Listener>();
|
||||
private buffer: TrafficEvent[] = [];
|
||||
private readonly maxBuffer: number;
|
||||
private activeSessions = new Map<string, ActiveSession>();
|
||||
|
||||
constructor(maxBuffer = DEFAULT_MAX_BUFFER) {
|
||||
this.maxBuffer = maxBuffer;
|
||||
}
|
||||
|
||||
emit(event: TrafficEvent): void {
|
||||
// Track active sessions
|
||||
if (event.eventType === 'session_created') {
|
||||
this.activeSessions.set(event.sessionId, {
|
||||
sessionId: event.sessionId,
|
||||
projectName: event.projectName,
|
||||
startedAt: event.timestamp,
|
||||
});
|
||||
} else if (event.eventType === 'session_closed') {
|
||||
this.activeSessions.delete(event.sessionId);
|
||||
}
|
||||
|
||||
// Ring buffer
|
||||
this.buffer.push(event);
|
||||
if (this.buffer.length > this.maxBuffer) {
|
||||
this.buffer.splice(0, this.buffer.length - this.maxBuffer);
|
||||
}
|
||||
|
||||
// Notify subscribers
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// Don't let a bad listener break the pipeline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to live events. Returns unsubscribe function. */
|
||||
subscribe(cb: Listener): () => void {
|
||||
this.listeners.add(cb);
|
||||
return () => {
|
||||
this.listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
/** Get buffered events, optionally filtered. */
|
||||
getBuffer(filter?: TrafficFilter): TrafficEvent[] {
|
||||
let events = this.buffer;
|
||||
if (filter?.project) {
|
||||
events = events.filter((e) => e.projectName === filter.project);
|
||||
}
|
||||
if (filter?.session) {
|
||||
events = events.filter((e) => e.sessionId === filter.session);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Get all currently active sessions. */
|
||||
getActiveSessions(): ActiveSession[] {
|
||||
return [...this.activeSessions.values()];
|
||||
}
|
||||
|
||||
/** Number of subscribers (for health/debug). */
|
||||
get subscriberCount(): number {
|
||||
return this.listeners.size;
|
||||
}
|
||||
|
||||
/** Total events in buffer. */
|
||||
get bufferSize(): number {
|
||||
return this.buffer.length;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import type { LlmProcessor } from './llm/processor.js';
|
||||
import { ResponsePaginator } from './llm/pagination.js';
|
||||
import type { McpdClient } from './http/mcpd-client.js';
|
||||
import { SessionGate } from './gate/session-gate.js';
|
||||
import { TagMatcher, extractKeywordsFromToolCall } from './gate/tag-matcher.js';
|
||||
import { TagMatcher, extractKeywordsFromToolCall, tokenizeDescription } from './gate/tag-matcher.js';
|
||||
import type { PromptIndexEntry, TagMatchResult } from './gate/tag-matcher.js';
|
||||
import { LlmPromptSelector } from './gate/llm-selector.js';
|
||||
import type { ProviderRegistry } from './providers/registry.js';
|
||||
import { LinkResolver } from './services/link-resolver.js';
|
||||
|
||||
export interface RouteContext {
|
||||
sessionId?: string;
|
||||
@@ -47,8 +48,13 @@ export class McpRouter {
|
||||
private cachedPromptIndex: PromptIndexEntry[] | null = null;
|
||||
private promptIndexFetchedAt = 0;
|
||||
private readonly PROMPT_INDEX_TTL_MS = 60_000;
|
||||
private linkResolver: LinkResolver | null = null;
|
||||
private systemPromptCache = new Map<string, { content: string; fetchedAt: number }>();
|
||||
private readonly SYSTEM_PROMPT_TTL_MS = 300_000; // 5 minutes
|
||||
private pendingNotifications = new Map<string, JsonRpcNotification[]>();
|
||||
|
||||
/** Optional callback for traffic inspection — called after each upstream call completes. */
|
||||
onUpstreamCall: ((info: { upstream: string; method?: string; request: unknown; response: unknown; durationMs: number }) => void) | null = null;
|
||||
|
||||
setPaginator(paginator: ResponsePaginator): void {
|
||||
this.paginator = paginator;
|
||||
@@ -73,6 +79,7 @@ export class McpRouter {
|
||||
setPromptConfig(mcpdClient: McpdClient, projectName: string): void {
|
||||
this.mcpdClient = mcpdClient;
|
||||
this.projectName = projectName;
|
||||
this.linkResolver = new LinkResolver(mcpdClient);
|
||||
}
|
||||
|
||||
addUpstream(connection: UpstreamConnection): void {
|
||||
@@ -277,6 +284,14 @@ export class McpRouter {
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -303,10 +318,10 @@ export class McpRouter {
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: {
|
||||
name: 'mcpctl-proxy',
|
||||
version: '0.1.0',
|
||||
version: '0.0.1',
|
||||
},
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tools: { listChanged: true },
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
@@ -455,16 +470,48 @@ export class McpRouter {
|
||||
return this.routeNamespacedCall(request, 'uri', this.resourceToServer);
|
||||
|
||||
case 'prompts/list': {
|
||||
const prompts = await this.discoverPrompts();
|
||||
const upstreamPrompts = await this.discoverPrompts();
|
||||
// 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`,
|
||||
}));
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: { prompts },
|
||||
result: { prompts: [...upstreamPrompts, ...managedPrompts] },
|
||||
};
|
||||
}
|
||||
|
||||
case 'prompts/get':
|
||||
case 'prompts/get': {
|
||||
const promptName = (request.params as Record<string, unknown> | undefined)?.name as string | undefined;
|
||||
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}` } };
|
||||
}
|
||||
return {
|
||||
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)' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return this.routeNamespacedCall(request, 'name', this.promptToServer);
|
||||
}
|
||||
|
||||
// Handle MCP notifications (no response expected, but return empty result if called as request)
|
||||
case 'notifications/initialized':
|
||||
@@ -634,6 +681,24 @@ export class McpRouter {
|
||||
// ── Gate tool definitions ──
|
||||
|
||||
private getBeginSessionTool(): { name: string; description: string; inputSchema: unknown } {
|
||||
// LLM available → description mode (natural language, LLM selects prompts)
|
||||
// No LLM → keywords mode (deterministic tag matching)
|
||||
if (this.llmSelector) {
|
||||
return {
|
||||
name: 'begin_session',
|
||||
description: 'Start your session by describing what you want to accomplish. You will receive relevant project context, policies, and guidelines. This is required before using other tools.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: {
|
||||
type: 'string',
|
||||
description: "Describe what you're trying to do in a sentence or two (e.g. \"I want to pair a new Zigbee device with the hub\")",
|
||||
},
|
||||
},
|
||||
required: ['description'],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: 'begin_session',
|
||||
description: 'Start your session by providing keywords that describe your current task. You will receive relevant project context, policies, and guidelines. This is required before using other tools.',
|
||||
@@ -680,10 +745,16 @@ export class McpRouter {
|
||||
|
||||
const params = request.params as Record<string, unknown> | undefined;
|
||||
const args = (params?.['arguments'] ?? {}) as Record<string, unknown>;
|
||||
const tags = args['tags'] as string[] | undefined;
|
||||
const rawTags = args['tags'] as string[] | undefined;
|
||||
const description = args['description'] as string | undefined;
|
||||
|
||||
if (!tags || !Array.isArray(tags) || tags.length === 0) {
|
||||
return { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'Missing or empty tags array' } };
|
||||
let tags: string[];
|
||||
if (rawTags && Array.isArray(rawTags) && rawTags.length > 0) {
|
||||
tags = rawTags;
|
||||
} else if (description && description.trim().length > 0) {
|
||||
tags = tokenizeDescription(description);
|
||||
} else {
|
||||
return { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'Provide tags or description' } };
|
||||
}
|
||||
|
||||
const sessionId = context?.sessionId;
|
||||
@@ -739,6 +810,7 @@ export class McpRouter {
|
||||
// Ungate the session
|
||||
if (sessionId) {
|
||||
this.sessionGate.ungate(sessionId, tags, matchResult);
|
||||
this.queueNotification(sessionId, { jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
|
||||
}
|
||||
|
||||
// Build response
|
||||
@@ -778,11 +850,38 @@ export class McpRouter {
|
||||
);
|
||||
responseParts.push(encouragement);
|
||||
|
||||
// Append tool inventory (names only — full descriptions available via tools/list)
|
||||
try {
|
||||
const tools = await this.discoverTools();
|
||||
if (tools.length > 0) {
|
||||
responseParts.push('\nAvailable MCP server tools:');
|
||||
for (const t of tools) {
|
||||
responseParts.push(` ${t.name}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Tool discovery is optional
|
||||
}
|
||||
|
||||
// Retry instruction (from system prompt)
|
||||
const retryInstruction = await this.getSystemPrompt(
|
||||
'gate-session-active',
|
||||
"The session is now active with full tool access. Proceed with the user's original request using the tools listed above.",
|
||||
);
|
||||
responseParts.push(`\n${retryInstruction}`);
|
||||
|
||||
// Safety cap to prevent token overflow (prompts first = most important, tool inventory last = least)
|
||||
const MAX_RESPONSE_CHARS = 24_000;
|
||||
let text = responseParts.join('\n');
|
||||
if (text.length > MAX_RESPONSE_CHARS) {
|
||||
text = text.slice(0, MAX_RESPONSE_CHARS) + '\n\n[Response truncated. Use read_prompts to retrieve full content.]';
|
||||
}
|
||||
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
content: [{ type: 'text', text: responseParts.join('\n') }],
|
||||
content: [{ type: 'text', text }],
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -886,6 +985,7 @@ export class McpRouter {
|
||||
|
||||
// Ungate the session
|
||||
this.sessionGate.ungate(sessionId, tags, matchResult);
|
||||
this.queueNotification(sessionId, { jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
|
||||
|
||||
// Build briefing from matched content
|
||||
const briefingParts: string[] = [];
|
||||
@@ -909,6 +1009,20 @@ export class McpRouter {
|
||||
briefingParts.push('');
|
||||
}
|
||||
|
||||
// Append tool inventory (names only — full descriptions available via tools/list)
|
||||
try {
|
||||
const tools = await this.discoverTools();
|
||||
if (tools.length > 0) {
|
||||
briefingParts.push('Available MCP server tools:');
|
||||
for (const t of tools) {
|
||||
briefingParts.push(` ${t.name}`);
|
||||
}
|
||||
briefingParts.push('');
|
||||
}
|
||||
} catch {
|
||||
// Tool discovery is optional
|
||||
}
|
||||
|
||||
// Now route the actual tool call
|
||||
const response = await this.routeNamespacedCall(request, 'name', this.toolToServer);
|
||||
const paginatedResponse = await this.maybePaginate(toolName, response);
|
||||
@@ -928,6 +1042,7 @@ export class McpRouter {
|
||||
} catch {
|
||||
// If prompt retrieval fails, just ungate and route normally
|
||||
this.sessionGate.ungate(sessionId, tags, { fullContent: [], indexOnly: [], remaining: [] });
|
||||
this.queueNotification(sessionId, { jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
|
||||
return this.routeNamespacedCall(request, 'name', this.toolToServer);
|
||||
}
|
||||
}
|
||||
@@ -951,17 +1066,35 @@ export class McpRouter {
|
||||
summary: string | null;
|
||||
chapters: string[] | null;
|
||||
content?: string;
|
||||
linkTarget?: string | null;
|
||||
}>>(
|
||||
`/api/v1/projects/${encodeURIComponent(this.projectName)}/prompts/visible`,
|
||||
);
|
||||
|
||||
this.cachedPromptIndex = index.map((p) => ({
|
||||
name: p.name,
|
||||
priority: p.priority,
|
||||
summary: p.summary,
|
||||
chapters: p.chapters,
|
||||
content: p.content ?? '',
|
||||
}));
|
||||
// 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;
|
||||
}
|
||||
@@ -981,6 +1114,19 @@ export class McpRouter {
|
||||
);
|
||||
parts.push(`\n${gateInstructions}`);
|
||||
|
||||
// Append tool inventory (names only — descriptions come from tools/list after ungating)
|
||||
try {
|
||||
const tools = await this.discoverTools();
|
||||
if (tools.length > 0) {
|
||||
parts.push('\nAvailable MCP server tools (accessible after begin_session):');
|
||||
for (const t of tools) {
|
||||
parts.push(` ${t.name}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Tool discovery is optional — don't fail initialization
|
||||
}
|
||||
|
||||
// Append compact prompt index so the LLM knows what's available
|
||||
try {
|
||||
const promptIndex = await this.fetchPromptIndex();
|
||||
@@ -1036,10 +1182,27 @@ export class McpRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
this.sessionGate.removeSession(sessionId);
|
||||
this.pendingNotifications.delete(sessionId);
|
||||
}
|
||||
|
||||
getUpstreamNames(): string[] {
|
||||
|
||||
@@ -32,7 +32,7 @@ export class HttpUpstream implements UpstreamConnection {
|
||||
port: parsed.port,
|
||||
path: parsed.pathname,
|
||||
method: 'POST',
|
||||
timeout: 30000,
|
||||
timeout: 120_000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
|
||||
@@ -73,6 +73,7 @@ function setupGatedRouter(
|
||||
prompts?: typeof samplePrompts;
|
||||
withLlm?: boolean;
|
||||
llmResponse?: string;
|
||||
byteBudget?: number;
|
||||
} = {},
|
||||
): { router: McpRouter; mcpdClient: McpdClient } {
|
||||
const router = new McpRouter();
|
||||
@@ -101,6 +102,7 @@ function setupGatedRouter(
|
||||
router.setGateConfig({
|
||||
gated: opts.gated !== false,
|
||||
providerRegistry,
|
||||
byteBudget: opts.byteBudget,
|
||||
});
|
||||
|
||||
return { router, mcpdClient };
|
||||
@@ -309,16 +311,18 @@ describe('McpRouter gating', () => {
|
||||
});
|
||||
|
||||
it('filters out already-sent prompts', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
// Use a tight byte budget so begin_session only sends the top-scoring prompts
|
||||
const { router } = setupGatedRouter({ byteBudget: 80 });
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// begin_session sends common-mistakes (priority 10) and zigbee-pairing
|
||||
// begin_session with ['zigbee'] sends common-mistakes (priority 10, Inf) and
|
||||
// zigbee-pairing (7+7=14) within 80 bytes. Lower-scored prompts overflow.
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
// read_prompts for mqtt should not re-send common-mistakes
|
||||
// read_prompts for mqtt should find mqtt-config (wasn't fully sent), not re-send common-mistakes
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt'] } } },
|
||||
{ sessionId: 's1' },
|
||||
@@ -495,6 +499,121 @@ describe('McpRouter gating', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool inventory', () => {
|
||||
it('includes tool names but NOT descriptions in gated initialize instructions', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] }));
|
||||
router.addUpstream(mockUpstream('node-red', { tools: [{ name: 'get_flows', description: 'Get all flows' }] }));
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const result = res.result as { instructions: string };
|
||||
expect(result.instructions).toContain('ha/get_entities');
|
||||
expect(result.instructions).toContain('node-red/get_flows');
|
||||
expect(result.instructions).toContain('after begin_session');
|
||||
// Descriptions should NOT be in init instructions (names only)
|
||||
expect(result.instructions).not.toContain('Get all entities');
|
||||
expect(result.instructions).not.toContain('Get all flows');
|
||||
});
|
||||
|
||||
it('includes tool names but NOT descriptions in begin_session response', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] }));
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
expect(text).toContain('ha/get_entities');
|
||||
expect(text).not.toContain('Get all entities');
|
||||
});
|
||||
|
||||
it('includes retry instruction in begin_session response', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
expect(text).toContain('Proceed with');
|
||||
});
|
||||
|
||||
it('includes tool names but NOT descriptions in gated intercept briefing', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
const ha = mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] });
|
||||
router.addUpstream(ha);
|
||||
await router.discoverTools();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: {} } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const result = res.result as { content: Array<{ type: string; text: string }> };
|
||||
const briefing = result.content[0]!.text;
|
||||
expect(briefing).toContain('ha/get_entities');
|
||||
expect(briefing).not.toContain('Get all entities');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifications after ungating', () => {
|
||||
it('queues tools/list_changed after begin_session ungating', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const notifications = router.consumeNotifications('s1');
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]!.method).toBe('notifications/tools/list_changed');
|
||||
});
|
||||
|
||||
it('queues tools/list_changed after gated intercept', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
const ha = mockUpstream('ha', { tools: [{ name: 'get_entities' }] });
|
||||
router.addUpstream(ha);
|
||||
await router.discoverTools();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: {} } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const notifications = router.consumeNotifications('s1');
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]!.method).toBe('notifications/tools/list_changed');
|
||||
});
|
||||
|
||||
it('consumeNotifications clears the queue', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
// First consume returns the notification
|
||||
expect(router.consumeNotifications('s1')).toHaveLength(1);
|
||||
// Second consume returns empty
|
||||
expect(router.consumeNotifications('s1')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt index caching', () => {
|
||||
it('caches prompt index for 60 seconds', async () => {
|
||||
const { router, mcpdClient } = setupGatedRouter({ gated: false });
|
||||
@@ -517,4 +636,216 @@ describe('McpRouter gating', () => {
|
||||
expect(getCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('begin_session description field', () => {
|
||||
it('accepts description and tokenizes to keywords', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { description: 'I want to pair a zigbee device with mqtt' } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
// Should match zigbee-pairing and mqtt-config via tokenized keywords
|
||||
expect(text).toContain('zigbee-pairing');
|
||||
expect(text).toContain('mqtt-config');
|
||||
});
|
||||
|
||||
it('prefers tags over description when both provided', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['mqtt'], description: 'zigbee pairing' } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
// Tags take priority — mqtt-config should match, zigbee-pairing should not
|
||||
expect(text).toContain('mqtt-config');
|
||||
});
|
||||
|
||||
it('rejects calls with neither tags nor description', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: {} } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeDefined();
|
||||
expect(res.error!.code).toBe(-32602);
|
||||
expect(res.error!.message).toContain('tags or description');
|
||||
});
|
||||
|
||||
it('rejects empty description with no tags', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { description: ' ' } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeDefined();
|
||||
expect(res.error!.code).toBe(-32602);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate config refresh', () => {
|
||||
it('new sessions pick up gate config change (gated → ungated)', async () => {
|
||||
const { router } = setupGatedRouter({ gated: true });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
// First session is gated
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
let toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');
|
||||
|
||||
// Project config changes: gated → ungated
|
||||
router.setGateConfig({ gated: false, providerRegistry: null });
|
||||
|
||||
// New session should be ungated
|
||||
await router.route({ jsonrpc: '2.0', id: 3, method: 'initialize' }, { sessionId: 's2' });
|
||||
toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 4, method: 'tools/list' },
|
||||
{ sessionId: 's2' },
|
||||
);
|
||||
const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
expect(names).not.toContain('begin_session');
|
||||
});
|
||||
|
||||
it('new sessions pick up gate config change (ungated → gated)', async () => {
|
||||
const { router } = setupGatedRouter({ gated: false });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
// First session is ungated
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
let toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
let names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
|
||||
// Project config changes: ungated → gated
|
||||
router.setGateConfig({ gated: true, providerRegistry: null });
|
||||
|
||||
// New session should be gated
|
||||
await router.route({ jsonrpc: '2.0', id: 3, method: 'initialize' }, { sessionId: 's2' });
|
||||
toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 4, method: 'tools/list' },
|
||||
{ sessionId: 's2' },
|
||||
);
|
||||
names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
|
||||
expect(names).toHaveLength(1);
|
||||
expect(names[0]).toBe('begin_session');
|
||||
});
|
||||
|
||||
it('existing sessions retain gate state after config change', async () => {
|
||||
const { router } = setupGatedRouter({ gated: true });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
// Session created while gated
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// Config changes to ungated
|
||||
router.setGateConfig({ gated: false, providerRegistry: null });
|
||||
|
||||
// Existing session s1 should STILL be gated (session state is immutable after creation)
|
||||
const toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');
|
||||
});
|
||||
|
||||
it('already-ungated sessions remain ungated after config changes to gated', async () => {
|
||||
const { router } = setupGatedRouter({ gated: false });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
// Session created while ungated
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// Config changes to gated
|
||||
router.setGateConfig({ gated: true, providerRegistry: null });
|
||||
|
||||
// Existing session s1 should remain ungated
|
||||
const toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
expect(names).not.toContain('begin_session');
|
||||
});
|
||||
|
||||
it('config refresh does not reset sessions that ungated via begin_session', async () => {
|
||||
const { router } = setupGatedRouter({ gated: true });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
// Session starts gated and ungates
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
// Config refreshes (still gated)
|
||||
router.setGateConfig({ gated: true, providerRegistry: null });
|
||||
|
||||
// Session should remain ungated — begin_session already completed
|
||||
const toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
expect(names).not.toContain('begin_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('response size cap', () => {
|
||||
it('truncates begin_session response over 24K chars', async () => {
|
||||
// Create prompts with very large content to exceed 24K
|
||||
// Use byteBudget large enough so content is included in fullContent
|
||||
const largePrompts = [
|
||||
{ name: 'huge-prompt', priority: 10, summary: 'A very large prompt', chapters: null, content: 'x'.repeat(30_000) },
|
||||
];
|
||||
const { router } = setupGatedRouter({ prompts: largePrompts, byteBudget: 50_000 });
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['huge'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
expect(text.length).toBeLessThanOrEqual(24_000 + 100); // allow for truncation message
|
||||
expect(text).toContain('[Response truncated');
|
||||
});
|
||||
|
||||
it('does not truncate responses under 24K chars', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
const res = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
|
||||
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
expect(text).not.toContain('[Response truncated');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TagMatcher, extractKeywordsFromToolCall, type PromptIndexEntry } from '../src/gate/tag-matcher.js';
|
||||
import { TagMatcher, extractKeywordsFromToolCall, tokenizeDescription, type PromptIndexEntry } from '../src/gate/tag-matcher.js';
|
||||
|
||||
function makePrompt(overrides: Partial<PromptIndexEntry> = {}): PromptIndexEntry {
|
||||
return {
|
||||
@@ -13,22 +13,23 @@ function makePrompt(overrides: Partial<PromptIndexEntry> = {}): PromptIndexEntry
|
||||
}
|
||||
|
||||
describe('TagMatcher', () => {
|
||||
it('returns priority 10 prompts regardless of tags', () => {
|
||||
it('returns priority 10 prompts first, then others by priority', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const critical = makePrompt({ name: 'common-mistakes', priority: 10, summary: 'Unrelated stuff' });
|
||||
const normal = makePrompt({ name: 'normal', priority: 5, summary: 'Something else' });
|
||||
|
||||
const result = matcher.match([], [critical, normal]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['common-mistakes']);
|
||||
expect(result.remaining.map((p) => p.name)).toEqual(['normal']);
|
||||
// Both included — priority 10 first (Infinity), then priority 5 (baseline 5)
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['common-mistakes', 'normal']);
|
||||
expect(result.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it('scores by matching_tags * priority', () => {
|
||||
it('scores by priority baseline + matching_tags * priority', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const high = makePrompt({ name: 'important', priority: 8, summary: 'zigbee mqtt pairing' });
|
||||
const low = makePrompt({ name: 'basic', priority: 3, summary: 'zigbee basics' });
|
||||
|
||||
// Both match "zigbee": high scores 1*8=8, low scores 1*3=3
|
||||
// high: 8 + 1*8 = 16, low: 3 + 1*3 = 6
|
||||
const result = matcher.match(['zigbee'], [low, high]);
|
||||
expect(result.fullContent[0]!.name).toBe('important');
|
||||
expect(result.fullContent[1]!.name).toBe('basic');
|
||||
@@ -39,7 +40,7 @@ describe('TagMatcher', () => {
|
||||
const twoMatch = makePrompt({ name: 'two-match', priority: 5, summary: 'zigbee mqtt' });
|
||||
const oneMatch = makePrompt({ name: 'one-match', priority: 5, summary: 'zigbee only' });
|
||||
|
||||
// two-match: 2*5=10, one-match: 1*5=5
|
||||
// two-match: 5 + 2*5 = 15, one-match: 5 + 1*5 = 10
|
||||
const result = matcher.match(['zigbee', 'mqtt'], [oneMatch, twoMatch]);
|
||||
expect(result.fullContent[0]!.name).toBe('two-match');
|
||||
});
|
||||
@@ -72,24 +73,50 @@ describe('TagMatcher', () => {
|
||||
expect(result.indexOnly.map((p) => p.name)).toEqual(['big']);
|
||||
});
|
||||
|
||||
it('puts non-matched prompts in remaining', () => {
|
||||
it('includes all prompts — tag-matched ranked higher', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const matched = makePrompt({ name: 'matched', summary: 'zigbee stuff' });
|
||||
const unmatched = makePrompt({ name: 'unmatched', summary: 'completely different topic' });
|
||||
|
||||
const result = matcher.match(['zigbee'], [matched, unmatched]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['matched']);
|
||||
expect(result.remaining.map((p) => p.name)).toEqual(['unmatched']);
|
||||
// matched: 5 + 1*5 = 10, unmatched: 5 + 0 = 5 — both included, matched first
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['matched', 'unmatched']);
|
||||
expect(result.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty tags — only priority 10 matched', () => {
|
||||
it('handles empty tags — all prompts included by priority', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const critical = makePrompt({ name: 'critical', priority: 10 });
|
||||
const normal = makePrompt({ name: 'normal', priority: 5 });
|
||||
|
||||
const result = matcher.match([], [critical, normal]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['critical']);
|
||||
expect(result.remaining.map((p) => p.name)).toEqual(['normal']);
|
||||
// priority 10 → Infinity, priority 5 → baseline 5
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['critical', 'normal']);
|
||||
expect(result.remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes unrelated prompts within byte budget (priority baseline)', () => {
|
||||
const matcher = new TagMatcher(500);
|
||||
const related = makePrompt({ name: 'node-red-flows', priority: 5, summary: 'node-red flow management' });
|
||||
const unrelated = makePrompt({ name: 'stack', priority: 5, summary: 'project stack overview', content: 'Tech stack info...' });
|
||||
|
||||
// Tags match "node-red-flows" but not "stack" — both should be included
|
||||
const result = matcher.match(['node-red', 'flows'], [related, unrelated]);
|
||||
expect(result.fullContent.map((p) => p.name)).toContain('stack');
|
||||
expect(result.fullContent.map((p) => p.name)).toContain('node-red-flows');
|
||||
// Related prompt should be ranked higher
|
||||
expect(result.fullContent[0]!.name).toBe('node-red-flows');
|
||||
});
|
||||
|
||||
it('pushes low-priority unrelated prompts to indexOnly when budget is tight', () => {
|
||||
const matcher = new TagMatcher(100);
|
||||
const related = makePrompt({ name: 'related', priority: 5, summary: 'zigbee', content: 'x'.repeat(80) });
|
||||
const unrelated = makePrompt({ name: 'unrelated', priority: 3, summary: 'other', content: 'y'.repeat(80) });
|
||||
|
||||
const result = matcher.match(['zigbee'], [related, unrelated]);
|
||||
// related: 5 + 1*5 = 10 (higher score, fits budget), unrelated: 3 + 0 = 3 (overflow)
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['related']);
|
||||
expect(result.indexOnly.map((p) => p.name)).toEqual(['unrelated']);
|
||||
});
|
||||
|
||||
it('handles empty prompts array', () => {
|
||||
@@ -115,12 +142,13 @@ describe('TagMatcher', () => {
|
||||
|
||||
it('sorts matched by score descending', () => {
|
||||
const matcher = new TagMatcher();
|
||||
const p1 = makePrompt({ name: 'p1', priority: 3, summary: 'mqtt zigbee lights' }); // 3 matches * 3 = 9
|
||||
const p2 = makePrompt({ name: 'p2', priority: 8, summary: 'mqtt' }); // 1 match * 8 = 8
|
||||
const p3 = makePrompt({ name: 'p3', priority: 2, summary: 'mqtt zigbee lights pairing automation' }); // 5 * 2 = 10
|
||||
const p1 = makePrompt({ name: 'p1', priority: 3, summary: 'mqtt zigbee lights' }); // 3 + 3*3 = 12
|
||||
const p2 = makePrompt({ name: 'p2', priority: 8, summary: 'mqtt' }); // 8 + 1*8 = 16
|
||||
const p3 = makePrompt({ name: 'p3', priority: 2, summary: 'mqtt zigbee lights pairing automation' }); // 2 + 5*2 = 12
|
||||
|
||||
const result = matcher.match(['mqtt', 'zigbee', 'lights', 'pairing', 'automation'], [p1, p2, p3]);
|
||||
expect(result.fullContent.map((p) => p.name)).toEqual(['p3', 'p1', 'p2']);
|
||||
// p2 (16) > p1 (12) = p3 (12), tie-break by input order
|
||||
expect(result.fullContent[0]!.name).toBe('p2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,3 +191,67 @@ describe('extractKeywordsFromToolCall', () => {
|
||||
expect(keywords).toContain('mqtt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeDescription', () => {
|
||||
it('extracts meaningful words from a sentence', () => {
|
||||
const result = tokenizeDescription('I want to get node-red flows');
|
||||
expect(result).toContain('node-red');
|
||||
expect(result).toContain('flows');
|
||||
});
|
||||
|
||||
it('filters stop words', () => {
|
||||
const result = tokenizeDescription('I want to get the flows for my project');
|
||||
expect(result).not.toContain('want');
|
||||
expect(result).not.toContain('the');
|
||||
expect(result).not.toContain('for');
|
||||
expect(result).toContain('flows');
|
||||
expect(result).toContain('project');
|
||||
});
|
||||
|
||||
it('filters words shorter than 3 characters', () => {
|
||||
const result = tokenizeDescription('go to my HA setup');
|
||||
expect(result).not.toContain('go');
|
||||
expect(result).not.toContain('to');
|
||||
expect(result).not.toContain('my');
|
||||
expect(result).not.toContain('ha');
|
||||
expect(result).toContain('setup');
|
||||
});
|
||||
|
||||
it('lowercases all tokens', () => {
|
||||
const result = tokenizeDescription('Configure MQTT Broker Settings');
|
||||
expect(result).toContain('configure');
|
||||
expect(result).toContain('mqtt');
|
||||
expect(result).toContain('broker');
|
||||
expect(result).toContain('settings');
|
||||
});
|
||||
|
||||
it('caps at 10 keywords', () => {
|
||||
const result = tokenizeDescription(
|
||||
'alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa',
|
||||
);
|
||||
expect(result.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('deduplicates words', () => {
|
||||
const result = tokenizeDescription('zigbee zigbee zigbee pairing');
|
||||
expect(result.filter((w) => w === 'zigbee')).toHaveLength(1);
|
||||
expect(result).toContain('pairing');
|
||||
});
|
||||
|
||||
it('handles punctuation and special characters', () => {
|
||||
const result = tokenizeDescription('home-assistant; mqtt/broker (setup)');
|
||||
// Hyphens are preserved within words (compound names)
|
||||
expect(result).toContain('home-assistant');
|
||||
expect(result).toContain('mqtt');
|
||||
expect(result).toContain('broker');
|
||||
expect(result).toContain('setup');
|
||||
});
|
||||
|
||||
it('returns empty array for empty string', () => {
|
||||
expect(tokenizeDescription('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for only stop words', () => {
|
||||
expect(tokenizeDescription('I want to get the')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user