- 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>
311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
/**
|
|
* 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: '' };
|
|
}
|
|
}
|