feat: mcpctl v0.0.1 — first public release
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions

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:
Michal
2026-02-27 17:05:05 +00:00
parent 414a8d3774
commit 69867bd47a
65 changed files with 5710 additions and 695 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@mcpctl/cli",
"version": "0.1.0",
"version": "0.0.1",
"private": true,
"type": "module",
"bin": {

View File

@@ -106,12 +106,19 @@ const RbacBindingSpecSchema = z.object({
const PromptSpecSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
content: z.string().min(1).max(50000),
content: z.string().min(1).max(50000).optional(),
projectId: z.string().optional(),
project: z.string().optional(),
priority: z.number().int().min(1).max(10).optional(),
link: z.string().optional(),
linkTarget: z.string().optional(),
});
const ServerAttachmentSpecSchema = z.object({
server: z.string().min(1),
project: z.string().min(1),
});
const ProjectSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
@@ -130,6 +137,7 @@ const ApplyConfigSchema = z.object({
groups: z.array(GroupSpecSchema).default([]),
projects: z.array(ProjectSpecSchema).default([]),
templates: z.array(TemplateSpecSchema).default([]),
serverattachments: z.array(ServerAttachmentSpecSchema).default([]),
rbacBindings: z.array(RbacBindingSpecSchema).default([]),
rbac: z.array(RbacBindingSpecSchema).default([]),
prompts: z.array(PromptSpecSchema).default([]),
@@ -169,6 +177,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
if (config.groups.length > 0) log(` ${config.groups.length} group(s)`);
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
if (config.templates.length > 0) log(` ${config.templates.length} template(s)`);
if (config.serverattachments.length > 0) log(` ${config.serverattachments.length} serverattachment(s)`);
if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`);
if (config.prompts.length > 0) log(` ${config.prompts.length} prompt(s)`);
return;
@@ -194,14 +203,62 @@ function readStdin(): string {
return Buffer.concat(chunks).toString('utf-8');
}
/** Map singular kind → plural resource key used by ApplyConfigSchema */
const KIND_TO_RESOURCE: Record<string, string> = {
server: 'servers',
project: 'projects',
secret: 'secrets',
template: 'templates',
user: 'users',
group: 'groups',
rbac: 'rbac',
prompt: 'prompts',
promptrequest: 'promptrequests',
serverattachment: 'serverattachments',
};
/**
* Convert multi-doc format (array of {kind, ...} items) into the grouped
* format that ApplyConfigSchema expects.
*/
function multiDocToGrouped(docs: Array<Record<string, unknown>>): Record<string, unknown[]> {
const grouped: Record<string, unknown[]> = {};
for (const doc of docs) {
const kind = doc.kind as string;
const resource = KIND_TO_RESOURCE[kind] ?? kind;
const { kind: _k, ...rest } = doc;
if (!grouped[resource]) grouped[resource] = [];
grouped[resource].push(rest);
}
return grouped;
}
function loadConfigFile(path: string): ApplyConfig {
const raw = path === '-' ? readStdin() : readFileSync(path, 'utf-8');
let parsed: unknown;
if (path === '-' ? raw.trimStart().startsWith('{') : path.endsWith('.json')) {
const isJson = path === '-' ? raw.trimStart().startsWith('{') || raw.trimStart().startsWith('[') : path.endsWith('.json');
if (isJson) {
parsed = JSON.parse(raw);
} else {
parsed = yaml.load(raw);
// Try multi-document YAML first
const docs: unknown[] = [];
yaml.loadAll(raw, (doc) => docs.push(doc));
const allDocs = docs.flatMap((d) => Array.isArray(d) ? d : [d]) as Array<Record<string, unknown>>;
if (allDocs.length > 0 && allDocs[0] != null && 'kind' in allDocs[0]) {
// Multi-doc or single doc with kind field
parsed = multiDocToGrouped(allDocs);
} else {
parsed = docs[0]; // Fall back to single-doc grouped format
}
}
// JSON: handle array of {kind, ...} docs
if (Array.isArray(parsed)) {
const arr = parsed as Array<Record<string, unknown>>;
if (arr.length > 0 && arr[0] != null && 'kind' in arr[0]) {
parsed = multiDocToGrouped(arr);
}
}
return ApplyConfigSchema.parse(parsed);
@@ -210,15 +267,59 @@ function loadConfigFile(path: string): ApplyConfig {
async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> {
// Apply order: secrets, servers, users, groups, projects, templates, rbacBindings
// Cache for name→record lookups to avoid repeated API calls (rate limit protection)
const nameCache = new Map<string, Map<string, { id: string; [key: string]: unknown }>>();
async function cachedFindByName(resource: string, name: string): Promise<{ id: string; [key: string]: unknown } | null> {
if (!nameCache.has(resource)) {
try {
const items = await client.get<Array<{ id: string; name: string }>>(`/api/v1/${resource}`);
const map = new Map<string, { id: string; [key: string]: unknown }>();
for (const item of items) {
if (item.name) map.set(item.name, item);
}
nameCache.set(resource, map);
} catch {
nameCache.set(resource, new Map());
}
}
return nameCache.get(resource)!.get(name) ?? null;
}
/** Invalidate a resource cache after a create/update so subsequent lookups see it */
function invalidateCache(resource: string): void {
nameCache.delete(resource);
}
/** Retry a function on 429 rate-limit errors with exponential backoff */
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (attempt < maxRetries && msg.includes('429')) {
const delay = 2000 * Math.pow(2, attempt); // 2s, 4s, 8s, 16s, 32s
process.stderr.write(`\r\x1b[33mRate limited, retrying in ${delay / 1000}s...\x1b[0m`);
await new Promise((r) => setTimeout(r, delay));
process.stderr.write('\r\x1b[K'); // clear the line
continue;
}
throw err;
}
}
}
// Apply secrets
for (const secret of config.secrets) {
try {
const existing = await findByName(client, 'secrets', secret.name);
const existing = await cachedFindByName('secrets', secret.name);
if (existing) {
await client.put(`/api/v1/secrets/${(existing as { id: string }).id}`, { data: secret.data });
await withRetry(() => client.put(`/api/v1/secrets/${existing.id}`, { data: secret.data }));
log(`Updated secret: ${secret.name}`);
} else {
await client.post('/api/v1/secrets', secret);
await withRetry(() => client.post('/api/v1/secrets', secret));
invalidateCache('secrets');
log(`Created secret: ${secret.name}`);
}
} catch (err) {
@@ -229,12 +330,13 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
// Apply servers
for (const server of config.servers) {
try {
const existing = await findByName(client, 'servers', server.name);
const existing = await cachedFindByName('servers', server.name);
if (existing) {
await client.put(`/api/v1/servers/${(existing as { id: string }).id}`, server);
await withRetry(() => client.put(`/api/v1/servers/${existing.id}`, server));
log(`Updated server: ${server.name}`);
} else {
await client.post('/api/v1/servers', server);
await withRetry(() => client.post('/api/v1/servers', server));
invalidateCache('servers');
log(`Created server: ${server.name}`);
}
} catch (err) {
@@ -245,12 +347,13 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
// Apply users (matched by email)
for (const user of config.users) {
try {
// Users use email, not name — use uncached findByField
const existing = await findByField(client, 'users', 'email', user.email);
if (existing) {
await client.put(`/api/v1/users/${(existing as { id: string }).id}`, user);
await withRetry(() => client.put(`/api/v1/users/${(existing as { id: string }).id}`, user));
log(`Updated user: ${user.email}`);
} else {
await client.post('/api/v1/users', user);
await withRetry(() => client.post('/api/v1/users', user));
log(`Created user: ${user.email}`);
}
} catch (err) {
@@ -261,12 +364,13 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
// Apply groups
for (const group of config.groups) {
try {
const existing = await findByName(client, 'groups', group.name);
const existing = await cachedFindByName('groups', group.name);
if (existing) {
await client.put(`/api/v1/groups/${(existing as { id: string }).id}`, group);
await withRetry(() => client.put(`/api/v1/groups/${existing.id}`, group));
log(`Updated group: ${group.name}`);
} else {
await client.post('/api/v1/groups', group);
await withRetry(() => client.post('/api/v1/groups', group));
invalidateCache('groups');
log(`Created group: ${group.name}`);
}
} catch (err) {
@@ -277,12 +381,13 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
// Apply projects (send full spec including servers)
for (const project of config.projects) {
try {
const existing = await findByName(client, 'projects', project.name);
const existing = await cachedFindByName('projects', project.name);
if (existing) {
await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, project);
await withRetry(() => client.put(`/api/v1/projects/${existing.id}`, project));
log(`Updated project: ${project.name}`);
} else {
await client.post('/api/v1/projects', project);
await withRetry(() => client.post('/api/v1/projects', project));
invalidateCache('projects');
log(`Created project: ${project.name}`);
}
} catch (err) {
@@ -293,12 +398,13 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
// Apply templates
for (const template of config.templates) {
try {
const existing = await findByName(client, 'templates', template.name);
const existing = await cachedFindByName('templates', template.name);
if (existing) {
await client.put(`/api/v1/templates/${(existing as { id: string }).id}`, template);
await withRetry(() => client.put(`/api/v1/templates/${existing.id}`, template));
log(`Updated template: ${template.name}`);
} else {
await client.post('/api/v1/templates', template);
await withRetry(() => client.post('/api/v1/templates', template));
invalidateCache('templates');
log(`Created template: ${template.name}`);
}
} catch (err) {
@@ -306,15 +412,37 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
}
}
// Apply server attachments (after projects and servers exist)
for (const sa of config.serverattachments) {
try {
const project = await cachedFindByName('projects', sa.project);
if (!project) {
log(`Error applying serverattachment: project '${sa.project}' not found`);
continue;
}
await withRetry(() => client.post(`/api/v1/projects/${project.id}/servers`, { server: sa.server }));
log(`Attached server '${sa.server}' to project '${sa.project}'`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Ignore "already attached" conflicts silently
if (msg.includes('409') || msg.includes('already')) {
log(`Server '${sa.server}' already attached to project '${sa.project}'`);
} else {
log(`Error applying serverattachment '${sa.project}/${sa.server}': ${msg}`);
}
}
}
// Apply RBAC bindings
for (const rbacBinding of config.rbacBindings) {
try {
const existing = await findByName(client, 'rbac', rbacBinding.name);
const existing = await cachedFindByName('rbac', rbacBinding.name);
if (existing) {
await client.put(`/api/v1/rbac/${(existing as { id: string }).id}`, rbacBinding);
await withRetry(() => client.put(`/api/v1/rbac/${existing.id}`, rbacBinding));
log(`Updated rbacBinding: ${rbacBinding.name}`);
} else {
await client.post('/api/v1/rbac', rbacBinding);
await withRetry(() => client.post('/api/v1/rbac', rbacBinding));
invalidateCache('rbac');
log(`Created rbacBinding: ${rbacBinding.name}`);
}
} catch (err) {
@@ -322,17 +450,77 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
}
}
// Apply prompts
// Apply prompts — project-scoped: same name in different projects are distinct resources.
// Cache project-scoped prompt lookups separately from global cache.
const promptProjectIds = new Map<string, string>();
const projectPromptCache = new Map<string, Map<string, { id: string; [key: string]: unknown }>>();
async function findPromptInProject(name: string, projectId: string | undefined): Promise<{ id: string; [key: string]: unknown } | null> {
// Global prompts (no project) — use standard cache
if (!projectId) {
return cachedFindByName('prompts', name);
}
// Project-scoped: query prompts filtered by projectId
if (!projectPromptCache.has(projectId)) {
try {
const items = await client.get<Array<{ id: string; name: string; projectId?: string }>>(`/api/v1/prompts?projectId=${projectId}`);
const map = new Map<string, { id: string; [key: string]: unknown }>();
for (const item of items) {
if (item.name) map.set(item.name, item);
}
projectPromptCache.set(projectId, map);
} catch {
projectPromptCache.set(projectId, new Map());
}
}
return projectPromptCache.get(projectId)!.get(name) ?? null;
}
for (const prompt of config.prompts) {
try {
const existing = await findByName(client, 'prompts', prompt.name);
// Resolve project name → projectId if needed
let projectId = prompt.projectId;
if (!projectId && prompt.project) {
if (promptProjectIds.has(prompt.project)) {
projectId = promptProjectIds.get(prompt.project)!;
} else {
const proj = await cachedFindByName('projects', prompt.project);
if (!proj) {
log(`Error applying prompt '${prompt.name}': project '${prompt.project}' not found`);
continue;
}
projectId = proj.id;
promptProjectIds.set(prompt.project, projectId);
}
}
// Normalize: accept both `link` and `linkTarget`, prefer `link`
const linkTarget = prompt.link ?? prompt.linkTarget;
// Linked prompts use placeholder content if none provided
const content = prompt.content ?? (linkTarget ? `Linked prompt — content fetched from ${linkTarget}` : '');
if (!content) {
log(`Error applying prompt '${prompt.name}': content is required (or provide link)`);
continue;
}
// Build API body (strip the `project` name field, use projectId)
const body: Record<string, unknown> = { name: prompt.name, content };
if (projectId) body.projectId = projectId;
if (prompt.priority !== undefined) body.priority = prompt.priority;
if (linkTarget) body.linkTarget = linkTarget;
const existing = await findPromptInProject(prompt.name, projectId);
if (existing) {
const updateData: Record<string, unknown> = { content: prompt.content };
const updateData: Record<string, unknown> = { content };
if (projectId) updateData.projectId = projectId;
if (prompt.priority !== undefined) updateData.priority = prompt.priority;
await client.put(`/api/v1/prompts/${(existing as { id: string }).id}`, updateData);
if (linkTarget) updateData.linkTarget = linkTarget;
await withRetry(() => client.put(`/api/v1/prompts/${existing.id}`, updateData));
log(`Updated prompt: ${prompt.name}`);
} else {
await client.post('/api/v1/prompts', prompt);
await withRetry(() => client.post('/api/v1/prompts', body));
projectPromptCache.delete(projectId ?? '');
log(`Created prompt: ${prompt.name}`);
}
} catch (err) {
@@ -341,15 +529,6 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
}
}
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {
try {
const items = await client.get<Array<{ name: string }>>(`/api/v1/${resource}`);
return items.find((item) => item.name === name) ?? null;
} catch {
return null;
}
}
async function findByField<T extends string>(client: ApiClient, resource: string, field: T, value: string): Promise<unknown | null> {
try {
const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}`);

View File

@@ -90,39 +90,51 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
const cmd = config
.command(name)
.description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge')
.requiredOption('--project <name>', 'Project name')
.option('--project <name>', 'Project name')
.option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
.option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring')
.option('--stdout', 'Print to stdout instead of writing a file')
.action((opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
const mcpConfig: McpConfig = {
mcpServers: {
[opts.project]: {
command: 'mcpctl',
args: ['mcp', '-p', opts.project],
},
},
};
.action((opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean }) => {
if (!opts.project && !opts.inspect) {
log('Error: at least one of --project or --inspect is required');
process.exitCode = 1;
return;
}
const servers: McpConfig['mcpServers'] = {};
if (opts.project) {
servers[opts.project] = {
command: 'mcpctl',
args: ['mcp', '-p', opts.project],
};
}
if (opts.inspect) {
servers['mcpctl-inspect'] = {
command: 'mcpctl',
args: ['console', '--inspect', '--stdin-mcp'],
};
}
if (opts.stdout) {
log(JSON.stringify(mcpConfig, null, 2));
log(JSON.stringify({ mcpServers: servers }, null, 2));
return;
}
const outputPath = resolve(opts.output);
let finalConfig = mcpConfig;
let finalConfig: McpConfig = { mcpServers: servers };
if (opts.merge && existsSync(outputPath)) {
// Always merge with existing .mcp.json — never overwrite other servers
if (existsSync(outputPath)) {
try {
const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig;
finalConfig = {
mcpServers: {
...existing.mcpServers,
...mcpConfig.mcpServers,
...servers,
},
};
} catch {
// If existing file is invalid, just overwrite
// If existing file is invalid, start fresh
}
}

View File

@@ -9,8 +9,10 @@ export interface ConsoleCommandDeps {
export function createConsoleCommand(deps: ConsoleCommandDeps): Command {
const cmd = new Command('console')
.description('Interactive MCP console — see what an LLM sees when attached to a project')
.argument('<project>', 'Project name to connect to')
.action(async (projectName: string) => {
.argument('[project]', 'Project name to connect to')
.option('--inspect', 'Passive traffic inspector — observe other clients\' MCP traffic')
.option('--stdin-mcp', 'Run inspector as MCP server over stdin/stdout (for Claude)')
.action(async (projectName: string | undefined, opts: { inspect?: boolean; stdinMcp?: boolean }) => {
let mcplocalUrl = 'http://localhost:3200';
if (deps.configLoader) {
mcplocalUrl = deps.configLoader().mcplocalUrl;
@@ -23,6 +25,28 @@ export function createConsoleCommand(deps: ConsoleCommandDeps): Command {
}
}
// --inspect --stdin-mcp: MCP server for Claude
if (opts.inspect && opts.stdinMcp) {
const { runInspectMcp } = await import('./inspect-mcp.js');
await runInspectMcp(mcplocalUrl);
return;
}
// --inspect: TUI traffic inspector
if (opts.inspect) {
const { renderInspect } = await import('./inspect-app.js');
await renderInspect({ mcplocalUrl, projectFilter: projectName });
return;
}
// Regular interactive console — requires project name
if (!projectName) {
console.error('Error: project name is required for interactive console mode.');
console.error('Usage: mcpctl console <project>');
console.error(' mcpctl console --inspect [project]');
process.exit(1);
}
let token: string | undefined;
if (deps.credentialsLoader) {
token = deps.credentialsLoader()?.token;

View File

@@ -0,0 +1,825 @@
/**
* Inspector TUI — passive MCP traffic sniffer.
*
* Connects to mcplocal's /inspect SSE endpoint and displays
* live traffic per project/session with color coding.
*
* Keys:
* s toggle sidebar
* j/k navigate events
* Enter expand/collapse event detail
* Esc close detail / deselect
* ↑/↓ select session (when sidebar visible)
* a all sessions
* c clear traffic
* q quit
*/
import { useState, useEffect, useRef } from 'react';
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
import type { IncomingMessage } from 'node:http';
import { request as httpRequest } from 'node:http';
// ── Types matching mcplocal's TrafficEvent ──
interface TrafficEvent {
timestamp: string;
projectName: string;
sessionId: string;
eventType: string;
method?: string;
upstreamName?: string;
body: unknown;
durationMs?: number;
}
interface ActiveSession {
sessionId: string;
projectName: string;
startedAt: string;
}
// ── SSE Client ──
function connectSSE(
url: string,
opts: {
onSessions: (sessions: ActiveSession[]) => void;
onEvent: (event: TrafficEvent) => void;
onLive: () => void;
onError: (err: string) => void;
},
): () => void {
let aborted = false;
const parsed = new URL(url);
const req = httpRequest(
{
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
headers: { Accept: 'text/event-stream' },
},
(res: IncomingMessage) => {
let buffer = '';
let currentEventType = 'message';
res.setEncoding('utf-8');
res.on('data', (chunk: string) => {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop()!; // Keep incomplete line
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
const data = line.slice(6);
try {
const parsed = JSON.parse(data);
if (currentEventType === 'sessions') {
opts.onSessions(parsed as ActiveSession[]);
} else if (currentEventType === 'live') {
opts.onLive();
} else {
opts.onEvent(parsed as TrafficEvent);
}
} catch {
// Ignore unparseable data
}
currentEventType = 'message';
}
// Ignore comments (: keepalive) and blank lines
}
});
res.on('end', () => {
if (!aborted) opts.onError('SSE connection closed');
});
res.on('error', (err) => {
if (!aborted) opts.onError(err.message);
});
},
);
req.on('error', (err) => {
if (!aborted) opts.onError(err.message);
});
req.end();
return () => {
aborted = true;
req.destroy();
};
}
// ── Formatting helpers ──
/** Safely dig into unknown objects */
function dig(obj: unknown, ...keys: string[]): unknown {
let cur = obj;
for (const k of keys) {
if (cur === null || cur === undefined || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[k];
}
return cur;
}
function trunc(s: string, maxLen: number): string {
return s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s;
}
function nameList(items: unknown[], key: string, max: number): string {
if (items.length === 0) return '(none)';
const names = items.map((it) => dig(it, key) as string).filter(Boolean);
const shown = names.slice(0, max);
const rest = names.length - shown.length;
return shown.join(', ') + (rest > 0 ? ` +${rest} more` : '');
}
/** Extract meaningful summary from request params (strips jsonrpc/id boilerplate) */
function summarizeRequest(method: string, body: unknown): string {
const params = dig(body, 'params') as Record<string, unknown> | undefined;
switch (method) {
case 'initialize': {
const name = dig(params, 'clientInfo', 'name') ?? '?';
const ver = dig(params, 'clientInfo', 'version') ?? '';
const proto = dig(params, 'protocolVersion') ?? '';
return `client=${name}${ver ? ` v${ver}` : ''} proto=${proto}`;
}
case 'tools/call': {
const toolName = dig(params, 'name') as string ?? '?';
const args = dig(params, 'arguments') as Record<string, unknown> | undefined;
if (!args || Object.keys(args).length === 0) return `${toolName}()`;
const pairs = Object.entries(args).map(([k, v]) => {
const vs = typeof v === 'string' ? v : JSON.stringify(v);
return `${k}: ${trunc(vs, 40)}`;
});
return `${toolName}(${trunc(pairs.join(', '), 80)})`;
}
case 'resources/read': {
const uri = dig(params, 'uri') as string ?? '';
return uri;
}
case 'prompts/get': {
const name = dig(params, 'name') as string ?? '';
return name;
}
case 'tools/list':
case 'resources/list':
case 'prompts/list':
case 'notifications/initialized':
return '';
default: {
if (!params || Object.keys(params).length === 0) return '';
const s = JSON.stringify(params);
return trunc(s, 80);
}
}
}
/** Extract meaningful summary from response result */
function summarizeResponse(method: string, body: unknown): string {
const error = dig(body, 'error') as { message?: string; code?: number } | undefined;
if (error) {
return `ERROR ${error.code ?? ''}: ${error.message ?? 'unknown'}`;
}
const result = dig(body, 'result') as Record<string, unknown> | undefined;
if (!result) return '';
switch (method) {
case 'initialize': {
const name = dig(result, 'serverInfo', 'name') ?? '?';
const ver = dig(result, 'serverInfo', 'version') ?? '';
const caps = dig(result, 'capabilities') as Record<string, unknown> | undefined;
const capList = caps ? Object.keys(caps).filter((k) => caps[k] && Object.keys(caps[k] as object).length > 0) : [];
return `server=${name}${ver ? ` v${ver}` : ''}${capList.length ? ` caps=[${capList.join(',')}]` : ''}`;
}
case 'tools/list': {
const tools = (result.tools ?? []) as unknown[];
return `${tools.length} tools: ${nameList(tools, 'name', 6)}`;
}
case 'resources/list': {
const resources = (result.resources ?? []) as unknown[];
return `${resources.length} resources: ${nameList(resources, 'name', 6)}`;
}
case 'prompts/list': {
const prompts = (result.prompts ?? []) as unknown[];
if (prompts.length === 0) return '0 prompts';
return `${prompts.length} prompts: ${nameList(prompts, 'name', 6)}`;
}
case 'tools/call': {
const content = (result.content ?? []) as unknown[];
const isError = result.isError;
const first = content[0];
const text = (dig(first, 'text') as string) ?? '';
const prefix = isError ? 'ERROR: ' : '';
if (text) return prefix + trunc(text.replace(/\n/g, ' '), 100);
return prefix + `${content.length} content block(s)`;
}
case 'resources/read': {
const contents = (result.contents ?? []) as unknown[];
const first = contents[0];
const text = (dig(first, 'text') as string) ?? '';
if (text) return trunc(text.replace(/\n/g, ' '), 80);
return `${contents.length} content block(s)`;
}
case 'notifications/initialized':
return 'ok';
default: {
if (Object.keys(result).length === 0) return 'ok';
const s = JSON.stringify(result);
return trunc(s, 80);
}
}
}
/** Format full event body for expanded detail view (multi-line, readable) */
function formatBodyDetail(event: TrafficEvent): string[] {
const body = event.body as Record<string, unknown> | null;
if (!body) return ['(no body)'];
const lines: string[] = [];
const method = event.method ?? '';
// Strip jsonrpc envelope — show meaningful content only
if (event.eventType.includes('request') || event.eventType === 'client_notification') {
const params = body['params'] as Record<string, unknown> | undefined;
if (method === 'tools/call' && params) {
lines.push(`Tool: ${params['name'] as string}`);
const args = params['arguments'] as Record<string, unknown> | undefined;
if (args && Object.keys(args).length > 0) {
lines.push('Arguments:');
for (const [k, v] of Object.entries(args)) {
const vs = typeof v === 'string' ? v : JSON.stringify(v, null, 2);
for (const vl of vs.split('\n')) {
lines.push(` ${k}: ${vl}`);
}
}
}
} else if (method === 'initialize' && params) {
const ci = params['clientInfo'] as Record<string, unknown> | undefined;
lines.push(`Client: ${ci?.['name'] ?? '?'} v${ci?.['version'] ?? '?'}`);
lines.push(`Protocol: ${params['protocolVersion'] ?? '?'}`);
const caps = params['capabilities'] as Record<string, unknown> | undefined;
if (caps) lines.push(`Capabilities: ${JSON.stringify(caps)}`);
} else if (params && Object.keys(params).length > 0) {
for (const l of JSON.stringify(params, null, 2).split('\n')) {
lines.push(l);
}
} else {
lines.push('(empty params)');
}
} else if (event.eventType.includes('response')) {
const error = body['error'] as Record<string, unknown> | undefined;
if (error) {
lines.push(`Error ${error['code']}: ${error['message']}`);
if (error['data']) {
for (const l of JSON.stringify(error['data'], null, 2).split('\n')) {
lines.push(` ${l}`);
}
}
} else {
const result = body['result'] as Record<string, unknown> | undefined;
if (!result) {
lines.push('(empty result)');
} else if (method === 'tools/list') {
const tools = (result['tools'] ?? []) as Array<{ name: string; description?: string }>;
lines.push(`${tools.length} tools:`);
for (const t of tools) {
lines.push(` ${t.name}${t.description ? `${trunc(t.description, 60)}` : ''}`);
}
} else if (method === 'resources/list') {
const resources = (result['resources'] ?? []) as Array<{ name: string; uri?: string; description?: string }>;
lines.push(`${resources.length} resources:`);
for (const r of resources) {
lines.push(` ${r.name}${r.uri ? ` (${r.uri})` : ''}${r.description ? `${trunc(r.description, 50)}` : ''}`);
}
} else if (method === 'prompts/list') {
const prompts = (result['prompts'] ?? []) as Array<{ name: string; description?: string }>;
lines.push(`${prompts.length} prompts:`);
for (const p of prompts) {
lines.push(` ${p.name}${p.description ? `${trunc(p.description, 60)}` : ''}`);
}
} else if (method === 'tools/call') {
const isErr = result['isError'];
const content = (result['content'] ?? []) as Array<{ type?: string; text?: string }>;
if (isErr) lines.push('(error response)');
for (const c of content) {
if (c.text) {
for (const l of c.text.split('\n')) {
lines.push(l);
}
} else {
lines.push(`[${c.type ?? 'unknown'} content]`);
}
}
} else if (method === 'initialize') {
const si = result['serverInfo'] as Record<string, unknown> | undefined;
lines.push(`Server: ${si?.['name'] ?? '?'} v${si?.['version'] ?? '?'}`);
lines.push(`Protocol: ${result['protocolVersion'] ?? '?'}`);
const caps = result['capabilities'] as Record<string, unknown> | undefined;
if (caps) {
lines.push('Capabilities:');
for (const [k, v] of Object.entries(caps)) {
if (v && typeof v === 'object' && Object.keys(v).length > 0) {
lines.push(` ${k}: ${JSON.stringify(v)}`);
}
}
}
const instructions = result['instructions'] as string | undefined;
if (instructions) {
lines.push('');
lines.push('Instructions:');
for (const l of instructions.split('\n')) {
lines.push(` ${l}`);
}
}
} else {
for (const l of JSON.stringify(result, null, 2).split('\n')) {
lines.push(l);
}
}
}
} else {
// Lifecycle events
for (const l of JSON.stringify(body, null, 2).split('\n')) {
lines.push(l);
}
}
return lines;
}
interface FormattedEvent {
arrow: string;
color: string;
label: string;
detail: string;
detailColor?: string | undefined;
}
function formatEvent(event: TrafficEvent): FormattedEvent {
const method = event.method ?? '';
switch (event.eventType) {
case 'client_request':
return { arrow: '→', color: 'green', label: method, detail: summarizeRequest(method, event.body) };
case 'client_response': {
const detail = summarizeResponse(method, event.body);
const hasError = detail.startsWith('ERROR');
return { arrow: '←', color: 'blue', label: method, detail, detailColor: hasError ? 'red' : undefined };
}
case 'client_notification':
return { arrow: '◂', color: 'magenta', label: method, detail: summarizeRequest(method, event.body) };
case 'upstream_request':
return { arrow: ' ⇢', color: 'yellowBright', label: `${event.upstreamName ?? '?'}/${method}`, detail: summarizeRequest(method, event.body) };
case 'upstream_response': {
const ms = event.durationMs !== undefined ? `${event.durationMs}ms` : '';
const detail = summarizeResponse(method, event.body);
const hasError = detail.startsWith('ERROR');
return { arrow: ' ⇠', color: 'yellowBright', label: `${event.upstreamName ?? '?'}/${method}`, detail: ms ? `[${ms}] ${detail}` : detail, detailColor: hasError ? 'red' : undefined };
}
case 'session_created':
return { arrow: '●', color: 'cyan', label: `session ${event.sessionId.slice(0, 8)}`, detail: `project=${event.projectName}` };
case 'session_closed':
return { arrow: '○', color: 'red', label: `session ${event.sessionId.slice(0, 8)}`, detail: 'closed' };
default:
return { arrow: '?', color: 'white', label: event.eventType, detail: '' };
}
}
function formatTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return '??:??:??';
}
}
// ── Session Sidebar ──
function SessionList({ sessions, selected, eventCounts }: {
sessions: ActiveSession[];
selected: number;
eventCounts: Map<string, number>;
}) {
return (
<Box flexDirection="column" width={32} borderStyle="round" borderColor="gray" paddingX={1}>
<Text bold color="cyan">
{' '}Sessions{' '}
<Text dimColor>({sessions.length})</Text>
</Text>
<Box marginTop={0}>
<Text color={selected === -1 ? 'cyan' : undefined} bold={selected === -1}>
{selected === -1 ? ' ▸ ' : ' '}
<Text>all sessions</Text>
</Text>
</Box>
{sessions.length === 0 && (
<Box marginTop={1}>
<Text dimColor> waiting for connections</Text>
</Box>
)}
{sessions.map((s, i) => {
const count = eventCounts.get(s.sessionId) ?? 0;
return (
<Box key={s.sessionId} flexDirection="column">
<Text wrap="truncate">
<Text color={i === selected ? 'cyan' : undefined} bold={i === selected}>
{i === selected ? ' ▸ ' : ' '}
{s.projectName}
</Text>
</Text>
<Text wrap="truncate" dimColor>
{' '}
{s.sessionId.slice(0, 8)}
{count > 0 ? ` · ${count} events` : ''}
</Text>
</Box>
);
})}
<Box flexGrow={1} />
<Box borderStyle="single" borderTop borderColor="gray" paddingTop={0}>
<Text dimColor>
{'[↑↓] session [a] all\n[s] sidebar [c] clear\n[j/k] event [⏎] expand\n[q] quit'}
</Text>
</Box>
</Box>
);
}
// ── Traffic Log ──
function TrafficLog({ events, height, showProject, focusedIdx }: {
events: TrafficEvent[];
height: number;
showProject: boolean;
focusedIdx: number; // -1 = no focus (auto-scroll to bottom)
}) {
// When focusedIdx >= 0, center the focused event in the view
// When focusedIdx === -1, show the latest events (auto-scroll)
const maxVisible = height - 2;
let startIdx: number;
if (focusedIdx >= 0) {
// Center focused event, but clamp to valid range
startIdx = Math.max(0, Math.min(focusedIdx - Math.floor(maxVisible / 2), events.length - maxVisible));
} else {
startIdx = Math.max(0, events.length - maxVisible);
}
const visible = events.slice(startIdx, startIdx + maxVisible);
const visibleBaseIdx = startIdx;
return (
<Box flexDirection="column" flexGrow={1} paddingLeft={1}>
<Text bold>
Traffic <Text dimColor>({events.length} events{focusedIdx >= 0 ? ` · #${focusedIdx + 1} selected` : ''})</Text>
</Text>
{visible.length === 0 && (
<Box marginTop={1}>
<Text dimColor> waiting for traffic</Text>
</Box>
)}
{visible.map((event, vi) => {
const absIdx = visibleBaseIdx + vi;
const isFocused = absIdx === focusedIdx;
const { arrow, color, label, detail, detailColor } = formatEvent(event);
const isUpstream = event.eventType.startsWith('upstream_');
const isLifecycle = event.eventType === 'session_created' || event.eventType === 'session_closed';
const marker = isFocused ? '▸' : ' ';
if (isLifecycle) {
return (
<Text key={vi} wrap="truncate">
<Text color={isFocused ? 'cyan' : undefined}>{marker}</Text>
<Text dimColor>{formatTime(event.timestamp)} </Text>
<Text color={color} bold>{arrow} {label}</Text>
<Text dimColor> {detail}</Text>
</Text>
);
}
return (
<Text key={vi} wrap="truncate">
<Text color={isFocused ? 'cyan' : undefined}>{marker}</Text>
<Text dimColor>{formatTime(event.timestamp)} </Text>
{showProject && <Text color="gray">[{trunc(event.projectName, 12)}] </Text>}
<Text color={color}>{arrow} </Text>
<Text bold={!isUpstream} color={color}>{label}</Text>
{detail ? (
<Text color={detailColor} dimColor={!detailColor}> {detail}</Text>
) : null}
</Text>
);
})}
</Box>
);
}
// ── Detail Pane ──
function DetailPane({ event, maxLines, scrollOffset }: {
event: TrafficEvent;
maxLines: number;
scrollOffset: number;
}) {
const { arrow, color, label } = formatEvent(event);
const allLines = formatBodyDetail(event);
const bodyHeight = maxLines - 3; // header + border
const visibleLines = allLines.slice(scrollOffset, scrollOffset + bodyHeight);
const totalLines = allLines.length;
const canScroll = totalLines > bodyHeight;
const atEnd = scrollOffset + bodyHeight >= totalLines;
return (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} height={maxLines}>
<Text bold>
<Text color={color}>{arrow} {label}</Text>
<Text dimColor> {formatTime(event.timestamp)} {event.projectName}/{event.sessionId.slice(0, 8)}</Text>
{canScroll ? (
<Text dimColor> [{scrollOffset + 1}-{Math.min(scrollOffset + bodyHeight, totalLines)}/{totalLines}] scroll Esc close</Text>
) : (
<Text dimColor> Esc to close</Text>
)}
</Text>
{visibleLines.map((line, i) => (
<Text key={i} wrap="truncate" dimColor={line.startsWith(' ')}>
{line}
</Text>
))}
{canScroll && !atEnd && (
<Text dimColor> +{totalLines - scrollOffset - bodyHeight} more lines </Text>
)}
</Box>
);
}
// ── Root App ──
interface InspectAppProps {
inspectUrl: string;
projectFilter?: string;
}
function InspectApp({ inspectUrl, projectFilter }: InspectAppProps) {
const { exit } = useApp();
const { stdout } = useStdout();
const termHeight = stdout?.rows ?? 24;
const [sessions, setSessions] = useState<ActiveSession[]>([]);
const [events, setEvents] = useState<TrafficEvent[]>([]);
const [selectedSession, setSelectedSession] = useState(-1); // -1 = all
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showSidebar, setShowSidebar] = useState(true);
const [focusedEvent, setFocusedEvent] = useState(-1); // -1 = auto-scroll
const [expandedEvent, setExpandedEvent] = useState(false);
const [detailScroll, setDetailScroll] = useState(0);
// Track latest event count for auto-follow
const prevCountRef = useRef(0);
useEffect(() => {
const url = new URL(inspectUrl);
if (projectFilter) url.searchParams.set('project', projectFilter);
const disconnect = connectSSE(url.toString(), {
onSessions: (s) => setSessions(s),
onEvent: (e) => {
setEvents((prev) => [...prev, e]);
// Auto-add new sessions we haven't seen
if (e.eventType === 'session_created') {
setSessions((prev) => {
if (prev.some((s) => s.sessionId === e.sessionId)) return prev;
return [...prev, { sessionId: e.sessionId, projectName: e.projectName, startedAt: e.timestamp }];
});
}
if (e.eventType === 'session_closed') {
setSessions((prev) => prev.filter((s) => s.sessionId !== e.sessionId));
}
},
onLive: () => setConnected(true),
onError: (msg) => setError(msg),
});
return disconnect;
}, [inspectUrl, projectFilter]);
// Filter events by selected session
const filteredEvents = selectedSession === -1
? events
: events.filter((e) => e.sessionId === sessions[selectedSession]?.sessionId);
// Auto-follow: when new events arrive and we're not browsing, stay at bottom
useEffect(() => {
if (focusedEvent === -1 && filteredEvents.length > prevCountRef.current) {
// Auto-scrolling (focusedEvent === -1 means "follow tail")
}
prevCountRef.current = filteredEvents.length;
}, [filteredEvents.length, focusedEvent]);
// Event counts per session
const eventCounts = new Map<string, number>();
for (const e of events) {
eventCounts.set(e.sessionId, (eventCounts.get(e.sessionId) ?? 0) + 1);
}
const showProject = selectedSession === -1 && sessions.length > 1;
// Keyboard
useInput((input, key) => {
if (input === 'q') {
exit();
return;
}
// When detail pane is expanded, arrows scroll the detail content
if (expandedEvent && focusedEvent >= 0) {
if (key.escape) {
setExpandedEvent(false);
setDetailScroll(0);
return;
}
if (key.downArrow || input === 'j') {
setDetailScroll((s) => s + 1);
return;
}
if (key.upArrow || input === 'k') {
setDetailScroll((s) => Math.max(0, s - 1));
return;
}
// Enter: close detail
if (key.return) {
setExpandedEvent(false);
setDetailScroll(0);
return;
}
// q still quits even in detail mode
return;
}
// Esc: deselect event
if (key.escape) {
if (focusedEvent >= 0) {
setFocusedEvent(-1);
}
return;
}
// Enter: open detail pane for focused event
if (key.return && focusedEvent >= 0 && focusedEvent < filteredEvents.length) {
setExpandedEvent(true);
setDetailScroll(0);
return;
}
// s: toggle sidebar
if (input === 's') {
setShowSidebar((prev) => !prev);
return;
}
// a: all sessions
if (input === 'a') {
setSelectedSession(-1);
setFocusedEvent(-1);
setExpandedEvent(false);
setDetailScroll(0);
return;
}
// c: clear
if (input === 'c') {
setEvents([]);
setFocusedEvent(-1);
setExpandedEvent(false);
setDetailScroll(0);
return;
}
// j/k or arrow keys: navigate events
if (input === 'j' || key.downArrow) {
if (key.downArrow && showSidebar && focusedEvent < 0) {
// Arrow keys control session selection when sidebar visible and no event focused
setSelectedSession((s) => Math.min(sessions.length - 1, s + 1));
} else {
// j always controls event navigation, down-arrow too when event is focused
setFocusedEvent((prev) => {
const next = prev + 1;
return next >= filteredEvents.length ? filteredEvents.length - 1 : next;
});
setExpandedEvent(false);
}
return;
}
if (input === 'k' || key.upArrow) {
if (key.upArrow && showSidebar && focusedEvent < 0) {
setSelectedSession((s) => Math.max(-1, s - 1));
} else {
setFocusedEvent((prev) => {
if (prev <= 0) return -1; // Back to auto-scroll
return prev - 1;
});
setExpandedEvent(false);
}
return;
}
// G: jump to latest (end)
if (input === 'G') {
setFocusedEvent(-1);
setExpandedEvent(false);
setDetailScroll(0);
return;
}
});
// Layout calculations
const headerHeight = 1;
const footerHeight = 1;
// Detail pane takes up to half the screen
const detailHeight = expandedEvent && focusedEvent >= 0 ? Math.max(6, Math.floor(termHeight * 0.45)) : 0;
const contentHeight = termHeight - headerHeight - footerHeight - detailHeight;
const focusedEventObj = focusedEvent >= 0 ? filteredEvents[focusedEvent] : undefined;
return (
<Box flexDirection="column" height={termHeight}>
{/* ── Header ── */}
<Box paddingX={1}>
<Text bold color="cyan">MCP Inspector</Text>
<Text dimColor> </Text>
<Text color={connected ? 'green' : 'yellow'}>{connected ? '● live' : '○ connecting…'}</Text>
{projectFilter && <Text dimColor> project: {projectFilter}</Text>}
{selectedSession >= 0 && sessions[selectedSession] && (
<Text dimColor> session: {sessions[selectedSession]!.sessionId.slice(0, 8)}</Text>
)}
{!showSidebar && <Text dimColor> [s] show sidebar</Text>}
</Box>
{error && (
<Box paddingX={1}>
<Text color="red"> {error}</Text>
</Box>
)}
{/* ── Main content ── */}
<Box flexDirection="row" height={contentHeight}>
{showSidebar && (
<SessionList
sessions={sessions}
selected={selectedSession}
eventCounts={eventCounts}
/>
)}
<TrafficLog
events={filteredEvents}
height={contentHeight}
showProject={showProject}
focusedIdx={focusedEvent}
/>
</Box>
{/* ── Detail pane ── */}
{expandedEvent && focusedEventObj && (
<DetailPane event={focusedEventObj} maxLines={detailHeight} scrollOffset={detailScroll} />
)}
{/* ── Footer legend ── */}
<Box paddingX={1}>
<Text dimColor>
<Text color="green"> req</Text>
{' '}
<Text color="blue"> resp</Text>
{' '}
<Text color="yellowBright"> upstream</Text>
{' '}
<Text color="magenta"> notify</Text>
{' │ '}
{!showSidebar && <Text>[s] sidebar </Text>}
<Text>[j/k] navigate [] expand [G] latest [q] quit</Text>
</Text>
</Box>
</Box>
);
}
// ── Render entrypoint ──
export interface InspectRenderOptions {
mcplocalUrl: string;
projectFilter?: string;
}
export async function renderInspect(opts: InspectRenderOptions): Promise<void> {
const inspectUrl = `${opts.mcplocalUrl.replace(/\/$/, '')}/inspect`;
const instance = render(
<InspectApp inspectUrl={inspectUrl} projectFilter={opts.projectFilter} />,
);
await instance.waitUntilExit();
}

View File

@@ -0,0 +1,404 @@
/**
* MCP server over stdin/stdout for the traffic inspector.
*
* Claude adds this to .mcp.json as:
* { "mcpctl-inspect": { "command": "mcpctl", "args": ["console", "--inspect", "--stdin-mcp"] } }
*
* Subscribes to mcplocal's /inspect SSE endpoint and exposes traffic
* data via MCP tools: list_sessions, get_traffic, get_session_info.
*/
import { createInterface } from 'node:readline';
import { request as httpRequest } from 'node:http';
import type { IncomingMessage } from 'node:http';
// ── Types ──
interface TrafficEvent {
timestamp: string;
projectName: string;
sessionId: string;
eventType: string;
method?: string;
upstreamName?: string;
body: unknown;
durationMs?: number;
}
interface ActiveSession {
sessionId: string;
projectName: string;
startedAt: string;
eventCount: number;
}
interface JsonRpcRequest {
jsonrpc: string;
id: string | number;
method: string;
params?: Record<string, unknown>;
}
// ── State ──
const sessions = new Map<string, ActiveSession>();
const events: TrafficEvent[] = [];
const MAX_EVENTS = 10000;
// ── SSE Client ──
function connectSSE(url: string): void {
const parsed = new URL(url);
const req = httpRequest(
{
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
headers: { Accept: 'text/event-stream' },
},
(res: IncomingMessage) => {
let buffer = '';
let currentEventType = 'message';
res.setEncoding('utf-8');
res.on('data', (chunk: string) => {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (currentEventType === 'sessions') {
for (const s of data as Array<{ sessionId: string; projectName: string; startedAt: string }>) {
sessions.set(s.sessionId, { ...s, eventCount: 0 });
}
} else if (currentEventType !== 'live') {
handleEvent(data as TrafficEvent);
}
} catch {
// ignore
}
currentEventType = 'message';
}
}
});
res.on('end', () => {
// Reconnect after 2s
setTimeout(() => connectSSE(url), 2000);
});
res.on('error', () => {
setTimeout(() => connectSSE(url), 2000);
});
},
);
req.on('error', () => {
setTimeout(() => connectSSE(url), 2000);
});
req.end();
}
function handleEvent(event: TrafficEvent): void {
events.push(event);
if (events.length > MAX_EVENTS) {
events.splice(0, events.length - MAX_EVENTS);
}
// Track sessions
if (event.eventType === 'session_created') {
sessions.set(event.sessionId, {
sessionId: event.sessionId,
projectName: event.projectName,
startedAt: event.timestamp,
eventCount: 0,
});
} else if (event.eventType === 'session_closed') {
sessions.delete(event.sessionId);
}
// Increment event count
const session = sessions.get(event.sessionId);
if (session) {
session.eventCount++;
}
}
// ── MCP Protocol Handlers ──
const TOOLS = [
{
name: 'list_sessions',
description: 'List all active MCP sessions with their project name, start time, and event count.',
inputSchema: {
type: 'object' as const,
properties: {
project: { type: 'string' as const, description: 'Filter by project name' },
},
},
},
{
name: 'get_traffic',
description: 'Get captured MCP traffic events. Returns recent events, optionally filtered by session, method, or event type.',
inputSchema: {
type: 'object' as const,
properties: {
sessionId: { type: 'string' as const, description: 'Filter by session ID (first 8 chars is enough)' },
method: { type: 'string' as const, description: 'Filter by JSON-RPC method (e.g. "tools/call", "initialize")' },
eventType: { type: 'string' as const, description: 'Filter by event type: client_request, client_response, client_notification, upstream_request, upstream_response' },
limit: { type: 'number' as const, description: 'Max events to return (default: 50)' },
offset: { type: 'number' as const, description: 'Skip first N matching events' },
},
},
},
{
name: 'get_session_info',
description: 'Get detailed information about a specific session including its recent traffic summary.',
inputSchema: {
type: 'object' as const,
properties: {
sessionId: { type: 'string' as const, description: 'Session ID (first 8 chars is enough)' },
},
required: ['sessionId'] as const,
},
},
];
function handleInitialize(id: string | number): void {
send({
jsonrpc: '2.0',
id,
result: {
protocolVersion: '2024-11-05',
serverInfo: { name: 'mcpctl-inspector', version: '1.0.0' },
capabilities: { tools: {} },
},
});
}
function handleToolsList(id: string | number): void {
send({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
}
function handleToolsCall(id: string | number, params: { name: string; arguments?: Record<string, unknown> }): void {
const args = params.arguments ?? {};
switch (params.name) {
case 'list_sessions': {
let result = [...sessions.values()];
const project = args['project'] as string | undefined;
if (project) {
result = result.filter((s) => s.projectName === project);
}
send({
jsonrpc: '2.0',
id,
result: {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
},
});
break;
}
case 'get_traffic': {
const sessionFilter = args['sessionId'] as string | undefined;
const methodFilter = args['method'] as string | undefined;
const typeFilter = args['eventType'] as string | undefined;
const limit = (args['limit'] as number | undefined) ?? 50;
const offset = (args['offset'] as number | undefined) ?? 0;
let filtered = events;
if (sessionFilter) {
filtered = filtered.filter((e) => e.sessionId.startsWith(sessionFilter));
}
if (methodFilter) {
filtered = filtered.filter((e) => e.method === methodFilter);
}
if (typeFilter) {
filtered = filtered.filter((e) => e.eventType === typeFilter);
}
const sliced = filtered.slice(offset, offset + limit);
// Format as readable lines (strip jsonrpc/id boilerplate)
const lines = sliced.map((e) => {
const arrow = e.eventType === 'client_request' ? '→'
: e.eventType === 'client_response' ? '←'
: e.eventType === 'client_notification' ? '◂'
: e.eventType === 'upstream_request' ? '⇢'
: e.eventType === 'upstream_response' ? '⇠'
: e.eventType === 'session_created' ? '●'
: e.eventType === 'session_closed' ? '○'
: '?';
const layer = e.eventType.startsWith('upstream') ? 'internal' : 'client';
const ms = e.durationMs !== undefined ? ` (${e.durationMs}ms)` : '';
const upstream = e.upstreamName ? `${e.upstreamName}/` : '';
const time = e.timestamp.split('T')[1]?.replace('Z', '') ?? e.timestamp;
// Extract meaningful content from body (strip jsonrpc/id envelope)
const body = e.body as Record<string, unknown> | null;
let content = '';
if (body) {
if (e.eventType.includes('request') || e.eventType === 'client_notification') {
const params = body['params'] as Record<string, unknown> | undefined;
if (e.method === 'tools/call' && params) {
const toolArgs = params['arguments'] as Record<string, unknown> | undefined;
content = `tool=${params['name']}${toolArgs ? ` args=${JSON.stringify(toolArgs)}` : ''}`;
} else if (e.method === 'resources/read' && params) {
content = `uri=${params['uri']}`;
} else if (e.method === 'initialize' && params) {
const ci = params['clientInfo'] as Record<string, unknown> | undefined;
content = ci ? `client=${ci['name']} v${ci['version']}` : '';
} else if (params && Object.keys(params).length > 0) {
content = JSON.stringify(params);
}
} else if (e.eventType.includes('response')) {
const result = body['result'] as Record<string, unknown> | undefined;
const error = body['error'] as Record<string, unknown> | undefined;
if (error) {
content = `ERROR ${error['code']}: ${error['message']}`;
} else if (result) {
if (e.method === 'tools/list') {
const tools = (result['tools'] ?? []) as Array<{ name: string }>;
content = `${tools.length} tools: ${tools.map((t) => t.name).join(', ')}`;
} else if (e.method === 'resources/list') {
const res = (result['resources'] ?? []) as Array<{ name: string }>;
content = `${res.length} resources: ${res.map((r) => r.name).join(', ')}`;
} else if (e.method === 'tools/call') {
const c = (result['content'] ?? []) as Array<{ text?: string }>;
const text = c[0]?.text ?? '';
content = text.length > 200 ? text.slice(0, 200) + '…' : text;
} else if (e.method === 'initialize') {
const si = result['serverInfo'] as Record<string, unknown> | undefined;
content = si ? `server=${si['name']} v${si['version']}` : '';
} else if (Object.keys(result).length > 0) {
const s = JSON.stringify(result);
content = s.length > 200 ? s.slice(0, 200) + '…' : s;
}
}
}
}
return `${time} ${arrow} [${layer}] ${upstream}${e.method ?? e.eventType}${ms}${content ? ' ' + content : ''}`;
});
send({
jsonrpc: '2.0',
id,
result: {
content: [{
type: 'text',
text: `${filtered.length} total events (showing ${offset + 1}-${offset + sliced.length})\n\n${lines.join('\n')}`,
}],
},
});
break;
}
case 'get_session_info': {
const sid = args['sessionId'] as string;
const session = [...sessions.values()].find((s) => s.sessionId.startsWith(sid));
if (!session) {
send({
jsonrpc: '2.0',
id,
result: {
content: [{ type: 'text', text: `Session not found: ${sid}` }],
isError: true,
},
});
return;
}
const sessionEvents = events.filter((e) => e.sessionId === session.sessionId);
const methods = new Map<string, number>();
for (const e of sessionEvents) {
if (e.method) {
methods.set(e.method, (methods.get(e.method) ?? 0) + 1);
}
}
const info = {
...session,
totalEvents: sessionEvents.length,
methodCounts: Object.fromEntries(methods),
lastEvent: sessionEvents.length > 0
? sessionEvents[sessionEvents.length - 1]!.timestamp
: null,
};
send({
jsonrpc: '2.0',
id,
result: {
content: [{ type: 'text', text: JSON.stringify(info, null, 2) }],
},
});
break;
}
default:
send({
jsonrpc: '2.0',
id,
error: { code: -32601, message: `Unknown tool: ${params.name}` },
});
}
}
function handleRequest(request: JsonRpcRequest): void {
switch (request.method) {
case 'initialize':
handleInitialize(request.id);
break;
case 'notifications/initialized':
// Notification — no response
break;
case 'tools/list':
handleToolsList(request.id);
break;
case 'tools/call':
handleToolsCall(request.id, request.params as { name: string; arguments?: Record<string, unknown> });
break;
default:
if (request.id !== undefined) {
send({
jsonrpc: '2.0',
id: request.id,
error: { code: -32601, message: `Method not supported: ${request.method}` },
});
}
}
}
function send(message: unknown): void {
process.stdout.write(JSON.stringify(message) + '\n');
}
// ── Entrypoint ──
export async function runInspectMcp(mcplocalUrl: string): Promise<void> {
const inspectUrl = `${mcplocalUrl.replace(/\/$/, '')}/inspect`;
connectSSE(inspectUrl);
const rl = createInterface({ input: process.stdin });
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const request = JSON.parse(trimmed) as JsonRpcRequest;
handleRequest(request);
} catch {
// Ignore unparseable lines
}
}
}

View File

@@ -1,5 +1,6 @@
import { Command } from 'commander';
import { type ApiClient, ApiError } from '../api-client.js';
import { resolveNameOrId } from './shared.js';
export interface CreateCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
@@ -55,7 +56,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('create')
.description('Create a resource (server, secret, project, user, group, rbac)');
.description('Create a resource (server, secret, project, user, group, rbac, serverattachment, prompt)');
// --- create server ---
cmd.command('server')
@@ -72,6 +73,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--replicas <count>', 'Number of replicas')
.option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
.option('--from-template <name>', 'Create from template (name or name:version)')
.option('--env-from-secret <secret>', 'Map template env vars from a secret')
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
let base: Record<string, unknown> = {};
@@ -103,7 +105,33 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
// Convert template env (description/required) to server env (name/value/valueFrom)
const tplEnv = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
if (tplEnv && tplEnv.length > 0) {
base.env = tplEnv.map((e) => ({ name: e.name, value: e.defaultValue ?? '' }));
if (opts.envFromSecret) {
// --env-from-secret: map all template env vars from the specified secret
const secretName = opts.envFromSecret as string;
const secrets = await client.get<Array<{ name: string; data: Record<string, string> }>>('/api/v1/secrets');
const secret = secrets.find((s) => s.name === secretName);
if (!secret) throw new Error(`Secret '${secretName}' not found`);
const missing = tplEnv
.filter((e) => e.required !== false && !(e.name in secret.data))
.map((e) => e.name);
if (missing.length > 0) {
throw new Error(
`Secret '${secretName}' is missing required keys: ${missing.join(', ')}\n` +
`Secret has: ${Object.keys(secret.data).join(', ')}`,
);
}
base.env = tplEnv.map((e) => {
if (e.name in secret.data) {
return { name: e.name, valueFrom: { secretRef: { name: secretName, key: e.name } } };
}
return { name: e.name, value: e.defaultValue ?? '' };
});
log(`Mapped ${tplEnv.filter((e) => e.name in secret.data).length} env var(s) from secret '${secretName}'`);
} else {
base.env = tplEnv.map((e) => ({ name: e.name, value: e.defaultValue ?? '' }));
}
}
// Track template origin
@@ -363,6 +391,10 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
const fs = await import('node:fs/promises');
content = await fs.readFile(opts.contentFile as string, 'utf-8');
}
// For linked prompts, auto-generate placeholder content if none provided
if (!content && opts.link) {
content = `Linked prompt — content fetched from ${opts.link as string}`;
}
if (!content) {
throw new Error('--content or --content-file is required');
}
@@ -390,6 +422,22 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
});
// --- create serverattachment ---
cmd.command('serverattachment')
.alias('sa')
.description('Attach a server to a project')
.argument('<server>', 'Server name')
.option('--project <name>', 'Project name')
.action(async (serverName: string, opts) => {
const projectName = opts.project as string | undefined;
if (!projectName) {
throw new Error('--project is required. Usage: mcpctl create serverattachment <server> --project <name>');
}
const projectId = await resolveNameOrId(client, 'projects', projectName);
await client.post(`/api/v1/projects/${projectId}/servers`, { server: serverName });
log(`server '${serverName}' attached to project '${projectName}'`);
});
// --- create promptrequest ---
cmd.command('promptrequest')
.description('Create a prompt request (pending proposal that needs approval)')

View File

@@ -14,9 +14,21 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command {
.description('Delete a resource (server, instance, secret, project, user, group, rbac)')
.argument('<resource>', 'resource type')
.argument('<id>', 'resource ID or name')
.action(async (resourceArg: string, idOrName: string) => {
.option('--project <name>', 'Project name (for serverattachment)')
.action(async (resourceArg: string, idOrName: string, opts: { project?: string }) => {
const resource = resolveResource(resourceArg);
// Serverattachments: delete serverattachment <server> --project <project>
if (resource === 'serverattachments') {
if (!opts.project) {
throw new Error('--project is required. Usage: mcpctl delete serverattachment <server> --project <name>');
}
const projectId = await resolveNameOrId(client, 'projects', opts.project);
await client.delete(`/api/v1/projects/${projectId}/servers/${idOrName}`);
log(`server '${idOrName}' detached from project '${opts.project}'`);
return;
}
// Resolve name → ID for any resource type
let id: string;
try {

View File

@@ -504,6 +504,95 @@ function formatRbacDetail(rbac: Record<string, unknown>): string {
return lines.join('\n');
}
async function formatPromptDetail(prompt: Record<string, unknown>, client?: ApiClient): Promise<string> {
const lines: string[] = [];
lines.push(`=== Prompt: ${prompt.name} ===`);
lines.push(`${pad('Name:')}${prompt.name}`);
const proj = prompt.project as { name: string } | null | undefined;
lines.push(`${pad('Project:')}${proj?.name ?? (prompt.projectId ? String(prompt.projectId) : '(global)')}`);
lines.push(`${pad('Priority:')}${prompt.priority ?? 5}`);
// Link info
const link = prompt.linkTarget as string | null | undefined;
if (link) {
lines.push('');
lines.push('Link:');
lines.push(` ${pad('Target:', 12)}${link}`);
const status = prompt.linkStatus as string | null | undefined;
if (status) lines.push(` ${pad('Status:', 12)}${status}`);
}
// Content — resolve linked content if possible
let content = prompt.content as string | undefined;
if (link && client) {
const resolved = await resolveLink(link, client);
if (resolved) content = resolved;
}
lines.push('');
lines.push('Content:');
if (content) {
// Indent content with 2 spaces for readability
for (const line of content.split('\n')) {
lines.push(` ${line}`);
}
} else {
lines.push(' (no content)');
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${prompt.id}`);
if (prompt.version) lines.push(` ${pad('Version:', 12)}${prompt.version}`);
if (prompt.createdAt) lines.push(` ${pad('Created:', 12)}${prompt.createdAt}`);
if (prompt.updatedAt) lines.push(` ${pad('Updated:', 12)}${prompt.updatedAt}`);
return lines.join('\n');
}
/**
* Resolve a prompt link target via mcpd proxy's resources/read.
* Returns resolved content string or null on failure.
*/
async function resolveLink(linkTarget: string, client: ApiClient): Promise<string | null> {
try {
// Parse link: project/server:uri
const slashIdx = linkTarget.indexOf('/');
if (slashIdx < 1) return null;
const project = linkTarget.slice(0, slashIdx);
const rest = linkTarget.slice(slashIdx + 1);
const colonIdx = rest.indexOf(':');
if (colonIdx < 1) return null;
const serverName = rest.slice(0, colonIdx);
const uri = rest.slice(colonIdx + 1);
// Resolve server name → ID
const servers = await client.get<Array<{ id: string; name: string }>>(
`/api/v1/projects/${encodeURIComponent(project)}/servers`,
);
const target = servers.find((s) => s.name === serverName);
if (!target) return null;
// Call resources/read via proxy
const proxyResponse = await client.post<{
result?: { contents?: Array<{ text?: string }> };
error?: { code: number; message: string };
}>('/api/v1/mcp/proxy', {
serverId: target.id,
method: 'resources/read',
params: { uri },
});
if (proxyResponse.error) return null;
const contents = proxyResponse.result?.contents;
if (!contents || contents.length === 0) return null;
return contents.map((c) => c.text ?? '').join('\n');
} catch {
return null; // Silently fall back to stored content
}
}
function formatGenericDetail(obj: Record<string, unknown>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
@@ -563,10 +652,15 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
}
}
} else {
try {
id = await resolveNameOrId(deps.client, resource, idOrName);
} catch {
// Prompts/promptrequests: let fetchResource handle scoping (it respects --project)
if (resource === 'prompts' || resource === 'promptrequests') {
id = idOrName;
} else {
try {
id = await resolveNameOrId(deps.client, resource, idOrName);
} catch {
id = idOrName;
}
}
}
@@ -630,6 +724,9 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
case 'rbac':
deps.log(formatRbacDetail(item));
break;
case 'prompts':
deps.log(await formatPromptDetail(item, deps.client));
break;
default:
deps.log(formatGenericDetail(item));
}

View File

@@ -1,12 +1,13 @@
import { Command } from 'commander';
import { formatTable } from '../formatters/table.js';
import { formatJson, formatYaml } from '../formatters/output.js';
import { formatJson, formatYamlMultiDoc } from '../formatters/output.js';
import type { Column } from '../formatters/table.js';
import { resolveResource, stripInternalFields } from './shared.js';
export interface GetCommandDeps {
fetchResource: (resource: string, id?: string, opts?: { project?: string; all?: boolean }) => Promise<unknown[]>;
log: (...args: string[]) => void;
getProject?: () => string | undefined;
}
interface ServerRow {
@@ -179,6 +180,16 @@ const instanceColumns: Column<InstanceRow>[] = [
{ header: 'ID', key: 'id' },
];
interface ServerAttachmentRow {
project: string;
server: string;
}
const serverAttachmentColumns: Column<ServerAttachmentRow>[] = [
{ header: 'SERVER', key: 'server', width: 25 },
{ header: 'PROJECT', key: 'project', width: 25 },
];
function getColumnsForResource(resource: string): Column<Record<string, unknown>>[] {
switch (resource) {
case 'servers':
@@ -201,6 +212,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
return promptColumns as unknown as Column<Record<string, unknown>>[];
case 'promptrequests':
return promptRequestColumns as unknown as Column<Record<string, unknown>>[];
case 'serverattachments':
return serverAttachmentColumns as unknown as Column<Record<string, unknown>>[];
default:
return [
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
@@ -209,38 +222,61 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
}
}
/** Map plural resource name → singular kind for YAML documents */
const RESOURCE_KIND: Record<string, string> = {
servers: 'server',
projects: 'project',
secrets: 'secret',
templates: 'template',
instances: 'instance',
users: 'user',
groups: 'group',
rbac: 'rbac',
prompts: 'prompt',
promptrequests: 'promptrequest',
serverattachments: 'serverattachment',
};
/**
* Transform API response items into apply-compatible format.
* Strips internal fields and wraps in the resource key.
* Transform API response items into apply-compatible multi-doc format.
* Each item gets a `kind` field and internal fields stripped.
*/
function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> {
const cleaned = items.map((item) => {
return stripInternalFields(item as Record<string, unknown>);
function toApplyDocs(resource: string, items: unknown[]): Array<{ kind: string } & Record<string, unknown>> {
const kind = RESOURCE_KIND[resource] ?? resource;
return items.map((item) => {
const cleaned = stripInternalFields(item as Record<string, unknown>);
return { kind, ...cleaned };
});
return { [resource]: cleaned };
}
export function createGetCommand(deps: GetCommandDeps): Command {
return new Command('get')
.description('List resources (servers, projects, instances)')
.argument('<resource>', 'resource type (servers, projects, instances)')
.description('List resources (servers, projects, instances, all)')
.argument('<resource>', 'resource type (servers, projects, instances, all)')
.argument('[id]', 'specific resource ID or name')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.option('--project <name>', 'Filter by project')
.option('-A, --all', 'Show all (including project-scoped) resources')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string; project?: string; all?: true }) => {
const resource = resolveResource(resourceArg);
// Merge parent --project with local --project
const project = opts.project ?? deps.getProject?.();
// Handle `get all --project X` composite export
if (resource === 'all') {
await handleGetAll(deps, { ...opts, project });
return;
}
const fetchOpts: { project?: string; all?: boolean } = {};
if (opts.project) fetchOpts.project = opts.project;
if (project) fetchOpts.project = project;
if (opts.all) fetchOpts.all = true;
const items = await deps.fetchResource(resource, id, Object.keys(fetchOpts).length > 0 ? fetchOpts : undefined);
if (opts.output === 'json') {
// Apply-compatible JSON wrapped in resource key
deps.log(formatJson(toApplyFormat(resource, items)));
deps.log(formatJson(toApplyDocs(resource, items)));
} else if (opts.output === 'yaml') {
// Apply-compatible YAML wrapped in resource key
deps.log(formatYaml(toApplyFormat(resource, items)));
deps.log(formatYamlMultiDoc(toApplyDocs(resource, items)));
} else {
if (items.length === 0) {
deps.log(`No ${resource} found.`);
@@ -251,3 +287,59 @@ export function createGetCommand(deps: GetCommandDeps): Command {
}
});
}
async function handleGetAll(
deps: GetCommandDeps,
opts: { output: string; project?: string },
): Promise<void> {
if (!opts.project) {
throw new Error('--project is required with "get all". Usage: mcpctl get all --project <name>');
}
const docs: Array<{ kind: string } & Record<string, unknown>> = [];
// 1. Fetch the project
const projects = await deps.fetchResource('projects', opts.project);
if (projects.length === 0) {
deps.log(`Project '${opts.project}' not found.`);
return;
}
// 2. Add the project itself
for (const p of projects) {
docs.push({ kind: 'project', ...stripInternalFields(p as Record<string, unknown>) });
}
// 3. Extract serverattachments from project's server list
const project = projects[0] as ProjectRow;
let attachmentCount = 0;
if (project.servers && project.servers.length > 0) {
for (const ps of project.servers) {
docs.push({
kind: 'serverattachment',
server: typeof ps === 'string' ? ps : ps.server.name,
project: project.name,
});
attachmentCount++;
}
}
// 4. Fetch prompts owned by this project (exclude global prompts)
const prompts = await deps.fetchResource('prompts', undefined, { project: opts.project });
const projectPrompts = prompts.filter((p) => (p as { projectId?: string }).projectId != null);
for (const p of projectPrompts) {
docs.push({ kind: 'prompt', ...stripInternalFields(p as Record<string, unknown>) });
}
if (opts.output === 'json') {
deps.log(formatJson(docs));
} else if (opts.output === 'yaml') {
deps.log(formatYamlMultiDoc(docs));
} else {
// Table output: show summary
deps.log(`Project: ${opts.project}`);
deps.log(` Server Attachments: ${attachmentCount}`);
deps.log(` Prompts: ${projectPrompts.length}`);
deps.log(`\nUse -o yaml or -o json for apply-compatible output.`);
}
}

View File

@@ -0,0 +1,58 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveResource, resolveNameOrId } from './shared.js';
export interface PatchCommandDeps {
client: ApiClient;
log: (...args: string[]) => void;
}
/**
* Parse "key=value" pairs into a partial update object.
* Supports: key=value, key=null (sets null), key=123 (number if parseable).
*/
function parsePatches(pairs: string[]): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const pair of pairs) {
const eqIdx = pair.indexOf('=');
if (eqIdx === -1) {
throw new Error(`Invalid patch format '${pair}'. Expected key=value`);
}
const key = pair.slice(0, eqIdx);
const raw = pair.slice(eqIdx + 1);
if (raw === 'null') {
result[key] = null;
} else if (raw === 'true') {
result[key] = true;
} else if (raw === 'false') {
result[key] = false;
} else if (/^\d+$/.test(raw)) {
result[key] = parseInt(raw, 10);
} else {
result[key] = raw;
}
}
return result;
}
export function createPatchCommand(deps: PatchCommandDeps): Command {
const { client, log } = deps;
return new Command('patch')
.description('Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)')
.argument('<resource>', 'resource type (server, project, secret, ...)')
.argument('<name>', 'resource name or ID')
.argument('<patches...>', 'key=value pairs to patch')
.action(async (resourceArg: string, nameOrId: string, patches: string[]) => {
const resource = resolveResource(resourceArg);
const id = await resolveNameOrId(client, resource, nameOrId);
const body = parsePatches(patches);
await client.put(`/api/v1/${resource}/${id}`, body);
const fields = Object.entries(body)
.map(([k, v]) => `${k}=${v === null ? 'null' : String(v)}`)
.join(', ');
log(`patched ${resource.replace(/s$/, '')} '${nameOrId}': ${fields}`);
});
}

View File

@@ -21,6 +21,10 @@ export const RESOURCE_ALIASES: Record<string, string> = {
promptrequest: 'promptrequests',
promptrequests: 'promptrequests',
pr: 'promptrequests',
serverattachment: 'serverattachments',
serverattachments: 'serverattachments',
sa: 'serverattachments',
all: 'all',
};
export function resolveResource(name: string): string {
@@ -61,21 +65,53 @@ export async function resolveNameOrId(
/** Strip internal/read-only fields from an API response to make it apply-compatible. */
export function stripInternalFields(obj: Record<string, unknown>): Record<string, unknown> {
const result = { ...obj };
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId', 'summary', 'chapters']) {
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId', 'summary', 'chapters', 'linkStatus', 'serverId']) {
delete result[key];
}
// Strip relationship joins that aren't part of the resource spec (like k8s namespaces don't list deployments)
if ('servers' in result && Array.isArray(result.servers)) {
delete result.servers;
// Rename linkTarget → link for cleaner YAML
if ('linkTarget' in result) {
result.link = result.linkTarget;
delete result.linkTarget;
// Linked prompts: strip content (it's fetched from the link source, not static)
if (result.link) {
delete result.content;
}
}
// Convert project servers join array → string[] of server names
if ('servers' in result && Array.isArray(result.servers)) {
const entries = result.servers as Array<{ server?: { name: string } }>;
if (entries.length > 0 && entries[0]?.server) {
result.servers = entries.map((e) => e.server!.name);
} else if (entries.length === 0) {
result.servers = [];
} else {
delete result.servers;
}
}
// Convert prompt projectId CUID → project name string
if ('project' in result && typeof result.project === 'object' && result.project !== null) {
const proj = result.project as { name: string };
result.project = proj.name;
delete result.projectId;
}
// Strip remaining relationship objects
if ('owner' in result && typeof result.owner === 'object') {
delete result.owner;
}
if ('members' in result && Array.isArray(result.members)) {
delete result.members;
}
if ('project' in result && typeof result.project === 'object' && result.project !== null) {
delete result.project;
// Strip null values last (null = unset, omitting from YAML is cleaner and equivalent)
for (const key of Object.keys(result)) {
if (result[key] === null) {
delete result[key];
}
}
return result;
}

View File

@@ -15,10 +15,14 @@ export function reorderKeys(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(reorderKeys);
if (obj !== null && typeof obj === 'object') {
const rec = obj as Record<string, unknown>;
const lastKeys = ['content', 'prompt'];
const firstKeys = ['kind'];
const lastKeys = ['link', 'content', 'prompt'];
const ordered: Record<string, unknown> = {};
for (const key of firstKeys) {
if (key in rec) ordered[key] = rec[key];
}
for (const key of Object.keys(rec)) {
if (!lastKeys.includes(key)) ordered[key] = reorderKeys(rec[key]);
if (!firstKeys.includes(key) && !lastKeys.includes(key)) ordered[key] = reorderKeys(rec[key]);
}
for (const key of lastKeys) {
if (key in rec) ordered[key] = rec[key];
@@ -32,3 +36,16 @@ export function formatYaml(data: unknown): string {
const reordered = reorderKeys(data);
return yaml.dump(reordered, { lineWidth: 120, noRefs: true }).trimEnd();
}
/**
* Format multiple resources as Kubernetes-style multi-document YAML.
* Each item gets its own `---` separated document with a `kind` field.
*/
export function formatYamlMultiDoc(items: Array<{ kind: string } & Record<string, unknown>>): string {
return items
.map((item) => {
const reordered = reorderKeys(item);
return '---\n' + yaml.dump(reordered, { lineWidth: 120, noRefs: true }).trimEnd();
})
.join('\n');
}

View File

@@ -29,7 +29,7 @@ export function createProgram(): Command {
.enablePositionalOptions()
.option('--daemon-url <url>', 'mcplocal daemon URL')
.option('--direct', 'bypass mcplocal and connect directly to mcpd')
.option('--project <name>', 'Target project for project commands');
.option('-p, --project <name>', 'Target project for project commands');
program.addCommand(createStatusCommand());
program.addCommand(createLoginCommand());
@@ -59,17 +59,26 @@ export function createProgram(): Command {
const fetchResource = async (resource: string, nameOrId?: string, opts?: { project?: string; all?: boolean }): Promise<unknown[]> => {
const projectName = opts?.project ?? program.opts().project as string | undefined;
// --project scoping for servers and instances
if (projectName && !nameOrId && (resource === 'servers' || resource === 'instances')) {
const projectId = await resolveNameOrId(client, 'projects', projectName);
if (resource === 'servers') {
return client.get<unknown[]>(`/api/v1/projects/${projectId}/servers`);
// Virtual resource: serverattachments (composed from project data)
if (resource === 'serverattachments') {
type ProjectWithServers = { name: string; id: string; servers?: Array<{ server: { name: string } }> };
let projects: ProjectWithServers[];
if (projectName) {
const projectId = await resolveNameOrId(client, 'projects', projectName);
const project = await client.get<ProjectWithServers>(`/api/v1/projects/${projectId}`);
projects = [project];
} else {
projects = await client.get<ProjectWithServers[]>('/api/v1/projects');
}
// instances: fetch project servers, then filter instances by serverId
const projectServers = await client.get<Array<{ id: string }>>(`/api/v1/projects/${projectId}/servers`);
const serverIds = new Set(projectServers.map((s) => s.id));
const allInstances = await client.get<Array<{ serverId: string }>>(`/api/v1/instances`);
return allInstances.filter((inst) => serverIds.has(inst.serverId));
const attachments: Array<{ project: string; server: string }> = [];
for (const p of projects) {
if (p.servers) {
for (const ps of p.servers) {
attachments.push({ server: ps.server.name, project: p.name });
}
}
}
return attachments;
}
// --project scoping for prompts and promptrequests
@@ -101,6 +110,21 @@ export function createProgram(): Command {
};
const fetchSingleResource = async (resource: string, nameOrId: string): Promise<unknown> => {
const projectName = program.opts().project as string | undefined;
// Prompts: resolve within project scope (or global-only without --project)
if (resource === 'prompts' || resource === 'promptrequests') {
const scope = projectName
? `?project=${encodeURIComponent(projectName)}`
: '?scope=global';
const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}${scope}`);
const match = items.find((item) => item.name === nameOrId);
if (!match) {
throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found${projectName ? ` in project '${projectName}'` : ' (global scope). Use --project to specify a project'}`);
}
return client.get(`/api/v1/${resource}/${match.id as string}`);
}
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
@@ -113,6 +137,7 @@ export function createProgram(): Command {
program.addCommand(createGetCommand({
fetchResource,
log: (...args) => console.log(...args),
getProject: () => program.opts().project as string | undefined,
}));
program.addCommand(createDescribeCommand({

View File

@@ -9,7 +9,7 @@ describe('createProgram', () => {
it('has version flag', () => {
const program = createProgram();
expect(program.version()).toBe('0.1.0');
expect(program.version()).toBe('0.0.1');
});
it('has config subcommand', () => {

View File

@@ -64,7 +64,7 @@ describe('config claude', () => {
});
});
it('merges with existing .mcp.json', async () => {
it('always merges with existing .mcp.json', async () => {
const outPath = join(tmpDir, '.mcp.json');
writeFileSync(outPath, JSON.stringify({
mcpServers: { 'existing--server': { command: 'echo', args: [] } },
@@ -74,7 +74,7 @@ describe('config claude', () => {
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
await cmd.parseAsync(['claude', '--project', 'proj-1', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['existing--server']).toBeDefined();
@@ -85,6 +85,36 @@ describe('config claude', () => {
expect(output.join('\n')).toContain('2 server(s)');
});
it('adds inspect MCP server with --inspect', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--inspect', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['mcpctl-inspect']).toEqual({
command: 'mcpctl',
args: ['console', '--inspect', '--stdin-mcp'],
});
expect(output.join('\n')).toContain('1 server(s)');
});
it('adds both project and inspect with --project --inspect', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'ha', '--inspect', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['ha']).toBeDefined();
expect(written.mcpServers['mcpctl-inspect']).toBeDefined();
expect(output.join('\n')).toContain('2 server(s)');
});
it('backward compat: claude-generate still works', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(

View File

@@ -41,27 +41,28 @@ describe('get command', () => {
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1', undefined);
});
it('outputs apply-compatible JSON format', async () => {
it('outputs apply-compatible JSON format (multi-doc)', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '');
// Wrapped in resource key, internal fields stripped
expect(parsed).toHaveProperty('servers');
expect(parsed.servers[0].name).toBe('slack');
expect(parsed.servers[0]).not.toHaveProperty('id');
expect(parsed.servers[0]).not.toHaveProperty('createdAt');
expect(parsed.servers[0]).not.toHaveProperty('updatedAt');
expect(parsed.servers[0]).not.toHaveProperty('version');
// Array of documents with kind field, internal fields stripped
expect(Array.isArray(parsed)).toBe(true);
expect(parsed[0].kind).toBe('server');
expect(parsed[0].name).toBe('slack');
expect(parsed[0]).not.toHaveProperty('id');
expect(parsed[0]).not.toHaveProperty('createdAt');
expect(parsed[0]).not.toHaveProperty('updatedAt');
expect(parsed[0]).not.toHaveProperty('version');
});
it('outputs apply-compatible YAML format', async () => {
it('outputs apply-compatible YAML format (multi-doc)', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
const text = deps.output[0];
expect(text).toContain('servers:');
expect(text).toContain('kind: server');
expect(text).toContain('name: slack');
expect(text).not.toContain('id:');
expect(text).not.toContain('createdAt:');

View File

@@ -76,7 +76,7 @@ describe('status command', () => {
const cmd = createStatusCommand(baseDeps());
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['version']).toBe('0.1.0');
expect(parsed['version']).toBe('0.0.1');
expect(parsed['mcplocalReachable']).toBe(true);
expect(parsed['mcpdReachable']).toBe(true);
});

View File

@@ -1,12 +1,22 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const fishFile = readFileSync(join(root, 'completions', 'mcpctl.fish'), 'utf-8');
const bashFile = readFileSync(join(root, 'completions', 'mcpctl.bash'), 'utf-8');
describe('freshness', () => {
it('committed completions match generator output', () => {
const generatorPath = join(root, 'scripts', 'generate-completions.ts');
expect(existsSync(generatorPath), 'generator script must exist').toBe(true);
// Run the generator in --check mode; exit 0 means files are up to date
execSync(`npx tsx ${generatorPath} --check`, { cwd: root, stdio: 'pipe' });
});
});
describe('fish completions', () => {
it('erases stale completions at the top', () => {
const lines = fishFile.split('\n');
@@ -52,8 +62,8 @@ describe('fish completions', () => {
}
});
it('defines --project option', () => {
expect(fishFile).toContain("complete -c mcpctl -l project");
it('defines --project option with -p shorthand', () => {
expect(fishFile).toContain("-s p -l project");
});
it('attach-server command only shows with --project', () => {
@@ -139,8 +149,11 @@ describe('bash completions', () => {
it('fetches resource names dynamically after resource type', () => {
expect(bashFile).toContain('_mcpctl_resource_names');
// get/describe/delete should use resource_names when resource_type is set
expect(bashFile).toMatch(/get\|describe\|delete\)[\s\S]*?_mcpctl_resource_names/);
// get, describe, and delete should each use resource_names when resource_type is set
for (const cmd of ['get', 'describe', 'delete']) {
const block = bashFile.match(new RegExp(`${cmd}\\)[\\s\\S]*?return ;;`))?.[0] ?? '';
expect(block, `${cmd} case must use _mcpctl_resource_names`).toContain('_mcpctl_resource_names');
}
});
it('attach-server filters out already-attached servers and guards against repeat', () => {

View File

@@ -1,6 +1,6 @@
{
"name": "@mcpctl/db",
"version": "0.1.0",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",

View File

@@ -0,0 +1,20 @@
/**
* Vitest globalSetup: push schema once before all db tests.
* Runs in the main vitest process, outside test workers.
*/
import { execSync } from 'node:child_process';
const TEST_DATABASE_URL = process.env['DATABASE_URL'] ??
'postgresql://mcpctl:mcpctl_test@localhost:5433/mcpctl_test';
export function setup(): void {
execSync('npx prisma db push --force-reset --skip-generate', {
cwd: new URL('..', import.meta.url).pathname,
env: {
...process.env,
DATABASE_URL: TEST_DATABASE_URL,
PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: 'yes',
},
stdio: 'pipe',
});
}

View File

@@ -1,11 +1,9 @@
import { PrismaClient } from '@prisma/client';
import { execSync } from 'node:child_process';
const TEST_DATABASE_URL = process.env['DATABASE_URL'] ??
'postgresql://mcpctl:mcpctl_test@localhost:5433/mcpctl_test';
let prisma: PrismaClient | undefined;
let schemaReady = false;
export function getTestClient(): PrismaClient {
if (!prisma) {
@@ -16,26 +14,9 @@ export function getTestClient(): PrismaClient {
return prisma;
}
/** Return a connected test client. Schema is pushed by globalSetup. */
export async function setupTestDb(): Promise<PrismaClient> {
const client = getTestClient();
// Only push schema once per process (multiple test files share the worker)
if (!schemaReady) {
execSync('npx prisma db push --force-reset --skip-generate', {
cwd: new URL('..', import.meta.url).pathname,
env: {
...process.env,
DATABASE_URL: TEST_DATABASE_URL,
// Consent required when Prisma detects AI agent context.
// This targets the ephemeral test database (tmpfs-backed, port 5433).
PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: 'yes',
},
stdio: 'pipe',
});
schemaReady = true;
}
return client;
return getTestClient();
}
export async function cleanupTestDb(): Promise<void> {
@@ -49,8 +30,9 @@ export async function clearAllTables(client: PrismaClient): Promise<void> {
// Delete in order respecting foreign keys
await client.auditLog.deleteMany();
await client.mcpInstance.deleteMany();
await client.promptRequest.deleteMany();
await client.prompt.deleteMany();
await client.projectServer.deleteMany();
await client.projectMember.deleteMany();
await client.secret.deleteMany();
await client.session.deleteMany();
await client.project.deleteMany();

View File

@@ -1,6 +1,12 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables, getTestClient } from './helpers.js';
import { seedTemplates } from '../src/seed/index.js';
import type { SeedTemplate } from '../src/seed/index.js';
// Wrap all tests in a single describe to scope lifecycle hooks
// and prevent leakage when running in the same worker as other test files.
describe('db models', () => {
let prisma: PrismaClient;
@@ -496,45 +502,6 @@ describe('ProjectServer', () => {
});
});
// ── ProjectMember model ──
describe('ProjectMember', () => {
it('links project to user with role', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
const pm = await prisma.projectMember.create({
data: { projectId: project.id, userId: user.id, role: 'admin' },
});
expect(pm.role).toBe('admin');
});
it('defaults role to member', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
const pm = await prisma.projectMember.create({
data: { projectId: project.id, userId: user.id },
});
expect(pm.role).toBe('member');
});
it('enforces unique project-user pair', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } });
await expect(
prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } }),
).rejects.toThrow();
});
it('cascades delete when project is deleted', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } });
await prisma.project.delete({ where: { id: project.id } });
const members = await prisma.projectMember.findMany({ where: { projectId: project.id } });
expect(members).toHaveLength(0);
});
});
// ── Project new fields ──
@@ -566,3 +533,74 @@ describe('Project new fields', () => {
expect(project.llmModel).toBeNull();
});
});
// ── seedTemplates ──
const testTemplates: SeedTemplate[] = [
{
name: 'github',
version: '1.0.0',
description: 'GitHub MCP server',
packageName: '@anthropic/github-mcp',
transport: 'STDIO',
env: [{ name: 'GITHUB_TOKEN', description: 'Personal access token', required: true }],
},
{
name: 'slack',
version: '1.0.0',
description: 'Slack MCP server',
packageName: '@anthropic/slack-mcp',
transport: 'STDIO',
env: [],
},
];
describe('seedTemplates', () => {
it('seeds templates', async () => {
const count = await seedTemplates(prisma, testTemplates);
expect(count).toBe(2);
const templates = await prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
expect(templates).toHaveLength(2);
expect(templates.map((t) => t.name)).toEqual(['github', 'slack']);
});
it('is idempotent (upsert)', async () => {
await seedTemplates(prisma, testTemplates);
const count = await seedTemplates(prisma, testTemplates);
expect(count).toBe(2);
const templates = await prisma.mcpTemplate.findMany();
expect(templates).toHaveLength(2);
});
it('seeds env correctly', async () => {
await seedTemplates(prisma, testTemplates);
const github = await prisma.mcpTemplate.findUnique({ where: { name: 'github' } });
const env = github!.env as Array<{ name: string; description?: string; required?: boolean }>;
expect(env).toHaveLength(1);
expect(env[0].name).toBe('GITHUB_TOKEN');
expect(env[0].required).toBe(true);
});
it('accepts custom template list', async () => {
const custom: SeedTemplate[] = [
{
name: 'custom-template',
version: '2.0.0',
description: 'Custom test template',
packageName: '@test/custom',
transport: 'STDIO',
env: [],
},
];
const count = await seedTemplates(prisma, custom);
expect(count).toBe(1);
const templates = await prisma.mcpTemplate.findMany();
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('custom-template');
});
});
}); // close 'db models' wrapper

View File

@@ -1,86 +0,0 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
import { seedTemplates } from '../src/seed/index.js';
import type { SeedTemplate } from '../src/seed/index.js';
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDb();
}, 30_000);
afterAll(async () => {
await cleanupTestDb();
});
beforeEach(async () => {
await clearAllTables(prisma);
});
const testTemplates: SeedTemplate[] = [
{
name: 'github',
version: '1.0.0',
description: 'GitHub MCP server',
packageName: '@anthropic/github-mcp',
transport: 'STDIO',
env: [{ name: 'GITHUB_TOKEN', description: 'Personal access token', required: true }],
},
{
name: 'slack',
version: '1.0.0',
description: 'Slack MCP server',
packageName: '@anthropic/slack-mcp',
transport: 'STDIO',
env: [],
},
];
describe('seedTemplates', () => {
it('seeds templates', async () => {
const count = await seedTemplates(prisma, testTemplates);
expect(count).toBe(2);
const templates = await prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
expect(templates).toHaveLength(2);
expect(templates.map((t) => t.name)).toEqual(['github', 'slack']);
});
it('is idempotent (upsert)', async () => {
await seedTemplates(prisma, testTemplates);
const count = await seedTemplates(prisma, testTemplates);
expect(count).toBe(2);
const templates = await prisma.mcpTemplate.findMany();
expect(templates).toHaveLength(2);
});
it('seeds env correctly', async () => {
await seedTemplates(prisma, testTemplates);
const github = await prisma.mcpTemplate.findUnique({ where: { name: 'github' } });
const env = github!.env as Array<{ name: string; description?: string; required?: boolean }>;
expect(env).toHaveLength(1);
expect(env[0].name).toBe('GITHUB_TOKEN');
expect(env[0].required).toBe(true);
});
it('accepts custom template list', async () => {
const custom: SeedTemplate[] = [
{
name: 'custom-template',
version: '2.0.0',
description: 'Custom test template',
packageName: '@test/custom',
transport: 'STDIO',
env: [],
},
];
const count = await seedTemplates(prisma, custom);
expect(count).toBe(1);
const templates = await prisma.mcpTemplate.findMany();
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('custom-template');
});
});

View File

@@ -1,10 +1,10 @@
import { defineProject } from 'vitest/config';
import { defineConfig } from 'vitest/config';
export default defineProject({
export default defineConfig({
test: {
name: 'db',
include: ['tests/**/*.test.ts'],
// Test files share the same database — run sequentially
fileParallelism: false,
// Schema pushed once by globalSetup before any tests.
globalSetup: ['tests/global-setup.ts'],
},
});

View File

@@ -1,6 +1,6 @@
{
"name": "@mcpctl/mcpd",
"version": "0.1.0",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",

View File

@@ -23,14 +23,11 @@ const SYSTEM_PROMPTS: SystemPromptDef[] = [
{
name: 'gate-instructions',
priority: 10,
content: `This project uses a gated session. Before you can access tools, you must describe your current task by calling begin_session with 3-7 keywords.
content: `This project uses a gated session. Before you can access tools, you must start a session by calling begin_session.
After calling begin_session, you will receive:
1. Relevant project prompts matched to your keywords
2. A list of other available prompts
3. Full access to all project tools
Call begin_session immediately using the arguments it requires (check its input schema). If it accepts a description, briefly describe the user's task. If it accepts tags, provide 3-7 keywords relevant to the user's request.
Choose your keywords carefully — they determine which context you receive.`,
The available tools and prompts are listed below. After calling begin_session, you will receive relevant project context and full access to all tools.`,
},
{
name: 'gate-encouragement',
@@ -46,12 +43,19 @@ It is better to check and not need it than to proceed without important context.
Review this context carefully — it may contain important guidelines, constraints, or patterns relevant to your work. If you need more context, use read_prompts({ tags: [...] }) at any time.`,
},
{
name: 'gate-session-active',
priority: 10,
content: `The session is now active with full tool access. Proceed with the user's original request using the tools listed above.`,
},
{
name: 'session-greeting',
priority: 10,
content: `Welcome to this project. To get started, call begin_session with keywords describing your task.
content: `Welcome to this project. To get started, call begin_session with the arguments it requires.
Example: begin_session({ tags: ["zigbee", "pairing", "mqtt"] })
Examples:
begin_session({ tags: ["zigbee", "pairing", "mqtt"] })
begin_session({ description: "I want to pair a new Zigbee device" })
This will load relevant project context, policies, and guidelines tailored to your work.`,
},

View File

@@ -35,7 +35,10 @@ export class PromptRepository implements IPromptRepository {
}
async findById(id: string): Promise<Prompt | null> {
return this.prisma.prompt.findUnique({ where: { id } });
return this.prisma.prompt.findUnique({
where: { id },
include: { project: { select: { name: true } } },
});
}
async findByNameAndProject(name: string, projectId: string | null): Promise<Prompt | null> {

View File

@@ -6,6 +6,7 @@ import type {
ContainerInfo,
ContainerLogs,
ExecResult,
InteractiveExec,
} from '../orchestrator.js';
import { DEFAULT_MEMORY_LIMIT } from '../orchestrator.js';
@@ -239,4 +240,32 @@ export class DockerContainerManager implements McpOrchestrator {
});
});
}
async execInteractive(containerId: string, cmd: string[]): Promise<InteractiveExec> {
const container = this.docker.getContainer(containerId);
const exec = await container.exec({
Cmd: cmd,
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({ hijack: true, stdin: true });
// Demux Docker's multiplexed stream into separate stdout/stderr
const stdout = new PassThrough();
const stderr = new PassThrough();
this.docker.modem.demuxStream(stream, stdout, stderr);
return {
stdout,
write(data: string) {
stream.write(data);
},
close() {
try { stream.end(); } catch { /* ignore */ }
},
};
}
}

View File

@@ -218,7 +218,7 @@ export class HealthProbeRunner {
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.1.0' } },
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.0.1' } },
}),
signal: controller.signal,
});
@@ -333,7 +333,7 @@ export class HealthProbeRunner {
method: 'POST', headers: postHeaders,
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.1.0' } },
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.0.1' } },
}),
signal: controller.signal,
});
@@ -424,9 +424,16 @@ export class HealthProbeRunner {
const start = Date.now();
const packageName = server.packageName as string | null;
const command = server.command as string[] | null;
if (!packageName) {
return { healthy: false, latencyMs: 0, message: 'No package name for STDIO server' };
// Determine how to spawn the MCP server inside the container
let spawnCmd: string[];
if (packageName) {
spawnCmd = ['npx', '--prefer-offline', '-y', packageName];
} else if (command && command.length > 0) {
spawnCmd = command;
} else {
return { healthy: false, latencyMs: 0, message: 'No packageName or command for STDIO server' };
}
// Build JSON-RPC messages for the health probe
@@ -435,7 +442,7 @@ export class HealthProbeRunner {
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-health', version: '0.1.0' },
clientInfo: { name: 'mcpctl-health', version: '0.0.1' },
},
});
const initializedMsg = JSON.stringify({
@@ -447,13 +454,15 @@ export class HealthProbeRunner {
});
// Use a Node.js inline script that:
// 1. Spawns the MCP server binary via npx
// 1. Spawns the MCP server binary
// 2. Sends initialize + initialized + tool call via stdin
// 3. Reads responses from stdout
// 4. Exits with 0 if tool call succeeds, 1 if it fails
const spawnArgs = JSON.stringify(spawnCmd);
const probeScript = `
const { spawn } = require('child_process');
const proc = spawn('npx', ['--prefer-offline', '-y', ${JSON.stringify(packageName)}], { stdio: ['pipe', 'pipe', 'pipe'] });
const args = ${spawnArgs};
const proc = spawn(args[0], args.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
let output = '';
let responded = false;
proc.stdout.on('data', d => {

View File

@@ -5,6 +5,7 @@ import { NotFoundError } from './mcp-server.service.js';
import { InvalidStateError } from './instance.service.js';
import { sendViaSse } from './transport/sse-client.js';
import { sendViaStdio } from './transport/stdio-client.js';
import { PersistentStdioClient } from './transport/persistent-stdio.js';
export interface McpProxyRequest {
serverId: string;
@@ -37,6 +38,8 @@ function parseStreamableResponse(body: string): McpProxyResponse {
export class McpProxyService {
/** Session IDs per server for streamable-http protocol */
private sessions = new Map<string, string>();
/** Persistent STDIO connections keyed by containerId */
private stdioClients = new Map<string, PersistentStdioClient>();
constructor(
private readonly instanceRepo: IMcpInstanceRepository,
@@ -44,6 +47,23 @@ export class McpProxyService {
private readonly orchestrator?: McpOrchestrator,
) {}
/** Clean up all persistent connections (call on shutdown). */
closeAll(): void {
for (const [, client] of this.stdioClients) {
client.close();
}
this.stdioClients.clear();
}
/** Remove persistent connection for a container (call when instance stops). */
removeClient(containerId: string): void {
const client = this.stdioClients.get(containerId);
if (client) {
client.close();
this.stdioClients.delete(containerId);
}
}
async execute(request: McpProxyRequest): Promise<McpProxyResponse> {
const server = await this.serverRepo.findById(request.serverId);
if (!server) {
@@ -95,7 +115,7 @@ export class McpProxyService {
): Promise<McpProxyResponse> {
const transport = server.transport as string;
// STDIO: use docker exec
// STDIO: use persistent connection (falls back to one-shot on error)
if (transport === 'STDIO') {
if (!this.orchestrator) {
throw new InvalidStateError('Orchestrator required for STDIO transport');
@@ -104,10 +124,24 @@ export class McpProxyService {
throw new InvalidStateError(`Instance '${instance.id}' has no container ID`);
}
const packageName = server.packageName as string | null;
if (!packageName) {
throw new InvalidStateError(`Server '${server.id}' has no package name for STDIO transport`);
const command = server.command as string[] | null;
if (!packageName && (!command || command.length === 0)) {
throw new InvalidStateError(`Server '${server.id}' has no packageName or command for STDIO transport`);
}
// Build the spawn command for persistent mode
const spawnCmd = command && command.length > 0
? command
: ['npx', '--prefer-offline', '-y', packageName!];
// Try persistent connection first
try {
return await this.sendViaPersistentStdio(instance.containerId, spawnCmd, method, params);
} catch {
// Persistent failed — fall back to one-shot
this.removeClient(instance.containerId);
return sendViaStdio(this.orchestrator, instance.containerId, packageName, method, params, 120_000, command);
}
return sendViaStdio(this.orchestrator, instance.containerId, packageName, method, params);
}
// SSE or STREAMABLE_HTTP: need a base URL
@@ -121,6 +155,23 @@ export class McpProxyService {
return this.sendStreamableHttp(server.id, baseUrl, method, params);
}
/**
* Send via a persistent STDIO connection (reused across calls).
*/
private async sendViaPersistentStdio(
containerId: string,
command: string[],
method: string,
params?: Record<string, unknown>,
): Promise<McpProxyResponse> {
let client = this.stdioClients.get(containerId);
if (!client) {
client = new PersistentStdioClient(this.orchestrator!, containerId, command);
this.stdioClients.set(containerId, client);
}
return client.send(method, params);
}
/**
* Resolve the base URL for an HTTP-based managed server.
* Prefers container internal IP on Docker network, falls back to localhost:port.
@@ -218,7 +269,7 @@ export class McpProxyService {
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'mcpctl', version: '0.1.0' },
clientInfo: { name: 'mcpctl', version: '0.0.1' },
},
};

View File

@@ -68,10 +68,23 @@ export interface McpOrchestrator {
/** Execute a command inside a running container with optional stdin */
execInContainer(containerId: string, cmd: string[], opts?: { stdin?: string; timeoutMs?: number }): Promise<ExecResult>;
/** Start a long-running interactive exec session (bidirectional stdio stream). */
execInteractive?(containerId: string, cmd: string[]): Promise<InteractiveExec>;
/** Check if the orchestrator runtime is available */
ping(): Promise<boolean>;
}
/** A bidirectional stream to an interactive exec session. */
export interface InteractiveExec {
/** Demuxed stdout stream (JSON-RPC responses come here). */
stdout: NodeJS.ReadableStream;
/** Write raw bytes to the process stdin. */
write(data: string): void;
/** Kill the exec process. */
close(): void;
}
/** Default resource limits */
export const DEFAULT_MEMORY_LIMIT = 512 * 1024 * 1024; // 512 MB
export const DEFAULT_NANO_CPUS = 500_000_000; // 0.5 CPU

View File

@@ -176,20 +176,20 @@ export class PromptService {
async getVisiblePrompts(
projectId?: string,
sessionId?: string,
): Promise<Array<{ name: string; content: string; type: 'prompt' | 'promptrequest' }>> {
const results: Array<{ name: string; content: string; type: 'prompt' | 'promptrequest' }> = [];
): Promise<Array<{ name: string; content: string; priority: number; summary: string | null; chapters: string[] | null; linkTarget: string | null; type: 'prompt' | 'promptrequest' }>> {
const results: Array<{ name: string; content: string; priority: number; summary: string | null; chapters: string[] | null; linkTarget: string | null; type: 'prompt' | 'promptrequest' }> = [];
// Approved prompts (project-scoped + global)
const prompts = await this.promptRepo.findAll(projectId);
for (const p of prompts) {
results.push({ name: p.name, content: p.content, type: 'prompt' });
results.push({ name: p.name, content: p.content, priority: p.priority, summary: p.summary, chapters: p.chapters as string[] | null, linkTarget: p.linkTarget, type: 'prompt' });
}
// Session's own pending requests
if (sessionId) {
const requests = await this.promptRequestRepo.findBySession(sessionId, projectId);
for (const r of requests) {
results.push({ name: r.name, content: r.content, type: 'promptrequest' });
results.push({ name: r.name, content: r.content, priority: 5, summary: null, chapters: null, linkTarget: null, type: 'promptrequest' });
}
}

View File

@@ -0,0 +1,188 @@
import type { McpOrchestrator, InteractiveExec } from '../orchestrator.js';
import type { McpProxyResponse } from '../mcp-proxy-service.js';
/**
* Persistent STDIO connection to an MCP server running inside a Docker container.
*
* Instead of cold-starting a new process per call (docker exec one-shot), this keeps
* a long-running `docker exec -i <cmd>` session alive. The MCP init handshake runs
* once, then tool calls are multiplexed over the same stdin/stdout pipe.
*
* Falls back gracefully: if the process dies, the next call will reconnect.
*/
export class PersistentStdioClient {
private exec: InteractiveExec | null = null;
private buffer = '';
private nextId = 1;
private initialized = false;
private connecting: Promise<void> | null = null;
private pendingRequests = new Map<number, {
resolve: (res: McpProxyResponse) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>();
constructor(
private readonly orchestrator: McpOrchestrator,
private readonly containerId: string,
private readonly command: string[],
private readonly timeoutMs = 120_000,
) {}
/**
* Send a JSON-RPC request and wait for the matching response.
*/
async send(method: string, params?: Record<string, unknown>): Promise<McpProxyResponse> {
await this.ensureReady();
const id = this.nextId++;
const request: Record<string, unknown> = { jsonrpc: '2.0', id, method };
if (params !== undefined) {
request.params = params;
}
return new Promise<McpProxyResponse>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request timed out after ${this.timeoutMs}ms`));
}, this.timeoutMs);
this.pendingRequests.set(id, { resolve, reject, timer });
this.write(request);
});
}
/** Shut down the persistent connection. */
close(): void {
if (this.exec) {
this.exec.close();
this.exec = null;
}
this.initialized = false;
this.connecting = null;
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
}
get isConnected(): boolean {
return this.initialized && this.exec !== null;
}
// ── internals ──
private async ensureReady(): Promise<void> {
if (this.initialized && this.exec) return;
if (this.connecting) {
await this.connecting;
return;
}
this.connecting = this.connect();
try {
await this.connecting;
} finally {
this.connecting = null;
}
}
private async connect(): Promise<void> {
this.close();
if (!this.orchestrator.execInteractive) {
throw new Error('Orchestrator does not support interactive exec');
}
const exec = await this.orchestrator.execInteractive(this.containerId, this.command);
this.exec = exec;
this.buffer = '';
// Parse JSON-RPC responses line by line from stdout
exec.stdout.on('data', (chunk: Buffer) => {
this.buffer += chunk.toString('utf-8');
this.processBuffer();
});
exec.stdout.on('end', () => {
this.initialized = false;
this.exec = null;
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('STDIO process exited'));
}
this.pendingRequests.clear();
});
// Run MCP init handshake
const initId = this.nextId++;
const initPromise = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(initId);
reject(new Error('MCP init handshake timed out'));
}, 30_000);
this.pendingRequests.set(initId, {
resolve: () => {
clearTimeout(timer);
resolve();
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
timer,
});
});
this.write({
jsonrpc: '2.0',
id: initId,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-proxy', version: '0.0.1' },
},
});
await initPromise;
// Send initialized notification (no response expected)
this.write({ jsonrpc: '2.0', method: 'notifications/initialized' });
// Small delay to let the server process the notification
await new Promise((r) => setTimeout(r, 100));
this.initialized = true;
}
private write(msg: Record<string, unknown>): void {
if (!this.exec) throw new Error('Not connected');
this.exec.write(JSON.stringify(msg) + '\n');
}
private processBuffer(): void {
const lines = this.buffer.split('\n');
this.buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed) as Record<string, unknown>;
if ('id' in msg && msg.id !== undefined) {
const pending = this.pendingRequests.get(msg.id as number);
if (pending) {
this.pendingRequests.delete(msg.id as number);
clearTimeout(pending.timer);
pending.resolve(msg as unknown as McpProxyResponse);
}
}
// Notifications from server are ignored (not needed for proxy)
} catch {
// Skip non-JSON lines
}
}
}
}

View File

@@ -73,7 +73,7 @@ export async function sendViaSse(
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-proxy', version: '0.1.0' },
clientInfo: { name: 'mcpctl-proxy', version: '0.0.1' },
},
}),
signal: controller.signal,

View File

@@ -12,10 +12,11 @@ import type { McpProxyResponse } from '../mcp-proxy-service.js';
export async function sendViaStdio(
orchestrator: McpOrchestrator,
containerId: string,
packageName: string,
packageName: string | null,
method: string,
params?: Record<string, unknown>,
timeoutMs = 30_000,
command?: string[] | null,
): Promise<McpProxyResponse> {
const initMsg = JSON.stringify({
jsonrpc: '2.0',
@@ -24,7 +25,7 @@ export async function sendViaStdio(
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-proxy', version: '0.1.0' },
clientInfo: { name: 'mcpctl-proxy', version: '0.0.1' },
},
});
const initializedMsg = JSON.stringify({
@@ -42,14 +43,26 @@ export async function sendViaStdio(
}
const requestMsg = JSON.stringify(requestBody);
// Determine spawn command
let spawnCmd: string[];
if (packageName) {
spawnCmd = ['npx', '--prefer-offline', '-y', packageName];
} else if (command && command.length > 0) {
spawnCmd = command;
} else {
return errorResponse('No packageName or command for STDIO server');
}
const spawnArgs = JSON.stringify(spawnCmd);
// Inline Node.js script that:
// 1. Spawns the MCP server binary via npx
// 1. Spawns the MCP server binary
// 2. Sends initialize → initialized → actual request via stdin
// 3. Reads stdout for JSON-RPC response with id: 2
// 4. Outputs the full JSON-RPC response to stdout
const probeScript = `
const { spawn } = require('child_process');
const proc = spawn('npx', ['--prefer-offline', '-y', ${JSON.stringify(packageName)}], { stdio: ['pipe', 'pipe', 'pipe'] });
const args = ${spawnArgs};
const proc = spawn(args[0], args.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
let output = '';
let responded = false;
proc.stdout.on('data', d => {

View File

@@ -301,7 +301,7 @@ describe('RestoreService', () => {
const validBundle = {
version: '1',
mcpctlVersion: '0.1.0',
mcpctlVersion: '0.0.1',
createdAt: new Date().toISOString(),
encrypted: false,
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, env: [] }],

View File

@@ -1,6 +1,6 @@
{
"name": "@mcpctl/mcplocal",
"version": "0.1.0",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",

View File

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

View 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();
});
}

View File

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

View File

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

View 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;
}
}

View File

@@ -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[] {

View File

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

View File

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

View File

@@ -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([]);
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@mcpctl/shared",
"version": "0.1.0",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,5 @@
// Shared constants
export const APP_NAME = 'mcpctl';
export const APP_VERSION = '0.1.0';
export const APP_VERSION = '0.0.1';
export const DEFAULT_MCPD_URL = 'http://localhost:3000';
export const DEFAULT_DB_PORT = 5432;

View File

@@ -7,7 +7,7 @@ describe('shared package', () => {
});
it('exports APP_VERSION constant', () => {
expect(APP_VERSION).toBe('0.1.0');
expect(APP_VERSION).toBe('0.0.1');
});
it('exports DEFAULT_MCPD_URL constant', () => {