feat: eager vLLM warmup and smart page titles in paginate stage

- Add warmup() to LlmProvider interface for eager subprocess startup
- ManagedVllmProvider.warmup() starts vLLM in background on project load
- ProviderRegistry.warmupAll() triggers all managed providers
- NamedProvider proxies warmup() to inner provider
- paginate stage generates LLM-powered descriptive page titles when
  available, cached by content hash, falls back to generic "Page N"
- project-mcp-endpoint calls warmupAll() on router creation so vLLM
  is loading while the session initializes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-03 19:07:39 +00:00
parent 0427d7dc1a
commit 03827f11e4
147 changed files with 17561 additions and 2093 deletions

View File

@@ -21,6 +21,7 @@
"@mcpctl/shared": "workspace:*",
"chalk": "^5.4.0",
"commander": "^13.0.0",
"diff": "^8.0.3",
"ink": "^6.8.0",
"inquirer": "^12.0.0",
"js-yaml": "^4.1.0",
@@ -28,6 +29,7 @@
"zod": "^3.24.0"
},
"devDependencies": {
"@types/diff": "^8.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14"

View File

@@ -24,6 +24,7 @@ const ServerSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
packageName: z.string().optional(),
runtime: z.string().optional(),
dockerImage: z.string().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().url().optional(),
@@ -52,6 +53,7 @@ const TemplateSpecSchema = z.object({
version: z.string().default('1.0.0'),
description: z.string().default(''),
packageName: z.string().optional(),
runtime: z.string().optional(),
dockerImage: z.string().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().optional(),
@@ -124,6 +126,7 @@ const ProjectSpecSchema = z.object({
description: z.string().default(''),
prompt: z.string().max(10000).default(''),
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
proxyModel: z.string().optional(),
gated: z.boolean().default(true),
llmProvider: z.string().optional(),
llmModel: z.string().optional(),

View File

@@ -1,8 +1,10 @@
import { Command } from 'commander';
import http from 'node:http';
import https from 'node:https';
import { existsSync } from 'node:fs';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { homedir } from 'node:os';
import { loadConfig, saveConfig } from '../config/index.js';
import type { ConfigLoaderDeps, McpctlConfig, LlmConfig, LlmProviderName, LlmProviderEntry, LlmTier } from '../config/index.js';
import type { SecretStore } from '@mcpctl/shared';
@@ -37,11 +39,19 @@ interface ProviderFields {
model?: string;
url?: string;
binaryPath?: string;
venvPath?: string;
port?: number;
gpuMemoryUtilization?: number;
maxModelLen?: number;
idleTimeoutMinutes?: number;
extraArgs?: string[];
}
const FAST_PROVIDER_CHOICES: ProviderChoice[] = [
{ name: 'vLLM', value: 'vllm', description: 'Self-hosted vLLM (OpenAI-compatible)' },
{ name: 'Run vLLM Instance', value: 'vllm-managed', description: 'Auto-managed local vLLM (starts/stops with mcplocal)' },
{ name: 'vLLM (external)', value: 'vllm', description: 'Self-hosted vLLM (OpenAI-compatible)' },
{ name: 'Ollama', value: 'ollama', description: 'Local models via Ollama' },
{ name: 'Anthropic (Claude)', value: 'anthropic', description: 'Claude Haiku — fast & cheap' },
];
const HEAVY_PROVIDER_CHOICES: ProviderChoice[] = [
@@ -55,10 +65,10 @@ const ALL_PROVIDER_CHOICES: ProviderChoice[] = [
...FAST_PROVIDER_CHOICES,
...HEAVY_PROVIDER_CHOICES,
{ name: 'None (disable)', value: 'none', description: 'Disable LLM features' },
];
] as ProviderChoice[];
const GEMINI_MODELS = ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'];
const ANTHROPIC_MODELS = ['claude-haiku-3-5-20241022', 'claude-sonnet-4-20250514', 'claude-opus-4-20250514'];
const ANTHROPIC_MODELS = ['claude-haiku-3-5-20241022', 'claude-sonnet-4-20250514', 'claude-sonnet-4-5-20250514', 'claude-opus-4-20250514'];
const DEEPSEEK_MODELS = ['deepseek-chat', 'deepseek-reasoner'];
function defaultFetchModels(baseUrl: string, path: string): Promise<string[]> {
@@ -254,6 +264,40 @@ async function setupVllmFields(
return result;
}
async function setupVllmManagedFields(
prompt: ConfigSetupPrompt,
log: (...args: string[]) => void,
): Promise<ProviderFields> {
const defaultVenv = '~/vllm_env';
const venvPath = await prompt.input('vLLM venv path:', defaultVenv);
// Validate venv exists
const expandedPath = venvPath.startsWith('~') ? venvPath.replace('~', homedir()) : venvPath;
const vllmBin = `${expandedPath}/bin/vllm`;
if (!existsSync(vllmBin)) {
log(`Warning: ${vllmBin} not found.`);
log(` Create it with: uv venv ${venvPath} --python 3.12 && ${expandedPath}/bin/pip install vllm`);
} else {
log(`Found vLLM at: ${vllmBin}`);
}
const model = await prompt.input('Model to serve:', 'Qwen/Qwen2.5-7B-Instruct-AWQ');
const gpuStr = await prompt.input('GPU memory utilization (0.11.0):', '0.75');
const gpuMemoryUtilization = parseFloat(gpuStr) || 0.75;
const idleStr = await prompt.input('Stop after N minutes idle:', '15');
const idleTimeoutMinutes = parseInt(idleStr, 10) || 15;
const portStr = await prompt.input('Port:', '8000');
const port = parseInt(portStr, 10) || 8000;
return {
model,
venvPath,
port,
gpuMemoryUtilization,
idleTimeoutMinutes,
};
}
async function setupApiKeyFields(
prompt: ConfigSetupPrompt,
secretStore: SecretStore,
@@ -306,6 +350,70 @@ async function setupApiKeyFields(
return result;
}
async function promptForAnthropicKey(
prompt: ConfigSetupPrompt,
log: (...args: string[]) => void,
whichBinary: (name: string) => Promise<string | null>,
): Promise<string> {
const claudePath = await whichBinary('claude');
if (claudePath) {
log(`Found Claude CLI at: ${claudePath}`);
const useOAuth = await prompt.confirm(
'Generate free token via Claude CLI? (requires Pro/Max subscription)', true);
if (useOAuth) {
log('');
log(' Run: claude setup-token');
log(' Then paste the token below (starts with sk-ant-oat01-)');
log('');
return prompt.password('OAuth token:');
}
} else {
log('Tip: Install Claude CLI (npm i -g @anthropic-ai/claude-code) to generate');
log(' a free OAuth token with "claude setup-token" (Pro/Max subscription).');
log('');
}
return prompt.password('API key (from console.anthropic.com):');
}
async function setupAnthropicFields(
prompt: ConfigSetupPrompt,
secretStore: SecretStore,
log: (...args: string[]) => void,
whichBinary: (name: string) => Promise<string | null>,
currentModel?: string,
): Promise<ProviderFields> {
const existingKey = await secretStore.get('anthropic-api-key');
let apiKey: string;
if (existingKey) {
const isOAuth = existingKey.startsWith('sk-ant-oat');
const masked = `****${existingKey.slice(-4)}`;
const label = isOAuth ? `OAuth token stored (${masked})` : `API key stored (${masked})`;
const changeKey = await prompt.confirm(`${label}. Change it?`, false);
apiKey = changeKey ? await promptForAnthropicKey(prompt, log, whichBinary) : existingKey;
} else {
apiKey = await promptForAnthropicKey(prompt, log, whichBinary);
}
if (apiKey !== existingKey) {
await secretStore.set('anthropic-api-key', apiKey);
}
const choices = ANTHROPIC_MODELS.map((m) => ({
name: m === currentModel ? `${m} (current)` : m,
value: m,
}));
choices.push({ name: 'Custom...', value: '__custom__' });
let model = await prompt.select<string>('Select model:', choices);
if (model === '__custom__') {
model = await prompt.input('Model name:', currentModel);
}
return { model };
}
/** Configure a single provider type and return its fields. */
async function setupProviderFields(
providerType: LlmProviderName,
@@ -322,8 +430,10 @@ async function setupProviderFields(
return setupOllamaFields(prompt, fetchModels);
case 'vllm':
return setupVllmFields(prompt, fetchModels);
case 'vllm-managed':
return setupVllmManagedFields(prompt, log);
case 'anthropic':
return setupApiKeyFields(prompt, secretStore, 'anthropic', 'anthropic-api-key', ANTHROPIC_MODELS);
return setupAnthropicFields(prompt, secretStore, log, whichBinary);
case 'openai':
return setupApiKeyFields(prompt, secretStore, 'openai', 'openai-api-key', []);
case 'deepseek':
@@ -339,6 +449,12 @@ function buildEntry(providerType: LlmProviderName, name: string, fields: Provide
if (fields.model) entry.model = fields.model;
if (fields.url) entry.url = fields.url;
if (fields.binaryPath) entry.binaryPath = fields.binaryPath;
if (fields.venvPath) entry.venvPath = fields.venvPath;
if (fields.port !== undefined) entry.port = fields.port;
if (fields.gpuMemoryUtilization !== undefined) entry.gpuMemoryUtilization = fields.gpuMemoryUtilization;
if (fields.maxModelLen !== undefined) entry.maxModelLen = fields.maxModelLen;
if (fields.idleTimeoutMinutes !== undefined) entry.idleTimeoutMinutes = fields.idleTimeoutMinutes;
if (fields.extraArgs !== undefined) entry.extraArgs = fields.extraArgs;
if (tier) entry.tier = tier;
return entry;
}
@@ -379,6 +495,14 @@ async function simpleSetup(
log('Restart mcplocal: systemctl --user restart mcplocal');
}
/** Generate a unique default name given names already in use. */
function uniqueDefaultName(baseName: string, usedNames: Set<string>): string {
if (!usedNames.has(baseName)) return baseName;
let i = 2;
while (usedNames.has(`${baseName}-${i}`)) i++;
return `${baseName}-${i}`;
}
/** Advanced mode: multiple providers with tier assignments. */
async function advancedSetup(
config: McpctlConfig,
@@ -390,6 +514,7 @@ async function advancedSetup(
secretStore: SecretStore,
): Promise<void> {
const entries: LlmProviderEntry[] = [];
const usedNames = new Set<string>();
// Fast providers
const addFast = await prompt.confirm('Add a FAST provider? (vLLM, Ollama — local, cheap, fast)', true);
@@ -397,8 +522,10 @@ async function advancedSetup(
let addMore = true;
while (addMore) {
const providerType = await prompt.select<LlmProviderName>('Fast provider type:', FAST_PROVIDER_CHOICES);
const defaultName = providerType === 'vllm' ? 'vllm-local' : providerType;
const rawDefault = providerType === 'vllm' || providerType === 'vllm-managed' ? 'vllm-local' : providerType;
const defaultName = uniqueDefaultName(rawDefault, usedNames);
const name = await prompt.input('Provider name:', defaultName);
usedNames.add(name);
const fields = await setupProviderFields(providerType, prompt, log, fetchModels, whichBinary, secretStore);
entries.push(buildEntry(providerType, name, fields, 'fast'));
log(` Added: ${name} (${providerType}) → fast tier`);
@@ -412,8 +539,9 @@ async function advancedSetup(
let addMore = true;
while (addMore) {
const providerType = await prompt.select<LlmProviderName>('Heavy provider type:', HEAVY_PROVIDER_CHOICES);
const defaultName = providerType;
const defaultName = uniqueDefaultName(providerType, usedNames);
const name = await prompt.input('Provider name:', defaultName);
usedNames.add(name);
const fields = await setupProviderFields(providerType, prompt, log, fetchModels, whichBinary, secretStore);
entries.push(buildEntry(providerType, name, fields, 'heavy'));
log(` Added: ${name} (${providerType}) → heavy tier`);

View File

@@ -111,7 +111,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
if (opts.inspect) {
servers['mcpctl-inspect'] = {
command: 'mcpctl',
args: ['console', '--inspect', '--stdin-mcp'],
args: ['console', '--stdin-mcp'],
};
}

View File

@@ -1,368 +0,0 @@
import { useState, useEffect, useCallback, createContext, useContext } from 'react';
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
import { McpSession } from './mcp-session.js';
import type { LogEntry } from './mcp-session.js';
import { Header } from './components/header.js';
import { ProtocolLog } from './components/protocol-log.js';
import { ConnectingView } from './components/connecting-view.js';
import { MainMenu } from './components/main-menu.js';
import { BeginSessionView } from './components/begin-session.js';
import { ToolListView } from './components/tool-list.js';
import { ToolDetailView } from './components/tool-detail.js';
import { ResourceListView } from './components/resource-list.js';
import { PromptListView } from './components/prompt-list.js';
import { RawJsonRpcView } from './components/raw-jsonrpc.js';
import { ResultView } from './components/result-view.js';
import type { McpTool, McpResource, McpPrompt, InitializeResult } from './mcp-session.js';
// ── Types ──
type View =
| { type: 'connecting' }
| { type: 'main' }
| { type: 'begin-session' }
| { type: 'tools' }
| { type: 'tool-detail'; tool: McpTool }
| { type: 'resources' }
| { type: 'resource-detail'; resource: McpResource; content: string }
| { type: 'prompts' }
| { type: 'prompt-detail'; prompt: McpPrompt; content: unknown }
| { type: 'raw' }
| { type: 'result'; title: string; data: unknown };
interface AppState {
view: View[];
gated: boolean;
initResult: InitializeResult | null;
tools: McpTool[];
resources: McpResource[];
prompts: McpPrompt[];
logEntries: LogEntry[];
error: string | null;
reconnecting: boolean;
}
// ── Context ──
interface SessionContextValue {
session: McpSession;
projectName: string;
endpointUrl: string;
token?: string;
}
const SessionContext = createContext<SessionContextValue>(null!);
export const useSession = (): SessionContextValue => useContext(SessionContext);
// ── Root App ──
interface AppProps {
projectName: string;
endpointUrl: string;
token?: string;
}
function App({ projectName, endpointUrl, token }: AppProps) {
const { exit } = useApp();
const { stdout } = useStdout();
const termHeight = stdout?.rows ?? 24;
const logHeight = Math.max(6, Math.min(12, Math.floor(termHeight * 0.3)));
const [session, setSession] = useState(() => new McpSession(endpointUrl, token));
const [state, setState] = useState<AppState>({
view: [{ type: 'connecting' }],
gated: false,
initResult: null,
tools: [],
resources: [],
prompts: [],
logEntries: [],
error: null,
reconnecting: false,
});
const currentView = state.view[state.view.length - 1]!;
// Log callback
const handleLog = useCallback((entry: LogEntry) => {
setState((s) => ({ ...s, logEntries: [...s.logEntries, entry] }));
}, []);
useEffect(() => {
session.onLog = handleLog;
}, [session, handleLog]);
// Navigation
const pushView = useCallback((v: View) => {
setState((s) => ({ ...s, view: [...s.view, v], error: null }));
}, []);
const popView = useCallback(() => {
setState((s) => {
if (s.view.length <= 1) return s;
return { ...s, view: s.view.slice(0, -1), error: null };
});
}, []);
const setError = useCallback((msg: string) => {
setState((s) => ({ ...s, error: msg }));
}, []);
// Initialize connection
const connect = useCallback(async (sess: McpSession) => {
try {
const initResult = await sess.initialize();
const tools = await sess.listTools();
// Detect gated: only begin_session tool available
const gated = tools.length === 1 && tools[0]?.name === 'begin_session';
setState((s) => ({
...s,
initResult,
tools,
gated,
reconnecting: false,
view: [{ type: 'main' }],
}));
// If not gated, also fetch resources and prompts
if (!gated) {
try {
const [resources, prompts] = await Promise.all([
sess.listResources(),
sess.listPrompts(),
]);
setState((s) => ({ ...s, resources, prompts }));
} catch {
// Non-fatal
}
}
} catch (err) {
setState((s) => ({
...s,
error: `Connection failed: ${err instanceof Error ? err.message : String(err)}`,
reconnecting: false,
view: [{ type: 'main' }],
}));
}
}, []);
// Initial connect
useEffect(() => {
connect(session);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Reconnect (new session)
const reconnect = useCallback(async () => {
setState((s) => ({ ...s, reconnecting: true, logEntries: [], error: null }));
await session.close().catch(() => {});
const newSession = new McpSession(endpointUrl, token);
newSession.onLog = handleLog;
setSession(newSession);
setState((s) => ({ ...s, view: [{ type: 'connecting' }] }));
await connect(newSession);
}, [session, endpointUrl, token, handleLog, connect]);
// After begin_session, refresh tools/resources/prompts
const onSessionBegan = useCallback(async (result: unknown) => {
pushView({ type: 'result', title: 'Session Started', data: result });
setState((s) => ({ ...s, gated: false }));
try {
const [tools, resources, prompts] = await Promise.all([
session.listTools(),
session.listResources(),
session.listPrompts(),
]);
setState((s) => ({ ...s, tools, resources, prompts }));
} catch {
// Non-fatal
}
}, [session, pushView]);
// Global keyboard shortcuts
useInput((input, key) => {
if (currentView.type === 'raw' || currentView.type === 'begin-session' || currentView.type === 'tool-detail') {
// Don't capture single-char shortcuts when text input is active
if (key.escape) popView();
return;
}
if (input === 'q' && !key.ctrl) {
session.close().catch(() => {});
exit();
return;
}
if (key.escape) {
popView();
return;
}
if (input === 'n') {
reconnect();
return;
}
if (input === 'r') {
pushView({ type: 'raw' });
return;
}
});
// Cleanup on unmount
useEffect(() => {
return () => {
session.close().catch(() => {});
};
}, [session]);
const contentHeight = Math.max(1, termHeight - logHeight - 4); // 4 for header + mode bar + borders
return (
<SessionContext.Provider value={{ session, projectName, endpointUrl, token }}>
<Box flexDirection="column" height={termHeight}>
<Header
projectName={projectName}
sessionId={session.getSessionId()}
gated={state.gated}
reconnecting={state.reconnecting}
/>
{state.error && (
<Box paddingX={1}>
<Text color="red">{state.error}</Text>
</Box>
)}
<Box flexDirection="column" height={contentHeight} paddingX={1}>
{currentView.type === 'connecting' && <ConnectingView />}
{currentView.type === 'main' && (
<MainMenu
gated={state.gated}
toolCount={state.tools.length}
resourceCount={state.resources.length}
promptCount={state.prompts.length}
onSelect={(action) => {
switch (action) {
case 'begin-session':
pushView({ type: 'begin-session' });
break;
case 'tools':
pushView({ type: 'tools' });
break;
case 'resources':
pushView({ type: 'resources' });
break;
case 'prompts':
pushView({ type: 'prompts' });
break;
case 'raw':
pushView({ type: 'raw' });
break;
case 'session-info':
pushView({ type: 'result', title: 'Session Info', data: {
sessionId: session.getSessionId(),
gated: state.gated,
initResult: state.initResult,
}});
break;
}
}}
/>
)}
{currentView.type === 'begin-session' && (
<BeginSessionView
session={session}
onDone={onSessionBegan}
onError={setError}
onBack={popView}
/>
)}
{currentView.type === 'tools' && (
<ToolListView
tools={state.tools}
onSelect={(tool) => pushView({ type: 'tool-detail', tool })}
onBack={popView}
/>
)}
{currentView.type === 'tool-detail' && (
<ToolDetailView
tool={currentView.tool}
session={session}
onResult={(data) => pushView({ type: 'result', title: `Result: ${currentView.tool.name}`, data })}
onError={setError}
onBack={popView}
/>
)}
{currentView.type === 'resources' && (
<ResourceListView
resources={state.resources}
session={session}
onResult={(resource, content) => pushView({ type: 'resource-detail', resource, content })}
onError={setError}
onBack={popView}
/>
)}
{currentView.type === 'resource-detail' && (
<Box flexDirection="column">
<Text bold color="cyan">{currentView.resource.uri}</Text>
<Text>{currentView.content}</Text>
</Box>
)}
{currentView.type === 'prompts' && (
<PromptListView
prompts={state.prompts}
session={session}
onResult={(prompt, content) => pushView({ type: 'prompt-detail', prompt, content })}
onError={setError}
onBack={popView}
/>
)}
{currentView.type === 'prompt-detail' && (
<Box flexDirection="column">
<Text bold color="cyan">{currentView.prompt.name}</Text>
<Text>{typeof currentView.content === 'string' ? currentView.content : JSON.stringify(currentView.content, null, 2)}</Text>
</Box>
)}
{currentView.type === 'raw' && (
<RawJsonRpcView
session={session}
onBack={popView}
/>
)}
{currentView.type === 'result' && (
<ResultView
title={currentView.title}
data={currentView.data}
/>
)}
</Box>
<ProtocolLog entries={state.logEntries} height={logHeight} />
<Box paddingX={1}>
<Text dimColor>
[] navigate [Enter] select [Esc] back [n] new session [r] raw [q] quit
</Text>
</Box>
</Box>
</SessionContext.Provider>
);
}
// ── Render entrypoint ──
export interface RenderOptions {
projectName: string;
endpointUrl: string;
token?: string;
}
export async function renderConsole(opts: RenderOptions): Promise<void> {
const instance = render(
<App projectName={opts.projectName} endpointUrl={opts.endpointUrl} token={opts.token} />,
);
await instance.waitUntilExit();
}

View File

@@ -0,0 +1,229 @@
/**
* ActionArea — context-sensitive bottom panel in the unified console.
*
* Renders the appropriate sub-view based on the current action state.
* Only one action at a time — Esc always returns to { type: 'none' }.
*/
import { Box, Text } from 'ink';
import type { ActionState, TimelineEvent } from '../unified-types.js';
import type { McpTool, McpSession, McpResource, McpPrompt } from '../mcp-session.js';
import { formatTime, formatEventSummary, formatBodyDetail } from '../format-event.js';
import { ProvenanceView } from './provenance-view.js';
import { ToolDetailView } from './tool-detail.js';
import { ToolListView } from './tool-list.js';
import { ResourceListView } from './resource-list.js';
import { PromptListView } from './prompt-list.js';
import { RawJsonRpcView } from './raw-jsonrpc.js';
interface ActionAreaProps {
action: ActionState;
events: TimelineEvent[];
session: McpSession;
tools: McpTool[];
resources: McpResource[];
prompts: McpPrompt[];
availableModels: string[];
height: number;
onSetAction: (action: ActionState) => void;
onError: (msg: string) => void;
}
export function ActionArea({
action,
events,
session,
tools,
resources,
prompts,
availableModels,
height,
onSetAction,
onError,
}: ActionAreaProps) {
if (action.type === 'none') return null;
if (action.type === 'detail') {
const event = events[action.eventIdx];
if (!event) return null;
return <DetailView event={event} maxLines={height} scrollOffset={action.scrollOffset} horizontalOffset={action.horizontalOffset} searchQuery={action.searchQuery} searchMatches={action.searchMatches} searchMatchIdx={action.searchMatchIdx} searchMode={action.searchMode} />;
}
if (action.type === 'provenance') {
const clientEvent = events[action.clientEventIdx];
if (!clientEvent) return null;
return (
<ProvenanceView
clientEvent={clientEvent}
upstreamEvent={action.upstreamEvent}
height={height}
scrollOffset={action.scrollOffset}
horizontalOffset={action.horizontalOffset}
focusedPanel={action.focusedPanel}
parameterIdx={action.parameterIdx}
replayConfig={action.replayConfig}
replayResult={action.replayResult}
replayRunning={action.replayRunning}
editingUpstream={action.editingUpstream}
editedContent={action.editedContent}
onEditContent={(text) => onSetAction({ ...action, editedContent: text })}
proxyModelDetails={action.proxyModelDetails}
liveOverride={action.liveOverride}
serverList={action.serverList}
serverOverrides={action.serverOverrides}
selectedServerIdx={action.selectedServerIdx}
serverPickerOpen={action.serverPickerOpen}
modelPickerOpen={action.modelPickerOpen}
modelPickerIdx={action.modelPickerIdx}
availableModels={availableModels}
searchMode={action.searchMode}
searchQuery={action.searchQuery}
searchMatches={action.searchMatches}
searchMatchIdx={action.searchMatchIdx}
/>
);
}
if (action.type === 'tool-input') {
return (
<Box flexDirection="column" height={height} borderStyle="round" borderColor="gray" paddingX={1}>
<ToolDetailView
tool={action.tool}
session={session}
onResult={() => onSetAction({ type: 'none' })}
onError={onError}
onBack={() => onSetAction({ type: 'none' })}
onLoadingChange={(loading) => onSetAction({ ...action, loading })}
/>
</Box>
);
}
if (action.type === 'tool-browser') {
return (
<Box flexDirection="column" height={height} borderStyle="round" borderColor="gray" paddingX={1}>
<ToolListView
tools={tools}
onSelect={(tool) => onSetAction({ type: 'tool-input', tool, loading: false })}
onBack={() => onSetAction({ type: 'none' })}
/>
</Box>
);
}
if (action.type === 'resource-browser') {
return (
<Box flexDirection="column" height={height} borderStyle="round" borderColor="gray" paddingX={1}>
<ResourceListView
resources={resources}
session={session}
onResult={() => {}}
onError={onError}
onBack={() => onSetAction({ type: 'none' })}
/>
</Box>
);
}
if (action.type === 'prompt-browser') {
return (
<Box flexDirection="column" height={height} borderStyle="round" borderColor="gray" paddingX={1}>
<PromptListView
prompts={prompts}
session={session}
onResult={() => {}}
onError={onError}
onBack={() => onSetAction({ type: 'none' })}
/>
</Box>
);
}
if (action.type === 'raw-jsonrpc') {
return (
<Box flexDirection="column" height={height} borderStyle="round" borderColor="gray" paddingX={1}>
<RawJsonRpcView
session={session}
onBack={() => onSetAction({ type: 'none' })}
/>
</Box>
);
}
return null;
}
// ── Detail View ──
function DetailView({ event, maxLines, scrollOffset, horizontalOffset, searchQuery, searchMatches, searchMatchIdx, searchMode }: {
event: TimelineEvent;
maxLines: number;
scrollOffset: number;
horizontalOffset: number;
searchQuery: string;
searchMatches: number[];
searchMatchIdx: number;
searchMode: boolean;
}) {
const { arrow, color, label } = formatEventSummary(
event.eventType,
event.method,
event.body,
event.upstreamName,
event.durationMs,
);
const allLines = formatBodyDetail(event.eventType, event.method ?? '', event.body);
const hasSearch = searchQuery.length > 0 || searchMode;
const bodyHeight = maxLines - 3 - (hasSearch ? 1 : 0);
const visibleLines = allLines.slice(scrollOffset, scrollOffset + bodyHeight);
const totalLines = allLines.length;
const canScroll = totalLines > bodyHeight;
const atEnd = scrollOffset + bodyHeight >= totalLines;
// Which absolute line indices are in the visible window?
const matchSet = new Set(searchMatches);
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>
{event.correlationId && <Text dimColor>{' \u26D3'}</Text>}
{canScroll ? (
<Text dimColor> [{scrollOffset + 1}-{Math.min(scrollOffset + bodyHeight, totalLines)}/{totalLines}]</Text>
) : null}
{horizontalOffset > 0 && <Text dimColor> col:{horizontalOffset}</Text>}
</Text>
<Text dimColor>{'\u2191\u2193:scroll \u2190\u2192:pan p:provenance /:search PgDn/PgUp:next/prev Esc:close'}</Text>
{visibleLines.map((line, i) => {
const absIdx = scrollOffset + i;
const isMatch = matchSet.has(absIdx);
const isCurrent = searchMatches[searchMatchIdx] === absIdx;
const displayLine = horizontalOffset > 0 ? line.slice(horizontalOffset) : line;
return (
<Text key={i} wrap="truncate" dimColor={!isMatch && line.startsWith(' ')}
backgroundColor={isCurrent ? 'yellow' : isMatch ? 'gray' : undefined}
color={isCurrent ? 'black' : isMatch ? 'white' : undefined}
>
{displayLine}
</Text>
);
})}
{canScroll && !atEnd && (
<Text dimColor>{'\u2026 +'}{totalLines - scrollOffset - bodyHeight}{' more lines \u2193'}</Text>
)}
{hasSearch && (
<Text>
<Text color="cyan">/{searchQuery}</Text>
{searchMatches.length > 0 && (
<Text dimColor> [{searchMatchIdx + 1}/{searchMatches.length}] n:next N:prev Esc:clear</Text>
)}
{searchQuery.length > 0 && searchMatches.length === 0 && (
<Text dimColor> (no matches)</Text>
)}
{searchMode && <Text color="cyan">_</Text>}
</Text>
)}
</Box>
);
}

View File

@@ -1,36 +1,92 @@
import { useState } from 'react';
import { Box, Text } from 'ink';
import { TextInput, Spinner } from '@inkjs/ui';
import type { McpSession } from '../mcp-session.js';
import type { McpTool, McpSession } from '../mcp-session.js';
interface BeginSessionViewProps {
tool: McpTool;
session: McpSession;
onDone: (result: unknown) => void;
onError: (msg: string) => void;
onBack: () => void;
onLoadingChange?: (loading: boolean) => void;
}
export function BeginSessionView({ session, onDone, onError }: BeginSessionViewProps) {
const [loading, setLoading] = useState(false);
interface SchemaProperty {
type?: string;
description?: string;
items?: { type?: string };
maxItems?: number;
}
/**
* Dynamically renders a form for the begin_session tool based on its
* inputSchema from the MCP protocol. Adapts to whatever the server sends:
* - string properties → text input
* - array of strings → comma-separated text input
* - multiple/unknown properties → raw JSON input
*/
export function BeginSessionView({ tool, session, onDone, onError, onLoadingChange }: BeginSessionViewProps) {
const [loading, _setLoading] = useState(false);
const setLoading = (v: boolean) => { _setLoading(v); onLoadingChange?.(v); };
const [input, setInput] = useState('');
const handleSubmit = async () => {
const tags = input
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
const schema = tool.inputSchema as {
properties?: Record<string, SchemaProperty>;
required?: string[];
} | undefined;
if (tags.length === 0) {
onError('Enter at least one tag (comma-separated)');
return;
const properties = schema?.properties ?? {};
const propEntries = Object.entries(properties);
// Determine mode: focused single-property or generic JSON
const singleProp = propEntries.length === 1 ? propEntries[0]! : null;
const propName = singleProp?.[0];
const propDef = singleProp?.[1];
const isArray = propDef?.type === 'array';
const buildArgs = (): Record<string, unknown> | null => {
if (!singleProp) {
// JSON mode
try {
return JSON.parse(input) as Record<string, unknown>;
} catch {
onError('Invalid JSON');
return null;
}
}
const trimmed = input.trim();
if (trimmed.length === 0) {
onError(`${propName} is required`);
return null;
}
if (isArray) {
const items = trimmed
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
if (items.length === 0) {
onError(`Enter at least one value for ${propName}`);
return null;
}
return { [propName!]: items };
}
return { [propName!]: trimmed };
};
const handleSubmit = async () => {
const args = buildArgs();
if (!args) return;
setLoading(true);
try {
const result = await session.callTool('begin_session', { tags });
const result = await session.callTool(tool.name, args);
onDone(result);
} catch (err) {
onError(`begin_session failed: ${err instanceof Error ? err.message : String(err)}`);
onError(`${tool.name} failed: ${err instanceof Error ? err.message : String(err)}`);
setLoading(false);
}
};
@@ -38,22 +94,57 @@ export function BeginSessionView({ session, onDone, onError }: BeginSessionViewP
if (loading) {
return (
<Box gap={1}>
<Spinner label="Calling begin_session..." />
<Spinner label={`Calling ${tool.name}...`} />
</Box>
);
}
// Focused single-property mode
if (singleProp) {
const label = propDef?.description ?? propName!;
const hint = isArray ? 'comma-separated values' : 'text';
return (
<Box flexDirection="column">
<Text bold>{tool.description ?? tool.name}</Text>
<Text dimColor>{label}</Text>
<Box marginTop={1}>
<Text color="cyan">{propName}: </Text>
<TextInput
placeholder={hint}
onChange={setInput}
onSubmit={handleSubmit}
/>
</Box>
</Box>
);
}
// Multi-property / unknown schema → JSON input
return (
<Box flexDirection="column">
<Text bold>Enter tags for begin_session (comma-separated):</Text>
<Text dimColor>Example: zigbee, pairing, mqtt</Text>
<Box marginTop={1}>
<Text color="cyan">Tags: </Text>
<TextInput
placeholder="tag1, tag2, tag3"
onChange={setInput}
onSubmit={handleSubmit}
/>
<Text bold>{tool.description ?? tool.name}</Text>
{propEntries.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Schema:</Text>
{propEntries.map(([name, def]) => (
<Text key={name} dimColor>
{name}: {def.type ?? 'any'}{def.description ? `${def.description}` : ''}
</Text>
))}
</Box>
)}
<Box flexDirection="column" marginTop={1}>
<Text bold>Arguments (JSON):</Text>
<Box>
<Text color="cyan">&gt; </Text>
<TextInput
placeholder="{}"
defaultValue="{}"
onChange={setInput}
onSubmit={handleSubmit}
/>
</Box>
</Box>
</Box>
);

View File

@@ -0,0 +1,185 @@
/**
* Diff computation and rendering for the Provenance view.
*
* Uses the `diff` package for line-level diffs with:
* - 3-line context around changes
* - Collapsed unchanged regions (GitKraken style)
* - vimdiff-style coloring (red=removed, green=added)
*/
import { Text } from 'ink';
import { diffLines } from 'diff';
// ── Types ──
export type DiffLineKind = 'added' | 'removed' | 'context' | 'collapsed';
export interface DiffLine {
kind: DiffLineKind;
text: string;
collapsedCount?: number; // only for 'collapsed' kind
}
export interface DiffStats {
added: number;
removed: number;
pctChanged: number;
}
export interface DiffResult {
lines: DiffLine[];
stats: DiffStats;
}
// ── Compute diff with context and collapsing ──
const DEFAULT_CONTEXT = 3;
export function computeDiffLines(
upstream: string,
transformed: string,
contextLines = DEFAULT_CONTEXT,
): DiffResult {
if (upstream === transformed) {
// Identical — show single collapsed block
const lineCount = upstream.split('\n').length;
return {
lines: [{ kind: 'collapsed', text: `${lineCount} unchanged lines`, collapsedCount: lineCount }],
stats: { added: 0, removed: 0, pctChanged: 0 },
};
}
const changes = diffLines(upstream, transformed);
// Step 1: Flatten changes into individual tagged lines
interface TaggedLine { kind: 'added' | 'removed' | 'unchanged'; text: string }
const tagged: TaggedLine[] = [];
for (const change of changes) {
const lines = change.value.replace(/\n$/, '').split('\n');
const kind: TaggedLine['kind'] = change.added ? 'added' : change.removed ? 'removed' : 'unchanged';
for (const line of lines) {
tagged.push({ kind, text: line });
}
}
// Step 2: Mark which unchanged lines are within context range of a change
const inContext = new Set<number>();
for (let i = 0; i < tagged.length; i++) {
if (tagged[i]!.kind !== 'unchanged') {
// Mark contextLines before and after
for (let j = Math.max(0, i - contextLines); j <= Math.min(tagged.length - 1, i + contextLines); j++) {
if (tagged[j]!.kind === 'unchanged') {
inContext.add(j);
}
}
}
}
// Step 3: Build output with collapsed regions
const result: DiffLine[] = [];
let collapsedRun = 0;
for (let i = 0; i < tagged.length; i++) {
const line = tagged[i]!;
if (line.kind !== 'unchanged') {
// Flush collapsed
if (collapsedRun > 0) {
result.push({ kind: 'collapsed', text: `${collapsedRun} unchanged lines`, collapsedCount: collapsedRun });
collapsedRun = 0;
}
result.push({ kind: line.kind, text: line.text });
} else if (inContext.has(i)) {
// Context line
if (collapsedRun > 0) {
result.push({ kind: 'collapsed', text: `${collapsedRun} unchanged lines`, collapsedCount: collapsedRun });
collapsedRun = 0;
}
result.push({ kind: 'context', text: line.text });
} else {
collapsedRun++;
}
}
// Flush trailing collapsed
if (collapsedRun > 0) {
result.push({ kind: 'collapsed', text: `${collapsedRun} unchanged lines`, collapsedCount: collapsedRun });
}
// Stats
let added = 0;
let removed = 0;
for (const t of tagged) {
if (t.kind === 'added') added++;
if (t.kind === 'removed') removed++;
}
const total = Math.max(1, tagged.length - added); // original line count approximation
const pctChanged = Math.round(((added + removed) / (total + added)) * 100);
return { lines: result, stats: { added, removed, pctChanged } };
}
// ── Format header stats ──
export function formatDiffStats(stats: DiffStats): string {
if (stats.added === 0 && stats.removed === 0) return 'no changes';
const parts: string[] = [];
if (stats.added > 0) parts.push(`+${stats.added}`);
if (stats.removed > 0) parts.push(`-${stats.removed}`);
parts.push(`${stats.pctChanged}% chg`);
return parts.join(' ');
}
// ── Rendering component ──
interface DiffPanelProps {
lines: DiffLine[];
scrollOffset: number;
height: number;
horizontalOffset?: number;
}
function hSlice(text: string, offset: number): string {
return offset > 0 ? text.slice(offset) : text;
}
export function DiffPanel({ lines, scrollOffset, height, horizontalOffset = 0 }: DiffPanelProps) {
const visible = lines.slice(scrollOffset, scrollOffset + height);
const hasMore = lines.length > scrollOffset + height;
return (
<>
{visible.map((line, i) => {
switch (line.kind) {
case 'added':
return (
<Text key={i} wrap="truncate" color="green">
{'+ '}{hSlice(line.text, horizontalOffset)}
</Text>
);
case 'removed':
return (
<Text key={i} wrap="truncate" color="red">
{'- '}{hSlice(line.text, horizontalOffset)}
</Text>
);
case 'context':
return (
<Text key={i} wrap="truncate" dimColor>
{' '}{hSlice(line.text, horizontalOffset)}
</Text>
);
case 'collapsed':
return (
<Text key={i} wrap="truncate" color="gray">
{'\u2504\u2504\u2504 '}{line.text}{' \u2504\u2504\u2504'}
</Text>
);
}
})}
{hasMore && (
<Text dimColor>{'\u2026'} +{lines.length - scrollOffset - height} more</Text>
)}
</>
);
}

View File

@@ -1,55 +0,0 @@
import { Box, Text } from 'ink';
import type { LogEntry } from '../mcp-session.js';
interface ProtocolLogProps {
entries: LogEntry[];
height: number;
}
function truncate(s: string, maxLen: number): string {
return s.length > maxLen ? s.slice(0, maxLen - 3) + '...' : s;
}
function formatBody(body: unknown): string {
if (typeof body === 'string') return body;
try {
return JSON.stringify(body);
} catch {
return String(body);
}
}
export function ProtocolLog({ entries, height }: ProtocolLogProps) {
const visible = entries.slice(-height);
const maxBodyLen = 120;
return (
<Box
flexDirection="column"
height={height}
borderStyle="single"
borderTop={true}
borderBottom={false}
borderLeft={false}
borderRight={false}
paddingX={1}
>
<Text bold dimColor>Protocol Log ({entries.length} entries)</Text>
{visible.map((entry, i) => {
const arrow = entry.direction === 'request' ? '→' : entry.direction === 'error' ? '✗' : '←';
const color = entry.direction === 'request' ? 'green' : entry.direction === 'error' ? 'red' : 'blue';
const method = entry.method ? ` ${entry.method}` : '';
const body = truncate(formatBody(entry.body), maxBodyLen);
return (
<Text key={i} wrap="truncate">
<Text color={color}>{arrow}</Text>
<Text bold color={color}>{method}</Text>
<Text dimColor> {body}</Text>
</Text>
);
})}
{visible.length === 0 && <Text dimColor>(no traffic yet)</Text>}
</Box>
);
}

View File

@@ -0,0 +1,363 @@
/**
* ProvenanceView — 4-quadrant display:
* Top-left: Parameters (proxymodel, LLM config, live override, server)
* Top-right: Preview (diff from upstream after replay)
* Bottom-left: Upstream (raw) — the origin, optionally editable
* Bottom-right: Client (diff from upstream)
*/
import { Box, Text } from 'ink';
import { Spinner, TextInput } from '@inkjs/ui';
import type { TimelineEvent, ReplayConfig, ReplayResult, ProxyModelDetails } from '../unified-types.js';
import { computeDiffLines, formatDiffStats, DiffPanel } from './diff-renderer.js';
interface ProvenanceViewProps {
clientEvent: TimelineEvent;
upstreamEvent: TimelineEvent | null;
height: number;
scrollOffset: number;
horizontalOffset: number;
focusedPanel: 'client' | 'upstream' | 'parameters' | 'preview';
parameterIdx: number; // 0=ProxyModel, 1=Provider, 2=Model, 3=Live, 4=Server
replayConfig: ReplayConfig;
replayResult: ReplayResult | null;
replayRunning: boolean;
editingUpstream: boolean;
editedContent: string;
onEditContent: (text: string) => void;
proxyModelDetails: ProxyModelDetails | null;
liveOverride: boolean;
serverList: string[];
serverOverrides: Record<string, string>;
selectedServerIdx: number;
serverPickerOpen: boolean;
modelPickerOpen: boolean;
modelPickerIdx: number;
availableModels: string[];
searchMode: boolean;
searchQuery: string;
searchMatches: number[];
searchMatchIdx: number;
}
export function getContentText(event: TimelineEvent): string {
const body = event.body as Record<string, unknown> | null;
if (!body) return '(no body)';
const result = body['result'] as Record<string, unknown> | undefined;
if (!result) return JSON.stringify(body, null, 2);
const content = (result['content'] ?? result['contents'] ?? []) as Array<{ text?: string }>;
if (content.length > 0) {
return content.map((c) => c.text ?? '').join('\n');
}
return JSON.stringify(result, null, 2);
}
export function ProvenanceView({
clientEvent,
upstreamEvent,
height,
scrollOffset,
horizontalOffset,
focusedPanel,
parameterIdx,
replayConfig,
replayResult,
replayRunning,
editingUpstream,
editedContent,
onEditContent,
proxyModelDetails,
liveOverride,
serverList,
serverOverrides,
selectedServerIdx,
serverPickerOpen,
modelPickerOpen,
modelPickerIdx,
availableModels,
searchMode,
searchQuery,
searchMatches,
searchMatchIdx,
}: ProvenanceViewProps) {
// Split height: top half for params+preview, bottom half for upstream+client
const topHeight = Math.max(4, Math.floor((height - 2) * 0.35));
const bottomHeight = Math.max(4, height - topHeight - 2);
const upstreamText = editedContent || (upstreamEvent ? getContentText(upstreamEvent) : '(no upstream event found)');
const clientText = getContentText(clientEvent);
const upstreamChars = upstreamText.length;
// Upstream raw lines (for the origin panel)
const upstreamLines = upstreamText.split('\n');
const bottomBodyHeight = Math.max(1, bottomHeight - 3);
// Route scrollOffset and horizontalOffset to only the focused panel
const upstreamScroll = focusedPanel === 'upstream' ? scrollOffset : 0;
const clientScroll = focusedPanel === 'client' ? scrollOffset : 0;
const previewScroll = focusedPanel === 'preview' ? scrollOffset : 0;
const upstreamHScroll = focusedPanel === 'upstream' ? horizontalOffset : 0;
const clientHScroll = focusedPanel === 'client' ? horizontalOffset : 0;
const previewHScroll = focusedPanel === 'preview' ? horizontalOffset : 0;
const upstreamVisible = upstreamLines.slice(upstreamScroll, upstreamScroll + bottomBodyHeight);
// Client diff (from upstream)
const clientDiff = computeDiffLines(upstreamText, clientText);
// Preview diff (from upstream, when replay result available)
let previewDiff = { lines: [] as ReturnType<typeof computeDiffLines>['lines'], stats: { added: 0, removed: 0, pctChanged: 0 } };
let previewError: string | null = null;
let previewReady = false;
if (replayRunning) {
// spinner handles this
} else if (replayResult?.error) {
previewError = replayResult.error;
} else if (replayResult) {
previewDiff = computeDiffLines(upstreamText, replayResult.content);
previewReady = true;
}
const previewBodyHeight = Math.max(1, topHeight - 3);
// Server display for row 4 — show per-server override if set
const selectedServerName = selectedServerIdx >= 0 ? serverList[selectedServerIdx] : undefined;
const serverOverrideModel = selectedServerName ? serverOverrides[selectedServerName] : undefined;
const serverDisplay = selectedServerIdx < 0
? '(project-wide)'
: `${selectedServerName ?? '(unknown)'}${serverOverrideModel ? ` [${serverOverrideModel}]` : ''}`;
// Build parameter rows
const paramRows = [
{ label: 'ProxyModel', value: replayConfig.proxyModel },
{ label: 'Provider ', value: replayConfig.provider ?? '(default)' },
{ label: 'Model ', value: replayConfig.llmModel ?? '(default)' },
{ label: 'Live ', value: liveOverride ? 'ON' : 'OFF', isLive: true },
{ label: 'Server ', value: serverDisplay },
];
// Build preview header
let previewHeader = 'Preview';
if (replayRunning) {
previewHeader = 'Preview (running...)';
} else if (previewError) {
previewHeader = 'Preview (error)';
} else if (previewReady) {
previewHeader = `Preview (diff, ${formatDiffStats(previewDiff.stats)})`;
}
// Build client header
const clientHeader = `Client (diff, ${formatDiffStats(clientDiff.stats)})`;
// Show tooltip when ProxyModel row focused
const showTooltip = focusedPanel === 'parameters' && parameterIdx === 0 && proxyModelDetails != null;
return (
<Box flexDirection="column" height={height}>
{/* Top row: Parameters + Preview */}
<Box flexDirection="row" height={topHeight}>
{/* Parameters panel */}
<Box
flexDirection="column"
width="50%"
borderStyle="single"
borderColor={focusedPanel === 'parameters' ? 'cyan' : 'gray'}
paddingX={1}
>
{/* When server picker is open, show ONLY the picker (full panel height) */}
{serverPickerOpen && focusedPanel === 'parameters' && parameterIdx === 4 ? (
<>
<Text bold color="cyan">Select Server</Text>
<Text key="project-wide">
<Text color={selectedServerIdx === -1 ? 'cyan' : undefined}>
{selectedServerIdx === -1 ? '\u25B6 ' : ' '}
</Text>
<Text bold={selectedServerIdx === -1}>(project-wide)</Text>
{serverOverrides['*'] && <Text dimColor> [{serverOverrides['*']}]</Text>}
</Text>
{serverList.map((name, i) => (
<Text key={name}>
<Text color={selectedServerIdx === i ? 'cyan' : undefined}>
{selectedServerIdx === i ? '\u25B6 ' : ' '}
</Text>
<Text bold={selectedServerIdx === i}>{name}</Text>
{serverOverrides[name] && <Text dimColor> [{serverOverrides[name]}]</Text>}
</Text>
))}
<Text dimColor>{'\u2191\u2193'}:navigate Enter:select Esc:cancel</Text>
</>
) : modelPickerOpen && focusedPanel === 'parameters' && selectedServerIdx >= 0 ? (
<>
<Text bold color="cyan">
ProxyModel for {serverList[selectedServerIdx] ?? '(unknown)'}
</Text>
{availableModels.map((name, i) => {
const serverName = serverList[selectedServerIdx] ?? '';
const isCurrentOverride = serverOverrides[serverName] === name;
return (
<Text key={name}>
<Text color={modelPickerIdx === i ? 'cyan' : undefined}>
{modelPickerIdx === i ? '\u25B6 ' : ' '}
</Text>
<Text bold={modelPickerIdx === i}>{name}</Text>
{isCurrentOverride && <Text color="green"> (active)</Text>}
</Text>
);
})}
<Text dimColor>{'\u2191\u2193'}:navigate Enter:apply Esc:cancel</Text>
</>
) : (
<>
<Text bold color={focusedPanel === 'parameters' ? 'cyan' : 'magenta'}>Parameters</Text>
{paramRows.map((row, i) => {
const isFocused = focusedPanel === 'parameters' && parameterIdx === i;
const isLiveRow = 'isLive' in row;
return (
<Text key={i}>
<Text color={isFocused ? 'cyan' : undefined}>{isFocused ? '\u25C0 ' : ' '}</Text>
<Text dimColor={!isFocused}>{row.label}: </Text>
{isLiveRow ? (
<Text bold={isFocused} color={liveOverride ? 'green' : undefined}>
{row.value}
</Text>
) : (
<Text bold={isFocused}>{row.value}</Text>
)}
<Text color={isFocused ? 'cyan' : undefined}>{isFocused ? ' \u25B6' : ''}</Text>
</Text>
);
})}
{/* ProxyModel details tooltip */}
{showTooltip && proxyModelDetails && (
<Box
flexDirection="column"
borderStyle="round"
borderColor="magenta"
paddingX={1}
marginTop={0}
>
<Text bold color="magenta">{proxyModelDetails.name}</Text>
<Text dimColor>
{proxyModelDetails.source}
{proxyModelDetails.cacheable ? ', cached' : ''}
{proxyModelDetails.appliesTo.length > 0 ? ` \u00B7 ${proxyModelDetails.appliesTo.join(', ')}` : ''}
</Text>
{proxyModelDetails.stages.map((stage, i) => (
<Text key={i}>
<Text color="yellow">{i + 1}. {stage.type}</Text>
{stage.config && Object.keys(stage.config).length > 0 && (
<Text dimColor>
{' '}{Object.entries(stage.config).map(([k, v]) => `${k}=${String(v)}`).join(' ')}
</Text>
)}
</Text>
))}
</Box>
)}
{/* Per-server overrides summary */}
{Object.keys(serverOverrides).length > 0 && (
<Text dimColor wrap="truncate">
Overrides: {Object.entries(serverOverrides).map(([s, m]) => `${s}=${m}`).join(', ')}
</Text>
)}
</>
)}
</Box>
{/* Preview panel — diff from upstream */}
<Box
flexDirection="column"
width="50%"
borderStyle="single"
borderColor={focusedPanel === 'preview' ? 'cyan' : 'gray'}
paddingX={1}
>
<Text bold color={focusedPanel === 'preview' ? 'cyan' : 'green'}>
{previewHeader}
</Text>
{replayRunning ? (
<Spinner label="Running replay..." />
) : previewError ? (
<Text color="red" wrap="truncate">Error: {previewError}</Text>
) : previewReady ? (
<DiffPanel lines={previewDiff.lines} scrollOffset={previewScroll} height={previewBodyHeight} horizontalOffset={previewHScroll} />
) : (
<Text dimColor>Press Enter to run preview</Text>
)}
</Box>
</Box>
{/* Bottom row: Upstream (raw) + Client (diff) */}
<Box flexDirection="row" height={bottomHeight}>
{/* Upstream panel — origin, raw text */}
<Box
flexDirection="column"
width="50%"
borderStyle="single"
borderColor={focusedPanel === 'upstream' ? 'cyan' : 'gray'}
paddingX={1}
>
<Box>
<Text bold color={focusedPanel === 'upstream' ? 'cyan' : 'yellowBright'}>
Upstream (raw, {upstreamChars} chars)
</Text>
{editingUpstream && <Text color="yellow"> [EDITING]</Text>}
</Box>
{upstreamEvent?.upstreamName && upstreamEvent.upstreamName.includes(',') && (
<Text dimColor wrap="truncate">{upstreamEvent.upstreamName}</Text>
)}
{editingUpstream ? (
<Box flexGrow={1}>
<TextInput defaultValue={editedContent} onChange={onEditContent} />
</Box>
) : (
<>
{upstreamVisible.map((line, i) => (
<Text key={i} wrap="truncate">{upstreamHScroll > 0 ? (line || ' ').slice(upstreamHScroll) : (line || ' ')}</Text>
))}
{upstreamLines.length > upstreamScroll + bottomBodyHeight && (
<Text dimColor>{'\u2026'} +{upstreamLines.length - upstreamScroll - bottomBodyHeight} more</Text>
)}
</>
)}
</Box>
{/* Client panel — diff from upstream */}
<Box
flexDirection="column"
width="50%"
borderStyle="single"
borderColor={focusedPanel === 'client' ? 'cyan' : 'gray'}
paddingX={1}
>
<Text bold color={focusedPanel === 'client' ? 'cyan' : 'blue'}>
{clientHeader}
</Text>
<DiffPanel lines={clientDiff.lines} scrollOffset={clientScroll} height={bottomBodyHeight} horizontalOffset={clientHScroll} />
</Box>
</Box>
{/* Footer */}
<Box paddingX={1}>
{searchMode || searchQuery.length > 0 ? (
<Text>
<Text color="cyan">/{searchQuery}</Text>
{searchMatches.length > 0 && (
<Text dimColor> [{searchMatchIdx + 1}/{searchMatches.length}] n:next N:prev Esc:clear</Text>
)}
{searchQuery.length > 0 && searchMatches.length === 0 && (
<Text dimColor> (no matches)</Text>
)}
{searchMode && <Text color="cyan">_</Text>}
</Text>
) : (
<Text dimColor>Tab:panel {'\u2191\u2193'}:scroll {'\u2190\u2192'}:pan/param /:search Enter:run/toggle e:edit Esc:close</Text>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,321 @@
/**
* SessionSidebar — project-grouped session list with "New Session" entry
* and project picker mode.
*
* Sessions are grouped by project name. Each project appears once as a header,
* with its sessions listed below. Discovers sessions from both the SSE snapshot
* AND traffic events so closed sessions still appear.
*
* selectedIdx: -2 = "New Session", -1 = all sessions, 0+ = individual sessions
*/
import { Box, Text } from 'ink';
import type { ActiveSession, TimelineEvent } from '../unified-types.js';
interface SessionSidebarProps {
interactiveSessionId: string | undefined;
observedSessions: ActiveSession[];
events: TimelineEvent[];
selectedIdx: number; // -2 = new session, -1 = all, 0+ = session
height: number;
projectName: string;
mode: 'sessions' | 'project-picker';
availableProjects: string[];
projectPickerIdx: number;
}
interface SessionEntry {
sessionId: string;
projectName: string;
}
interface ProjectGroup {
projectName: string;
sessions: SessionEntry[];
}
export function SessionSidebar({
interactiveSessionId,
observedSessions,
events,
selectedIdx,
height,
projectName,
mode,
availableProjects,
projectPickerIdx,
}: SessionSidebarProps) {
if (mode === 'project-picker') {
return (
<ProjectPicker
projects={availableProjects}
selectedIdx={projectPickerIdx}
height={height}
/>
);
}
const sessions = buildSessionList(interactiveSessionId, observedSessions, events, projectName);
const groups = groupByProject(sessions);
// Count events per session
const counts = new Map<string, number>();
for (const e of events) {
counts.set(e.sessionId, (counts.get(e.sessionId) ?? 0) + 1);
}
const headerLines = 3; // "Sessions (N)" + "New Session" + "all sessions"
const footerLines = 5; // keybinding help box
const bodyHeight = Math.max(1, height - headerLines - footerLines);
// Build flat render lines for scrolling
interface RenderLine {
type: 'project-header' | 'session';
projectName: string;
sessionId?: string;
flatSessionIdx?: number;
}
const lines: RenderLine[] = [];
let flatIdx = 0;
for (const group of groups) {
lines.push({ type: 'project-header', projectName: group.projectName });
for (const s of group.sessions) {
lines.push({ type: 'session', projectName: group.projectName, sessionId: s.sessionId, flatSessionIdx: flatIdx });
flatIdx++;
}
}
// Find which render line corresponds to the selected session
let selectedLineIdx = -1;
if (selectedIdx >= 0) {
selectedLineIdx = lines.findIndex((l) => l.flatSessionIdx === selectedIdx);
}
// Scroll to keep selected visible
let scrollStart = 0;
if (selectedLineIdx >= 0) {
if (selectedLineIdx >= scrollStart + bodyHeight) {
scrollStart = selectedLineIdx - bodyHeight + 1;
}
if (selectedLineIdx < scrollStart) {
scrollStart = selectedLineIdx;
}
}
scrollStart = Math.max(0, scrollStart);
const visibleLines = lines.slice(scrollStart, scrollStart + bodyHeight);
const hasMore = scrollStart + bodyHeight < lines.length;
return (
<Box
flexDirection="column"
width={32}
borderStyle="round"
borderColor="gray"
paddingX={1}
height={height}
>
<Text bold color="cyan">
{' Sessions '}
<Text dimColor>({sessions.length})</Text>
</Text>
{/* "New Session" row */}
<Text color={selectedIdx === -2 ? 'cyan' : 'green'} bold={selectedIdx === -2}>
{selectedIdx === -2 ? ' \u25b8 ' : ' '}
{'+ New Session'}
</Text>
{/* "All sessions" row */}
<Text color={selectedIdx === -1 ? 'cyan' : undefined} bold={selectedIdx === -1}>
{selectedIdx === -1 ? ' \u25b8 ' : ' '}
{'all sessions'}
</Text>
{/* Grouped session list */}
{sessions.length === 0 && (
<Box marginTop={1}>
<Text dimColor>{' waiting for connections\u2026'}</Text>
</Box>
)}
{visibleLines.map((line, vi) => {
if (line.type === 'project-header') {
return (
<Text key={`proj-${line.projectName}-${vi}`} bold wrap="truncate">
{' '}{line.projectName}
</Text>
);
}
// Session line
const isSelected = line.flatSessionIdx === selectedIdx;
const count = counts.get(line.sessionId!) ?? 0;
const isInteractive = line.sessionId === interactiveSessionId;
return (
<Text key={line.sessionId!} wrap="truncate">
<Text color={isSelected ? 'cyan' : undefined} bold={isSelected}>
{isSelected ? ' \u25b8 ' : ' '}
{line.sessionId!.slice(0, 8)}
</Text>
{count > 0 && <Text dimColor>{` \u00b7 ${count} ev`}</Text>}
{isInteractive && <Text color="green">{' *'}</Text>}
</Text>
);
})}
{hasMore && (
<Text dimColor>{' \u2026 more'}</Text>
)}
{/* Spacer */}
<Box flexGrow={1} />
{/* Help */}
<Box borderStyle="single" borderTop borderColor="gray" paddingTop={0}>
<Text dimColor>
{'[\u2191\u2193] session [a] all\n[\u23ce] select [Esc] close\n[x] clear [q] quit'}
</Text>
</Box>
</Box>
);
}
/** Project picker sub-view */
function ProjectPicker({
projects,
selectedIdx,
height,
}: {
projects: string[];
selectedIdx: number;
height: number;
}) {
const headerLines = 2;
const footerLines = 4;
const bodyHeight = Math.max(1, height - headerLines - footerLines);
let scrollStart = 0;
if (selectedIdx >= scrollStart + bodyHeight) {
scrollStart = selectedIdx - bodyHeight + 1;
}
if (selectedIdx < scrollStart) {
scrollStart = selectedIdx;
}
scrollStart = Math.max(0, scrollStart);
const visibleProjects = projects.slice(scrollStart, scrollStart + bodyHeight);
const hasMore = scrollStart + bodyHeight < projects.length;
return (
<Box
flexDirection="column"
width={32}
borderStyle="round"
borderColor="cyan"
paddingX={1}
height={height}
>
<Text bold color="cyan">
{' Select Project '}
</Text>
{projects.length === 0 ? (
<Box marginTop={1}>
<Text dimColor>{' no projects found'}</Text>
</Box>
) : (
visibleProjects.map((name, vi) => {
const realIdx = scrollStart + vi;
const isSelected = realIdx === selectedIdx;
return (
<Text key={name} wrap="truncate">
<Text color={isSelected ? 'cyan' : undefined} bold={isSelected}>
{isSelected ? ' \u25b8 ' : ' '}
{name}
</Text>
</Text>
);
})
)}
{hasMore && (
<Text dimColor>{' \u2026 more'}</Text>
)}
{/* Spacer */}
<Box flexGrow={1} />
{/* Help */}
<Box borderStyle="single" borderTop borderColor="gray" paddingTop={0}>
<Text dimColor>
{'[\u2191\u2193] pick [\u23ce] select\n[Esc] back'}
</Text>
</Box>
</Box>
);
}
/** Total session count across all groups */
export function getSessionCount(
interactiveSessionId: string | undefined,
observedSessions: ActiveSession[],
events: TimelineEvent[],
projectName: string,
): number {
return buildSessionList(interactiveSessionId, observedSessions, events, projectName).length;
}
function buildSessionList(
interactiveSessionId: string | undefined,
observedSessions: ActiveSession[],
events: TimelineEvent[],
projectName: string,
): SessionEntry[] {
const result: SessionEntry[] = [];
const seen = new Set<string>();
// Interactive session first
if (interactiveSessionId) {
result.push({ sessionId: interactiveSessionId, projectName });
seen.add(interactiveSessionId);
}
// Then observed sessions from SSE snapshot
for (const s of observedSessions) {
if (!seen.has(s.sessionId)) {
result.push({ sessionId: s.sessionId, projectName: s.projectName });
seen.add(s.sessionId);
}
}
// Also discover sessions from traffic events (covers sessions that
// were already closed before the SSE connected)
for (const e of events) {
if (!seen.has(e.sessionId)) {
result.push({ sessionId: e.sessionId, projectName: e.projectName });
seen.add(e.sessionId);
}
}
return result;
}
function groupByProject(sessions: SessionEntry[]): ProjectGroup[] {
const map = new Map<string, SessionEntry[]>();
const order: string[] = [];
for (const s of sessions) {
let group = map.get(s.projectName);
if (!group) {
group = [];
map.set(s.projectName, group);
order.push(s.projectName);
}
group.push(s);
}
return order.map((name) => ({ projectName: name, sessions: map.get(name)! }));
}

View File

@@ -0,0 +1,95 @@
/**
* Unified timeline — renders all events (interactive, observed)
* with a lane-colored gutter, windowed rendering, and auto-scroll.
*/
import { Box, Text } from 'ink';
import type { TimelineEvent, EventLane } from '../unified-types.js';
import { formatTime, formatEventSummary, trunc } from '../format-event.js';
const LANE_COLORS: Record<EventLane, string> = {
interactive: 'green',
observed: 'yellow',
};
const LANE_MARKERS: Record<EventLane, string> = {
interactive: '\u2502',
observed: '\u2502',
};
interface TimelineProps {
events: TimelineEvent[];
height: number;
focusedIdx: number; // -1 = auto-scroll to bottom
showProject: boolean;
}
export function Timeline({ events, height, focusedIdx, showProject }: TimelineProps) {
const maxVisible = Math.max(1, height - 2); // header + spacing
let startIdx: number;
if (focusedIdx >= 0) {
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);
return (
<Box flexDirection="column" flexGrow={1} paddingLeft={1}>
<Text bold>
Timeline <Text dimColor>({events.length} events{focusedIdx >= 0 ? ` \u00B7 #${focusedIdx + 1}` : ''})</Text>
</Text>
{visible.length === 0 && (
<Box marginTop={1}>
<Text dimColor>{' waiting for traffic\u2026'}</Text>
</Box>
)}
{visible.map((event, vi) => {
const absIdx = startIdx + vi;
const isFocused = absIdx === focusedIdx;
const { arrow, color, label, detail, detailColor } = formatEventSummary(
event.eventType,
event.method,
event.body,
event.upstreamName,
event.durationMs,
);
const isLifecycle = event.eventType === 'session_created' || event.eventType === 'session_closed';
const laneColor = LANE_COLORS[event.lane];
const laneMarker = LANE_MARKERS[event.lane];
const focusMarker = isFocused ? '\u25B8' : ' ';
const hasCorrelation = event.correlationId !== undefined;
if (isLifecycle) {
return (
<Text key={event.id} wrap="truncate">
<Text color={laneColor}>{laneMarker}</Text>
<Text color={isFocused ? 'cyan' : undefined}>{focusMarker}</Text>
<Text dimColor>{formatTime(event.timestamp)} </Text>
<Text color={color} bold>{arrow} {label}</Text>
{showProject && <Text color="gray"> [{trunc(event.projectName, 12)}]</Text>}
<Text dimColor> {event.sessionId.slice(0, 8)}</Text>
</Text>
);
}
const isUpstream = event.eventType.startsWith('upstream_');
return (
<Text key={event.id} wrap="truncate">
<Text color={laneColor}>{laneMarker}</Text>
<Text color={isFocused ? 'cyan' : undefined}>{focusMarker}</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}
{hasCorrelation && <Text dimColor>{' \u26D3'}</Text>}
</Text>
);
})}
</Box>
);
}

View File

@@ -9,6 +9,7 @@ interface ToolDetailViewProps {
onResult: (data: unknown) => void;
onError: (msg: string) => void;
onBack: () => void;
onLoadingChange?: (loading: boolean) => void;
}
interface SchemaProperty {
@@ -16,8 +17,9 @@ interface SchemaProperty {
description?: string;
}
export function ToolDetailView({ tool, session, onResult, onError }: ToolDetailViewProps) {
const [loading, setLoading] = useState(false);
export function ToolDetailView({ tool, session, onResult, onError, onLoadingChange }: ToolDetailViewProps) {
const [loading, _setLoading] = useState(false);
const setLoading = (v: boolean) => { _setLoading(v); onLoadingChange?.(v); };
const [argsJson, setArgsJson] = useState('{}');
// Extract properties from input schema

View File

@@ -0,0 +1,46 @@
/**
* Toolbar — compact 1-line bar showing Tools / Resources / Prompts / Raw JSON-RPC.
*
* Shown between the header and timeline when an interactive session is ungated.
* Items are selectable via Tab (focus on/off), ←/→ (cycle), Enter (open).
*/
import { Box, Text } from 'ink';
interface ToolbarProps {
toolCount: number;
resourceCount: number;
promptCount: number;
focusedItem: number; // -1 = not focused, 0-3 = which item
}
const ITEMS = [
{ label: 'Tools', key: 'tools' },
{ label: 'Resources', key: 'resources' },
{ label: 'Prompts', key: 'prompts' },
{ label: 'Raw JSON-RPC', key: 'raw' },
] as const;
export function Toolbar({ toolCount, resourceCount, promptCount, focusedItem }: ToolbarProps) {
const counts = [toolCount, resourceCount, promptCount, -1]; // -1 = no count for raw
return (
<Box paddingX={1} height={1}>
{ITEMS.map((item, i) => {
const focused = focusedItem === i;
const count = counts[i]!;
const separator = i < ITEMS.length - 1 ? ' | ' : '';
return (
<Text key={item.key}>
<Text color={focused ? 'cyan' : undefined} bold={focused} dimColor={!focused}>
{` ${item.label}`}
{count >= 0 && <Text>{` (${count})`}</Text>}
</Text>
{separator && <Text dimColor>{separator}</Text>}
</Text>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,310 @@
/**
* Shared formatting functions for MCP traffic events.
*
* Extracted from inspect-app.tsx so they can be reused by
* the unified timeline, action area, and provenance views.
*/
import type { TrafficEventType } from './unified-types.js';
/** Safely dig into unknown objects */
export 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;
}
export function trunc(s: string, maxLen: number): string {
return s.length > maxLen ? s.slice(0, maxLen - 1) + '\u2026' : s;
}
export 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` : '');
}
export function formatTime(ts: Date | string): string {
try {
const d = typeof ts === 'string' ? new Date(ts) : ts;
return d.toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return '??:??:??';
}
}
/** Extract meaningful summary from request params (strips jsonrpc/id boilerplate) */
export 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 */
export function summarizeResponse(method: string, body: unknown, durationMs?: number): 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 '';
let summary: string;
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) : [];
summary = `server=${name}${ver ? ` v${ver}` : ''}${capList.length ? ` caps=[${capList.join(',')}]` : ''}`;
break;
}
case 'tools/list': {
const tools = (result.tools ?? []) as unknown[];
summary = `${tools.length} tools: ${nameList(tools, 'name', 6)}`;
break;
}
case 'resources/list': {
const resources = (result.resources ?? []) as unknown[];
summary = `${resources.length} resources: ${nameList(resources, 'name', 6)}`;
break;
}
case 'prompts/list': {
const prompts = (result.prompts ?? []) as unknown[];
if (prompts.length === 0) { summary = '0 prompts'; break; }
summary = `${prompts.length} prompts: ${nameList(prompts, 'name', 6)}`;
break;
}
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) { summary = prefix + trunc(text.replace(/\n/g, ' '), 100); break; }
summary = prefix + `${content.length} content block(s)`;
break;
}
case 'resources/read': {
const contents = (result.contents ?? []) as unknown[];
const first = contents[0];
const text = (dig(first, 'text') as string) ?? '';
if (text) { summary = trunc(text.replace(/\n/g, ' '), 80); break; }
summary = `${contents.length} content block(s)`;
break;
}
case 'notifications/initialized':
summary = 'ok';
break;
default: {
if (Object.keys(result).length === 0) { summary = 'ok'; break; }
const s = JSON.stringify(result);
summary = trunc(s, 80);
break;
}
}
if (durationMs !== undefined) {
return `[${durationMs}ms] ${summary}`;
}
return summary;
}
/** Format full event body for expanded detail view (multi-line, readable) */
export function formatBodyDetail(eventType: string, method: string, body: unknown): string[] {
const bodyObj = body as Record<string, unknown> | null;
if (!bodyObj) return ['(no body)'];
const lines: string[] = [];
if (eventType.includes('request') || eventType === 'client_notification') {
const params = bodyObj['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 (eventType.includes('response')) {
const error = bodyObj['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 = bodyObj['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 ? ` \u2014 ${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 ? ` \u2014 ${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 ? ` \u2014 ${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(bodyObj, null, 2).split('\n')) {
lines.push(l);
}
}
return lines;
}
export interface FormattedEvent {
arrow: string;
color: string;
label: string;
detail: string;
detailColor?: string | undefined;
}
export function formatEventSummary(
eventType: TrafficEventType,
method: string | undefined,
body: unknown,
upstreamName?: string,
durationMs?: number,
): FormattedEvent {
const m = method ?? '';
switch (eventType) {
case 'client_request':
return { arrow: '\u2192', color: 'green', label: m, detail: summarizeRequest(m, body) };
case 'client_response': {
const detail = summarizeResponse(m, body, durationMs);
const hasError = detail.startsWith('ERROR');
return { arrow: '\u2190', color: 'blue', label: m, detail, detailColor: hasError ? 'red' : undefined };
}
case 'client_notification':
return { arrow: '\u25C2', color: 'magenta', label: m, detail: summarizeRequest(m, body) };
case 'upstream_request':
return { arrow: ' \u21E2', color: 'yellowBright', label: `${upstreamName ?? '?'}/${m}`, detail: summarizeRequest(m, body) };
case 'upstream_response': {
const detail = summarizeResponse(m, body, durationMs);
const hasError = detail.startsWith('ERROR');
return { arrow: ' \u21E0', color: 'yellowBright', label: `${upstreamName ?? '?'}/${m}`, detail, detailColor: hasError ? 'red' : undefined };
}
case 'session_created':
return { arrow: '\u25CF', color: 'cyan', label: 'session', detail: '' };
case 'session_closed':
return { arrow: '\u25CB', color: 'red', label: 'session', detail: 'closed' };
default:
return { arrow: '?', color: 'white', label: eventType, detail: '' };
}
}

View File

@@ -8,11 +8,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')
.description('Interactive MCP console — unified timeline with tools, provenance, and lab replay')
.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 }) => {
.action(async (projectName: string | undefined, opts: { stdinMcp?: boolean }) => {
let mcplocalUrl = 'http://localhost:3200';
if (deps.configLoader) {
mcplocalUrl = deps.configLoader().mcplocalUrl;
@@ -25,28 +24,13 @@ export function createConsoleCommand(deps: ConsoleCommandDeps): Command {
}
}
// --inspect --stdin-mcp: MCP server for Claude
if (opts.inspect && opts.stdinMcp) {
// --stdin-mcp: MCP server for Claude (unchanged)
if (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;
@@ -59,11 +43,55 @@ export function createConsoleCommand(deps: ConsoleCommandDeps): Command {
}
}
const endpointUrl = `${mcplocalUrl.replace(/\/$/, '')}/projects/${encodeURIComponent(projectName)}/mcp`;
// Build endpoint URL only if project specified
let endpointUrl: string | undefined;
if (projectName) {
endpointUrl = `${mcplocalUrl.replace(/\/$/, '')}/projects/${encodeURIComponent(projectName)}/mcp`;
// Dynamic import to avoid loading React/Ink for non-console commands
const { renderConsole } = await import('./app.js');
await renderConsole({ projectName, endpointUrl, token });
// Preflight check: verify the project exists before launching the TUI
const { postJsonRpc, sendDelete } = await import('../mcp.js');
try {
const initResult = await postJsonRpc(
endpointUrl,
JSON.stringify({
jsonrpc: '2.0',
id: 0,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-preflight', version: '0.0.1' },
},
}),
undefined,
token,
);
if (initResult.status >= 400) {
try {
const body = JSON.parse(initResult.body) as { error?: string };
console.error(`Error: ${body.error ?? `HTTP ${initResult.status}`}`);
} catch {
console.error(`Error: HTTP ${initResult.status}${initResult.body}`);
}
process.exit(1);
}
// Clean up the preflight session
const sid = initResult.headers['mcp-session-id'];
if (typeof sid === 'string') {
await sendDelete(endpointUrl, sid, token);
}
} catch (err) {
console.error(`Error: cannot connect to mcplocal at ${mcplocalUrl}`);
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
// Launch unified console (observe-only if no project, interactive available if project given)
const { renderUnifiedConsole } = await import('./unified-app.js');
await renderUnifiedConsole({ projectName, endpointUrl, mcplocalUrl, token });
});
return cmd;

View File

@@ -1,825 +0,0 @@
/**
* 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

@@ -2,7 +2,7 @@
* 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"] } }
* { "mcpctl-inspect": { "command": "mcpctl", "args": ["console", "--stdin-mcp"] } }
*
* Subscribes to mcplocal's /inspect SSE endpoint and exposes traffic
* data via MCP tools: list_sessions, get_traffic, get_session_info.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
/**
* Shared types for the unified MCP console.
*/
import type { McpTool, McpResource, McpPrompt, InitializeResult, McpSession } from './mcp-session.js';
// ── Traffic event types (mirrors mcplocal's TrafficEvent) ──
export type TrafficEventType =
| 'client_request'
| 'client_response'
| 'client_notification'
| 'upstream_request'
| 'upstream_response'
| 'session_created'
| 'session_closed';
export interface ActiveSession {
sessionId: string;
projectName: string;
startedAt: string;
}
// ── Timeline ──
export type EventLane = 'interactive' | 'observed';
export interface TimelineEvent {
id: number;
timestamp: Date;
lane: EventLane;
eventType: TrafficEventType;
method?: string | undefined;
projectName: string;
sessionId: string;
upstreamName?: string | undefined;
body: unknown;
durationMs?: number | undefined;
correlationId?: string | undefined;
}
// ── Lane filter ──
export type LaneFilter = 'all' | 'interactive' | 'observed';
// ── Action area ──
export interface ReplayConfig {
proxyModel: string;
provider: string | null;
llmModel: string | null;
}
export interface ReplayResult {
content: string;
durationMs: number;
error?: string | undefined;
}
export interface ProxyModelDetails {
name: string;
source: 'built-in' | 'local';
controller: string;
controllerConfig?: Record<string, unknown> | undefined;
stages: Array<{ type: string; config?: Record<string, unknown> }>;
appliesTo: string[];
cacheable: boolean;
}
export interface SearchState {
searchMode: boolean;
searchQuery: string;
searchMatches: number[]; // line indices matching query
searchMatchIdx: number; // current match index, -1 = none
}
export type ActionState =
| { type: 'none' }
| { type: 'detail'; eventIdx: number; scrollOffset: number; horizontalOffset: number } & SearchState
| {
type: 'provenance';
clientEventIdx: number;
upstreamEvent: TimelineEvent | null;
scrollOffset: number;
horizontalOffset: number;
focusedPanel: 'client' | 'upstream' | 'parameters' | 'preview';
replayConfig: ReplayConfig;
replayResult: ReplayResult | null;
replayRunning: boolean;
editingUpstream: boolean;
editedContent: string;
parameterIdx: number; // 0=ProxyModel, 1=Provider, 2=Model, 3=Live, 4=Server
proxyModelDetails: ProxyModelDetails | null;
liveOverride: boolean;
serverList: string[];
serverOverrides: Record<string, string>;
selectedServerIdx: number; // -1 = project-wide, 0+ = specific server
serverPickerOpen: boolean;
modelPickerOpen: boolean;
modelPickerIdx: number;
} & SearchState
| { type: 'tool-input'; tool: McpTool; loading: boolean }
| { type: 'tool-browser' }
| { type: 'resource-browser' }
| { type: 'prompt-browser' }
| { type: 'raw-jsonrpc' };
// ── Console state ──
export interface UnifiedConsoleState {
// Connection
phase: 'connecting' | 'ready' | 'error';
error: string | null;
// Interactive session
session: McpSession | null;
gated: boolean;
initResult: InitializeResult | null;
tools: McpTool[];
resources: McpResource[];
prompts: McpPrompt[];
// Observed traffic (SSE)
sseConnected: boolean;
observedSessions: ActiveSession[];
// Session sidebar
showSidebar: boolean;
selectedSessionIdx: number; // -2 = "New Session", -1 = all sessions, 0+ = sessions
sidebarMode: 'sessions' | 'project-picker';
availableProjects: string[];
activeProjectName: string | null;
// Toolbar
toolbarFocusIdx: number; // -1 = not focused, 0-3 = which item
// Timeline
events: TimelineEvent[];
focusedEventIdx: number; // -1 = auto-scroll
nextEventId: number;
laneFilter: LaneFilter;
// Action area
action: ActionState;
// ProxyModel / LLM options (for provenance preview)
availableModels: string[];
availableProviders: string[];
availableLlms: string[];
}
export const MAX_TIMELINE_EVENTS = 10_000;

View File

@@ -63,7 +63,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.description('Create an MCP server definition')
.argument('<name>', 'Server name (lowercase, hyphens allowed)')
.option('-d, --description <text>', 'Server description')
.option('--package-name <name>', 'NPM package name')
.option('--package-name <name>', 'Package name (npm, PyPI, Go module, etc.)')
.option('--runtime <type>', 'Package runtime (node, python, go — default: node)')
.option('--docker-image <image>', 'Docker image')
.option('--transport <type>', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)')
.option('--repository-url <url>', 'Source repository URL')
@@ -148,6 +149,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
if (opts.transport) body.transport = opts.transport;
if (opts.replicas) body.replicas = parseInt(opts.replicas, 10);
if (opts.packageName) body.packageName = opts.packageName;
if (opts.runtime) body.runtime = opts.runtime;
if (opts.dockerImage) body.dockerImage = opts.dockerImage;
if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl;
if (opts.externalUrl) body.externalUrl = opts.externalUrl;
@@ -224,6 +226,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '')
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
.option('--proxy-model <name>', 'ProxyModel pipeline name (e.g. default, subindex)')
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
.option('--gated', 'Enable gated sessions (default: true)')
.option('--no-gated', 'Disable gated sessions')
@@ -236,6 +239,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
proxyMode: opts.proxyMode ?? 'direct',
};
if (opts.prompt) body.prompt = opts.prompt;
if (opts.proxyModel) body.proxyModel = opts.proxyModel;
if (opts.gated !== undefined) body.gated = opts.gated as boolean;
if (opts.server.length > 0) body.servers = opts.server;

View File

@@ -8,6 +8,7 @@ export interface DescribeCommandDeps {
fetchResource: (resource: string, id: string) => Promise<unknown>;
fetchInspect?: (id: string) => Promise<unknown>;
log: (...args: string[]) => void;
mcplocalUrl?: string;
}
function pad(label: string, width = 18): string {
@@ -145,12 +146,14 @@ function formatProjectDetail(
// Proxy config section
const proxyMode = project.proxyMode as string | undefined;
const proxyModel = project.proxyModel as string | undefined;
const llmProvider = project.llmProvider as string | undefined;
const llmModel = project.llmModel as string | undefined;
if (proxyMode || llmProvider || llmModel) {
if (proxyMode || proxyModel || llmProvider || llmModel) {
lines.push('');
lines.push('Proxy Config:');
lines.push(` ${pad('Mode:', 18)}${proxyMode ?? 'direct'}`);
lines.push(` ${pad('ProxyModel:', 18)}${proxyModel || 'default'}`);
if (llmProvider) lines.push(` ${pad('LLM Provider:', 18)}${llmProvider}`);
if (llmModel) lines.push(` ${pad('LLM Model:', 18)}${llmModel}`);
}
@@ -593,6 +596,46 @@ async function resolveLink(linkTarget: string, client: ApiClient): Promise<strin
}
}
function formatProxymodelDetail(model: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== ProxyModel: ${model.name} ===`);
lines.push(`${pad('Name:')}${model.name}`);
lines.push(`${pad('Source:')}${model.source ?? 'unknown'}`);
lines.push(`${pad('Controller:')}${model.controller ?? '-'}`);
lines.push(`${pad('Cacheable:')}${model.cacheable ? 'yes' : 'no'}`);
const appliesTo = model.appliesTo as string[] | undefined;
if (appliesTo && appliesTo.length > 0) {
lines.push(`${pad('Applies To:')}${appliesTo.join(', ')}`);
}
const controllerConfig = model.controllerConfig as Record<string, unknown> | undefined;
if (controllerConfig && Object.keys(controllerConfig).length > 0) {
lines.push('');
lines.push('Controller Config:');
for (const [key, value] of Object.entries(controllerConfig)) {
lines.push(` ${pad(key + ':', 20)}${String(value)}`);
}
}
const stages = model.stages as Array<{ type: string; config?: Record<string, unknown> }> | undefined;
if (stages && stages.length > 0) {
lines.push('');
lines.push('Stages:');
for (let i = 0; i < stages.length; i++) {
const s = stages[i]!;
lines.push(` ${i + 1}. ${s.type}`);
if (s.config && Object.keys(s.config).length > 0) {
for (const [key, value] of Object.entries(s.config)) {
lines.push(` ${pad(key + ':', 20)}${String(value)}`);
}
}
}
}
return lines.join('\n');
}
function formatGenericDetail(obj: Record<string, unknown>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
@@ -629,6 +672,20 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
.action(async (resourceArg: string, idOrName: string, opts: { output: string; showValues?: boolean }) => {
const resource = resolveResource(resourceArg);
// ProxyModels are served by mcplocal, not mcpd
if (resource === 'proxymodels') {
const mcplocalUrl = deps.mcplocalUrl ?? 'http://localhost:3200';
const item = await fetchProxymodelFromMcplocal(mcplocalUrl, idOrName);
if (opts.output === 'json') {
deps.log(formatJson(item));
} else if (opts.output === 'yaml') {
deps.log(formatYaml(item));
} else {
deps.log(formatProxymodelDetail(item));
}
return;
}
// Resolve name → ID
let id: string;
if (resource === 'instances') {
@@ -733,3 +790,28 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
}
});
}
async function fetchProxymodelFromMcplocal(mcplocalUrl: string, name: string): Promise<Record<string, unknown>> {
const http = await import('node:http');
const url = `${mcplocalUrl}/proxymodels/${encodeURIComponent(name)}`;
return new Promise<Record<string, unknown>>((resolve, reject) => {
const req = http.get(url, { timeout: 5000 }, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
res.on('end', () => {
try {
if (res.statusCode === 404) {
reject(new Error(`ProxyModel '${name}' not found`));
return;
}
resolve(JSON.parse(data) as Record<string, unknown>);
} catch {
reject(new Error('Invalid response from mcplocal'));
}
});
});
req.on('error', () => reject(new Error(`Cannot connect to mcplocal at ${mcplocalUrl}`)));
req.on('timeout', () => { req.destroy(); reject(new Error('mcplocal request timed out')); });
});
}

View File

@@ -8,6 +8,7 @@ export interface GetCommandDeps {
fetchResource: (resource: string, id?: string, opts?: { project?: string; all?: boolean }) => Promise<unknown[]>;
log: (...args: string[]) => void;
getProject?: () => string | undefined;
mcplocalUrl?: string;
}
interface ServerRow {
@@ -23,6 +24,7 @@ interface ProjectRow {
name: string;
description: string;
proxyMode: string;
proxyModel: string;
gated: boolean;
ownerId: string;
servers?: Array<{ server: { name: string } }>;
@@ -85,6 +87,7 @@ interface RbacRow {
const projectColumns: Column<ProjectRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
{ header: 'PROXYMODEL', key: (r) => r.proxyModel || 'default', width: 12 },
{ header: 'GATED', key: (r) => r.gated ? 'yes' : 'no', width: 6 },
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
{ header: 'DESCRIPTION', key: 'description', width: 30 },
@@ -190,6 +193,22 @@ const serverAttachmentColumns: Column<ServerAttachmentRow>[] = [
{ header: 'PROJECT', key: 'project', width: 25 },
];
interface ProxymodelRow {
name: string;
source: string;
controller: string;
stages: string[];
cacheable: boolean;
}
const proxymodelColumns: Column<ProxymodelRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'SOURCE', key: 'source', width: 10 },
{ header: 'CONTROLLER', key: 'controller', width: 12 },
{ header: 'STAGES', key: (r) => r.stages.join(', '), width: 40 },
{ header: 'CACHEABLE', key: (r) => r.cacheable ? 'yes' : 'no', width: 10 },
];
function getColumnsForResource(resource: string): Column<Record<string, unknown>>[] {
switch (resource) {
case 'servers':
@@ -214,6 +233,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
return promptRequestColumns as unknown as Column<Record<string, unknown>>[];
case 'serverattachments':
return serverAttachmentColumns as unknown as Column<Record<string, unknown>>[];
case 'proxymodels':
return proxymodelColumns as unknown as Column<Record<string, unknown>>[];
default:
return [
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
@@ -268,6 +289,25 @@ export function createGetCommand(deps: GetCommandDeps): Command {
return;
}
// ProxyModels are served by mcplocal, not mcpd
if (resource === 'proxymodels') {
const mcplocalUrl = deps.mcplocalUrl ?? 'http://localhost:3200';
const items = await fetchProxymodels(mcplocalUrl, id);
if (opts.output === 'json') {
deps.log(formatJson(items));
} else if (opts.output === 'yaml') {
deps.log(formatYamlMultiDoc(items.map((i) => ({ kind: 'proxymodel', ...(i as Record<string, unknown>) }))));
} else {
if (items.length === 0) {
deps.log('No proxymodels found.');
return;
}
const columns = getColumnsForResource(resource);
deps.log(formatTable(items as Record<string, unknown>[], columns));
}
return;
}
const fetchOpts: { project?: string; all?: boolean } = {};
if (project) fetchOpts.project = project;
if (opts.all) fetchOpts.all = true;
@@ -343,3 +383,27 @@ async function handleGetAll(
deps.log(`\nUse -o yaml or -o json for apply-compatible output.`);
}
}
async function fetchProxymodels(mcplocalUrl: string, name?: string): Promise<unknown[]> {
const http = await import('node:http');
const url = name
? `${mcplocalUrl}/proxymodels/${encodeURIComponent(name)}`
: `${mcplocalUrl}/proxymodels`;
return new Promise<unknown[]>((resolve, reject) => {
const req = http.get(url, { timeout: 5000 }, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
res.on('end', () => {
try {
const parsed = JSON.parse(data) as unknown;
resolve(Array.isArray(parsed) ? parsed : [parsed]);
} catch {
reject(new Error('Invalid response from mcplocal'));
}
});
});
req.on('error', () => reject(new Error(`Cannot connect to mcplocal at ${mcplocalUrl}`)));
req.on('timeout', () => { req.destroy(); reject(new Error('mcplocal request timed out')); });
});
}

View File

@@ -24,6 +24,9 @@ export const RESOURCE_ALIASES: Record<string, string> = {
serverattachment: 'serverattachments',
serverattachments: 'serverattachments',
sa: 'serverattachments',
proxymodel: 'proxymodels',
proxymodels: 'proxymodels',
pm: 'proxymodels',
all: 'all',
};

View File

@@ -10,14 +10,22 @@ import { APP_VERSION } from '@mcpctl/shared';
// ANSI helpers
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
const YELLOW = '\x1b[33m';
const DIM = '\x1b[2m';
const RESET = '\x1b[0m';
const CLEAR_LINE = '\x1b[2K\r';
interface ProviderDetail {
managed: boolean;
state?: string;
lastError?: string;
}
interface ProvidersInfo {
providers: string[];
tiers: { fast: string[]; heavy: string[] };
health: Record<string, boolean>;
details?: Record<string, ProviderDetail>;
}
export interface StatusCommandDeps {
@@ -155,6 +163,40 @@ function isMultiProvider(llm: unknown): boolean {
return !!llm && typeof llm === 'object' && 'providers' in llm;
}
/**
* Format a single provider's status string for display.
* Managed providers show lifecycle state; regular providers show health check result.
*/
function formatProviderStatus(name: string, info: ProvidersInfo, ansi: boolean): string {
const detail = info.details?.[name];
if (detail?.managed) {
switch (detail.state) {
case 'running':
return ansi ? `${name} ${GREEN}✓ running${RESET}` : `${name} ✓ running`;
case 'stopped':
return ansi
? `${name} ${DIM}○ stopped (auto-starts on demand)${RESET}`
: `${name} ○ stopped (auto-starts on demand)`;
case 'starting':
return ansi ? `${name} ${YELLOW}⟳ starting...${RESET}` : `${name} ⟳ starting...`;
case 'error':
return ansi
? `${name} ${RED}✗ error: ${detail.lastError ?? 'unknown'}${RESET}`
: `${name} ✗ error: ${detail.lastError ?? 'unknown'}`;
default: {
const ok = info.health[name];
return ansi
? ok ? `${name} ${GREEN}${RESET}` : `${name} ${RED}${RESET}`
: ok ? `${name}` : `${name}`;
}
}
}
const ok = info.health[name];
return ansi
? ok ? `${name} ${GREEN}${RESET}` : `${name} ${RED}${RESET}`
: ok ? `${name}` : `${name}`;
}
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, isTTY } = { ...defaultDeps, ...deps };
@@ -241,10 +283,7 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
const names = providersInfo.tiers[tier];
if (names.length === 0) continue;
const label = tier === 'fast' ? 'LLM (fast): ' : 'LLM (heavy):';
const parts = names.map((n) => {
const ok = providersInfo.health[n];
return ok ? `${n} ${GREEN}${RESET}` : `${n} ${RED}${RESET}`;
});
const parts = names.map((n) => formatProviderStatus(n, providersInfo, true));
log(`${label} ${parts.join(', ')}`);
}
} else {
@@ -267,10 +306,7 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
const names = providersInfo.tiers[tier];
if (names.length === 0) continue;
const label = tier === 'fast' ? 'LLM (fast): ' : 'LLM (heavy):';
const parts = names.map((n) => {
const ok = providersInfo.health[n];
return ok ? `${n}` : `${n}`;
});
const parts = names.map((n) => formatProviderStatus(n, providersInfo, false));
log(`${label} ${parts.join(', ')}`);
}
} else {

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
export const LLM_PROVIDERS = ['gemini-cli', 'ollama', 'anthropic', 'openai', 'deepseek', 'vllm', 'none'] as const;
export const LLM_PROVIDERS = ['gemini-cli', 'ollama', 'anthropic', 'openai', 'deepseek', 'vllm', 'vllm-managed', 'none'] as const;
export type LlmProviderName = typeof LLM_PROVIDERS[number];
export const LLM_TIERS = ['fast', 'heavy'] as const;
@@ -34,6 +34,18 @@ export const LlmProviderEntrySchema = z.object({
binaryPath: z.string().optional(),
/** Tier assignment */
tier: z.enum(LLM_TIERS).optional(),
/** vllm-managed: path to Python venv (e.g. "~/vllm_env") */
venvPath: z.string().optional(),
/** vllm-managed: port for vLLM HTTP server */
port: z.number().int().positive().optional(),
/** vllm-managed: GPU memory utilization fraction */
gpuMemoryUtilization: z.number().min(0.1).max(1.0).optional(),
/** vllm-managed: max model context length */
maxModelLen: z.number().int().positive().optional(),
/** vllm-managed: minutes of idle before stopping vLLM */
idleTimeoutMinutes: z.number().int().positive().optional(),
/** vllm-managed: extra args for `vllm serve` */
extraArgs: z.array(z.string()).optional(),
}).strict();
export type LlmProviderEntry = z.infer<typeof LlmProviderEntrySchema>;

View File

@@ -81,6 +81,12 @@ export function createProgram(): Command {
return attachments;
}
// --project scoping for servers: show only attached servers
if (!nameOrId && resource === 'servers' && projectName) {
const projectId = await resolveNameOrId(client, 'projects', projectName);
return client.get<unknown[]>(`/api/v1/projects/${projectId}/servers`);
}
// --project scoping for prompts and promptrequests
if (!nameOrId && (resource === 'prompts' || resource === 'promptrequests')) {
if (projectName) {
@@ -138,6 +144,7 @@ export function createProgram(): Command {
fetchResource,
log: (...args) => console.log(...args),
getProject: () => program.opts().project as string | undefined,
mcplocalUrl: config.mcplocalUrl,
}));
program.addCommand(createDescribeCommand({
@@ -145,6 +152,7 @@ export function createProgram(): Command {
fetchResource: fetchSingleResource,
fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`),
log: (...args) => console.log(...args),
mcplocalUrl: config.mcplocalUrl,
}));
program.addCommand(createDeleteCommand({

View File

@@ -96,7 +96,7 @@ describe('config claude', () => {
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['mcpctl-inspect']).toEqual({
command: 'mcpctl',
args: ['console', '--inspect', '--stdin-mcp'],
args: ['console', '--stdin-mcp'],
});
expect(output.join('\n')).toContain('1 server(s)');
});

View File

@@ -161,9 +161,11 @@ describe('config setup wizard', () => {
describe('provider: anthropic', () => {
it('prompts for API key and saves to secret store', async () => {
// Answers: select provider, enter API key, select model
// Flow: simple → anthropic → (no existing key) → whichBinary('claude') returns null →
// log tip → password prompt → select model
const deps = buildDeps({
answers: ['simple', 'anthropic', 'sk-ant-new-key', 'claude-haiku-3-5-20241022'],
whichBinary: vi.fn(async () => null),
});
await runSetup(deps);
@@ -194,15 +196,84 @@ describe('config setup wizard', () => {
it('allows replacing existing key', async () => {
// Answers: select provider, confirm change=true, enter new key, select model
// Change=true → promptForAnthropicKey → whichBinary returns null → password prompt
const deps = buildDeps({
secrets: { 'anthropic-api-key': 'sk-ant-old' },
answers: ['simple', 'anthropic', true, 'sk-ant-new', 'claude-haiku-3-5-20241022'],
whichBinary: vi.fn(async () => null),
});
await runSetup(deps);
expect(deps.secretStore.set).toHaveBeenCalledWith('anthropic-api-key', 'sk-ant-new');
cleanup();
});
it('detects claude binary and prompts for OAuth token', async () => {
// Flow: simple → anthropic → (no existing key) → whichBinary finds claude →
// confirm OAuth=true → password prompt → select model
const deps = buildDeps({
answers: ['simple', 'anthropic', true, 'sk-ant-oat01-test-token', 'claude-haiku-3-5-20241022'],
whichBinary: vi.fn(async () => '/usr/bin/claude'),
});
await runSetup(deps);
expect(deps.secretStore.set).toHaveBeenCalledWith('anthropic-api-key', 'sk-ant-oat01-test-token');
expect(logs.some((l) => l.includes('Found Claude CLI at'))).toBe(true);
expect(logs.some((l) => l.includes('claude setup-token'))).toBe(true);
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.provider).toBe('anthropic');
expect(llm.model).toBe('claude-haiku-3-5-20241022');
cleanup();
});
it('falls back to API key when claude binary not found', async () => {
// Flow: simple → anthropic → (no existing key) → whichBinary returns null →
// password prompt (API key) → select model
const deps = buildDeps({
answers: ['simple', 'anthropic', 'sk-ant-api03-test', 'claude-sonnet-4-20250514'],
whichBinary: vi.fn(async () => null),
});
await runSetup(deps);
expect(deps.secretStore.set).toHaveBeenCalledWith('anthropic-api-key', 'sk-ant-api03-test');
expect(logs.some((l) => l.includes('Tip: Install Claude CLI'))).toBe(true);
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.model).toBe('claude-sonnet-4-20250514');
cleanup();
});
it('shows OAuth label when existing token is OAuth', async () => {
// Flow: simple → anthropic → existing OAuth key → confirm change=false → select model
const deps = buildDeps({
secrets: { 'anthropic-api-key': 'sk-ant-oat01-existing-token' },
answers: ['simple', 'anthropic', false, 'claude-haiku-3-5-20241022'],
});
await runSetup(deps);
// Should NOT have called set (kept existing key)
expect(deps.secretStore.set).not.toHaveBeenCalled();
// Confirm prompt should have received an OAuth label
expect(deps.prompt.confirm).toHaveBeenCalledWith(
expect.stringContaining('OAuth token stored'),
false,
);
cleanup();
});
it('declines OAuth and enters API key instead', async () => {
// Flow: simple → anthropic → (no existing key) → whichBinary finds claude →
// confirm OAuth=false → password prompt (API key) → select model
const deps = buildDeps({
answers: ['simple', 'anthropic', false, 'sk-ant-api03-manual', 'claude-sonnet-4-20250514'],
whichBinary: vi.fn(async () => '/usr/bin/claude'),
});
await runSetup(deps);
expect(deps.secretStore.set).toHaveBeenCalledWith('anthropic-api-key', 'sk-ant-api03-manual');
cleanup();
});
});
describe('provider: vllm', () => {
@@ -273,6 +344,44 @@ describe('config setup wizard', () => {
});
});
describe('advanced mode: duplicate names', () => {
it('generates unique default name when same provider added to both tiers', async () => {
// Flow: advanced →
// add fast? yes → anthropic → name "anthropic" (default) → whichBinary null → key → model → add more? no →
// add heavy? yes → anthropic → name "anthropic-2" (deduped default) → existing key, keep → model → add more? no
const deps = buildDeps({
answers: [
'advanced',
// fast tier
true, // add fast?
'anthropic', // fast provider type
'anthropic', // provider name (default)
'sk-ant-oat01-token', // API key (whichBinary returns null → password prompt)
'claude-haiku-3-5-20241022', // model
false, // add another fast?
// heavy tier
true, // add heavy?
'anthropic', // heavy provider type
'anthropic-2', // provider name (deduped default)
false, // keep existing key
'claude-opus-4-20250514', // model
false, // add another heavy?
],
whichBinary: vi.fn(async () => null),
});
await runSetup(deps);
const config = readConfig();
const llm = config.llm as { providers: Array<{ name: string; type: string; model: string; tier: string }> };
expect(llm.providers).toHaveLength(2);
expect(llm.providers[0].name).toBe('anthropic');
expect(llm.providers[0].tier).toBe('fast');
expect(llm.providers[1].name).toBe('anthropic-2');
expect(llm.providers[1].tier).toBe('heavy');
cleanup();
});
});
describe('output messages', () => {
it('shows restart instruction', async () => {
const deps = buildDeps({ answers: ['simple', 'gemini-cli', 'gemini-2.5-flash'] });

View File

@@ -85,17 +85,15 @@ describe('fish completions', () => {
}
});
it('resource name functions use jq .[][].name to unwrap wrapped JSON and avoid nested matches', () => {
// API returns { "resources": [...] } not [...], so .[].name fails silently.
// Must use .[][].name to unwrap the outer object then iterate the array.
// Also must not use string match regex which matches nested name fields.
it('resource name functions use jq to extract names and avoid nested matches', () => {
const resourceNamesFn = fishFile.match(/function __mcpctl_resource_names[\s\S]*?^end/m)?.[0] ?? '';
const projectNamesFn = fishFile.match(/function __mcpctl_project_names[\s\S]*?^end/m)?.[0] ?? '';
expect(resourceNamesFn, '__mcpctl_resource_names must use jq .[][].name').toContain("jq -r '.[][].name'");
// Resource names: uses .[].name for most resources, .[][].server.name for instances
expect(resourceNamesFn, '__mcpctl_resource_names must use jq for name extraction').toContain("jq -r");
expect(resourceNamesFn, '__mcpctl_resource_names must not use string match on name').not.toMatch(/string match.*"name"/);
expect(projectNamesFn, '__mcpctl_project_names must use jq .[][].name').toContain("jq -r '.[][].name'");
expect(projectNamesFn, '__mcpctl_project_names must use jq for name extraction').toContain("jq -r");
expect(projectNamesFn, '__mcpctl_project_names must not use string match on name').not.toMatch(/string match.*"name"/);
});
@@ -179,11 +177,9 @@ describe('bash completions', () => {
expect(bashFile).toContain('--project');
});
it('resource name function uses jq .[][].name to unwrap wrapped JSON and avoid nested matches', () => {
it('resource name function uses jq to extract names and avoid nested matches', () => {
const fnMatch = bashFile.match(/_mcpctl_resource_names\(\)[\s\S]*?\n\s*\}/)?.[0] ?? '';
expect(fnMatch, '_mcpctl_resource_names must use jq .[][].name').toContain("jq -r '.[][].name'");
expect(fnMatch, '_mcpctl_resource_names must use jq for name extraction').toContain("jq -r");
expect(fnMatch, '_mcpctl_resource_names must not use grep on name').not.toMatch(/grep.*"name"/);
// Guard against .[].name (single bracket) which fails on wrapped JSON
expect(fnMatch, '_mcpctl_resource_names must not use .[].name (needs .[][].name)').not.toMatch(/jq.*'\.\[\]\.name'/);
});
});