feat: interactive MCP console (mcpctl console <project>)
Ink-based TUI that shows exactly what an LLM sees through MCP. Browse tools/resources/prompts, execute them, and see raw JSON-RPC traffic in a protocol log. Supports gated session flow with begin_session, raw JSON-RPC input, and session reconnect. - McpSession class wrapping HTTP transport with typed methods - 12 React/Ink components (header, protocol-log, menu, tool/resource/prompt views, etc.) - 21 unit tests for McpSession against a mock MCP server - Fish + Bash completions with project name argument - bun compile with --external react-devtools-core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,16 +16,20 @@
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inkjs/ui": "^2.0.0",
|
||||
"@mcpctl/db": "workspace:*",
|
||||
"@mcpctl/shared": "workspace:*",
|
||||
"chalk": "^5.4.0",
|
||||
"commander": "^13.0.0",
|
||||
"ink": "^6.8.0",
|
||||
"inquirer": "^12.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.3.0"
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.14"
|
||||
}
|
||||
}
|
||||
|
||||
368
src/cli/src/commands/console/app.tsx
Normal file
368
src/cli/src/commands/console/app.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
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();
|
||||
}
|
||||
60
src/cli/src/commands/console/components/begin-session.tsx
Normal file
60
src/cli/src/commands/console/components/begin-session.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { TextInput, Spinner } from '@inkjs/ui';
|
||||
import type { McpSession } from '../mcp-session.js';
|
||||
|
||||
interface BeginSessionViewProps {
|
||||
session: McpSession;
|
||||
onDone: (result: unknown) => void;
|
||||
onError: (msg: string) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function BeginSessionView({ session, onDone, onError }: BeginSessionViewProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const tags = input
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
if (tags.length === 0) {
|
||||
onError('Enter at least one tag (comma-separated)');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await session.callTool('begin_session', { tags });
|
||||
onDone(result);
|
||||
} catch (err) {
|
||||
onError(`begin_session failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box gap={1}>
|
||||
<Spinner label="Calling begin_session..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
11
src/cli/src/commands/console/components/connecting-view.tsx
Normal file
11
src/cli/src/commands/console/components/connecting-view.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import { Spinner } from '@inkjs/ui';
|
||||
|
||||
export function ConnectingView() {
|
||||
return (
|
||||
<Box gap={1}>
|
||||
<Spinner label="Connecting..." />
|
||||
<Text dimColor>Sending initialize request</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
26
src/cli/src/commands/console/components/header.tsx
Normal file
26
src/cli/src/commands/console/components/header.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
interface HeaderProps {
|
||||
projectName: string;
|
||||
sessionId?: string;
|
||||
gated: boolean;
|
||||
reconnecting: boolean;
|
||||
}
|
||||
|
||||
export function Header({ projectName, sessionId, gated, reconnecting }: HeaderProps) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="single" borderBottom={true} borderTop={false} borderLeft={false} borderRight={false} paddingX={1}>
|
||||
<Box gap={2}>
|
||||
<Text bold color="white" backgroundColor="blue"> mcpctl console </Text>
|
||||
<Text bold>{projectName}</Text>
|
||||
{sessionId && <Text dimColor>session: {sessionId.slice(0, 8)}</Text>}
|
||||
{gated ? (
|
||||
<Text color="yellow" bold>[GATED]</Text>
|
||||
) : (
|
||||
<Text color="green" bold>[OPEN]</Text>
|
||||
)}
|
||||
{reconnecting && <Text color="cyan">reconnecting...</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
39
src/cli/src/commands/console/components/main-menu.tsx
Normal file
39
src/cli/src/commands/console/components/main-menu.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import { Select } from '@inkjs/ui';
|
||||
|
||||
type MenuAction = 'begin-session' | 'tools' | 'resources' | 'prompts' | 'raw' | 'session-info';
|
||||
|
||||
interface MainMenuProps {
|
||||
gated: boolean;
|
||||
toolCount: number;
|
||||
resourceCount: number;
|
||||
promptCount: number;
|
||||
onSelect: (action: MenuAction) => void;
|
||||
}
|
||||
|
||||
export function MainMenu({ gated, toolCount, resourceCount, promptCount, onSelect }: MainMenuProps) {
|
||||
const items = gated
|
||||
? [
|
||||
{ label: 'Begin Session — call begin_session with tags to ungate', value: 'begin-session' as MenuAction },
|
||||
{ label: 'Raw JSON-RPC — send freeform JSON-RPC messages', value: 'raw' as MenuAction },
|
||||
{ label: 'Session Info — view initialize result and session state', value: 'session-info' as MenuAction },
|
||||
]
|
||||
: [
|
||||
{ label: `Tools (${toolCount}) — browse and execute MCP tools`, value: 'tools' as MenuAction },
|
||||
{ label: `Resources (${resourceCount}) — browse and read MCP resources`, value: 'resources' as MenuAction },
|
||||
{ label: `Prompts (${promptCount}) — browse and get MCP prompts`, value: 'prompts' as MenuAction },
|
||||
{ label: 'Raw JSON-RPC — send freeform JSON-RPC messages', value: 'raw' as MenuAction },
|
||||
{ label: 'Session Info — view initialize result and session state', value: 'session-info' as MenuAction },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>
|
||||
{gated ? 'Session is gated — call begin_session to ungate:' : 'What would you like to explore?'}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Select options={items} onChange={(v) => onSelect(v as MenuAction)} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
57
src/cli/src/commands/console/components/prompt-list.tsx
Normal file
57
src/cli/src/commands/console/components/prompt-list.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Select, Spinner } from '@inkjs/ui';
|
||||
import type { McpPrompt, McpSession } from '../mcp-session.js';
|
||||
|
||||
interface PromptListViewProps {
|
||||
prompts: McpPrompt[];
|
||||
session: McpSession;
|
||||
onResult: (prompt: McpPrompt, content: unknown) => void;
|
||||
onError: (msg: string) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function PromptListView({ prompts, session, onResult, onError }: PromptListViewProps) {
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
if (prompts.length === 0) {
|
||||
return <Text dimColor>No prompts available.</Text>;
|
||||
}
|
||||
|
||||
const options = prompts.map((p) => ({
|
||||
label: `${p.name}${p.description ? ` — ${p.description.slice(0, 60)}` : ''}`,
|
||||
value: p.name,
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box gap={1}>
|
||||
<Spinner label={`Getting prompt ${loading}...`} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Prompts ({prompts.length}):</Text>
|
||||
<Box marginTop={1}>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={async (name) => {
|
||||
const prompt = prompts.find((p) => p.name === name);
|
||||
if (!prompt) return;
|
||||
setLoading(name);
|
||||
try {
|
||||
const result = await session.getPrompt(name);
|
||||
onResult(prompt, result);
|
||||
} catch (err) {
|
||||
onError(`prompts/get failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
55
src/cli/src/commands/console/components/protocol-log.tsx
Normal file
55
src/cli/src/commands/console/components/protocol-log.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
71
src/cli/src/commands/console/components/raw-jsonrpc.tsx
Normal file
71
src/cli/src/commands/console/components/raw-jsonrpc.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { TextInput, Spinner } from '@inkjs/ui';
|
||||
import type { McpSession } from '../mcp-session.js';
|
||||
|
||||
interface RawJsonRpcViewProps {
|
||||
session: McpSession;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function RawJsonRpcView({ session }: RawJsonRpcViewProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!input.trim()) return;
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await session.sendRaw(input);
|
||||
try {
|
||||
setResult(JSON.stringify(JSON.parse(response), null, 2));
|
||||
} catch {
|
||||
setResult(response);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Raw JSON-RPC</Text>
|
||||
<Text dimColor>Enter a full JSON-RPC message and press Enter to send:</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="cyan">> </Text>
|
||||
<TextInput
|
||||
placeholder='{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||
onChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{loading && (
|
||||
<Box marginTop={1}>
|
||||
<Spinner label="Sending..." />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">Error: {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>Response:</Text>
|
||||
<Text>{result}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
60
src/cli/src/commands/console/components/resource-list.tsx
Normal file
60
src/cli/src/commands/console/components/resource-list.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Select, Spinner } from '@inkjs/ui';
|
||||
import type { McpResource, McpSession } from '../mcp-session.js';
|
||||
|
||||
interface ResourceListViewProps {
|
||||
resources: McpResource[];
|
||||
session: McpSession;
|
||||
onResult: (resource: McpResource, content: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ResourceListView({ resources, session, onResult, onError }: ResourceListViewProps) {
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
if (resources.length === 0) {
|
||||
return <Text dimColor>No resources available.</Text>;
|
||||
}
|
||||
|
||||
const options = resources.map((r) => ({
|
||||
label: `${r.uri}${r.name ? ` (${r.name})` : ''}${r.description ? ` — ${r.description.slice(0, 50)}` : ''}`,
|
||||
value: r.uri,
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box gap={1}>
|
||||
<Spinner label={`Reading ${loading}...`} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Resources ({resources.length}):</Text>
|
||||
<Box marginTop={1}>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={async (uri) => {
|
||||
const resource = resources.find((r) => r.uri === uri);
|
||||
if (!resource) return;
|
||||
setLoading(uri);
|
||||
try {
|
||||
const result = await session.readResource(uri);
|
||||
const content = result.contents
|
||||
.map((c) => c.text ?? `[${c.mimeType ?? 'binary'}]`)
|
||||
.join('\n');
|
||||
onResult(resource, content);
|
||||
} catch (err) {
|
||||
onError(`resources/read failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
27
src/cli/src/commands/console/components/result-view.tsx
Normal file
27
src/cli/src/commands/console/components/result-view.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
interface ResultViewProps {
|
||||
title: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
function formatJson(data: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
|
||||
export function ResultView({ title, data }: ResultViewProps) {
|
||||
const formatted = formatJson(data);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="cyan">{title}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>{formatted}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
92
src/cli/src/commands/console/components/tool-detail.tsx
Normal file
92
src/cli/src/commands/console/components/tool-detail.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { TextInput, Spinner } from '@inkjs/ui';
|
||||
import type { McpTool, McpSession } from '../mcp-session.js';
|
||||
|
||||
interface ToolDetailViewProps {
|
||||
tool: McpTool;
|
||||
session: McpSession;
|
||||
onResult: (data: unknown) => void;
|
||||
onError: (msg: string) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface SchemaProperty {
|
||||
type?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function ToolDetailView({ tool, session, onResult, onError }: ToolDetailViewProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [argsJson, setArgsJson] = useState('{}');
|
||||
|
||||
// Extract properties from input schema
|
||||
const schema = tool.inputSchema as { properties?: Record<string, SchemaProperty>; required?: string[] } | undefined;
|
||||
const properties = schema?.properties ?? {};
|
||||
const required = new Set(schema?.required ?? []);
|
||||
const propNames = Object.keys(properties);
|
||||
|
||||
const handleExecute = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let args: Record<string, unknown>;
|
||||
try {
|
||||
args = JSON.parse(argsJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
onError('Invalid JSON for arguments');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const result = await session.callTool(tool.name, args);
|
||||
onResult(result);
|
||||
} catch (err) {
|
||||
onError(`tools/call failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box gap={1}>
|
||||
<Spinner label={`Calling ${tool.name}...`} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="cyan">{tool.name}</Text>
|
||||
{tool.description && <Text>{tool.description}</Text>}
|
||||
|
||||
{propNames.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>Schema:</Text>
|
||||
{propNames.map((name) => {
|
||||
const prop = properties[name]!;
|
||||
const req = required.has(name) ? ' (required)' : '';
|
||||
return (
|
||||
<Text key={name} dimColor>
|
||||
{name}: {prop.type ?? 'any'}{req}{prop.description ? ` — ${prop.description}` : ''}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>Arguments (JSON):</Text>
|
||||
<Box>
|
||||
<Text color="cyan">> </Text>
|
||||
<TextInput
|
||||
placeholder="{}"
|
||||
defaultValue="{}"
|
||||
onChange={setArgsJson}
|
||||
onSubmit={handleExecute}
|
||||
/>
|
||||
</Box>
|
||||
<Text dimColor>Press Enter to execute</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
35
src/cli/src/commands/console/components/tool-list.tsx
Normal file
35
src/cli/src/commands/console/components/tool-list.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import { Select } from '@inkjs/ui';
|
||||
import type { McpTool } from '../mcp-session.js';
|
||||
|
||||
interface ToolListViewProps {
|
||||
tools: McpTool[];
|
||||
onSelect: (tool: McpTool) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ToolListView({ tools, onSelect }: ToolListViewProps) {
|
||||
if (tools.length === 0) {
|
||||
return <Text dimColor>No tools available.</Text>;
|
||||
}
|
||||
|
||||
const options = tools.map((t) => ({
|
||||
label: `${t.name}${t.description ? ` — ${t.description.slice(0, 60)}` : ''}`,
|
||||
value: t.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Tools ({tools.length}):</Text>
|
||||
<Box marginTop={1}>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={(value) => {
|
||||
const tool = tools.find((t) => t.name === value);
|
||||
if (tool) onSelect(tool);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
46
src/cli/src/commands/console/index.ts
Normal file
46
src/cli/src/commands/console/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Command } from 'commander';
|
||||
|
||||
export interface ConsoleCommandDeps {
|
||||
getProject: () => string | undefined;
|
||||
configLoader?: () => { mcplocalUrl: string };
|
||||
credentialsLoader?: () => { token: string } | null;
|
||||
}
|
||||
|
||||
export function createConsoleCommand(deps: ConsoleCommandDeps): Command {
|
||||
const cmd = new Command('console')
|
||||
.description('Interactive MCP console — see what an LLM sees when attached to a project')
|
||||
.argument('<project>', 'Project name to connect to')
|
||||
.action(async (projectName: string) => {
|
||||
let mcplocalUrl = 'http://localhost:3200';
|
||||
if (deps.configLoader) {
|
||||
mcplocalUrl = deps.configLoader().mcplocalUrl;
|
||||
} else {
|
||||
try {
|
||||
const { loadConfig } = await import('../../config/index.js');
|
||||
mcplocalUrl = loadConfig().mcplocalUrl;
|
||||
} catch {
|
||||
// Use default
|
||||
}
|
||||
}
|
||||
|
||||
let token: string | undefined;
|
||||
if (deps.credentialsLoader) {
|
||||
token = deps.credentialsLoader()?.token;
|
||||
} else {
|
||||
try {
|
||||
const { loadCredentials } = await import('../../auth/index.js');
|
||||
token = loadCredentials()?.token;
|
||||
} catch {
|
||||
// No credentials
|
||||
}
|
||||
}
|
||||
|
||||
const 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 });
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
238
src/cli/src/commands/console/mcp-session.ts
Normal file
238
src/cli/src/commands/console/mcp-session.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* MCP protocol session — wraps HTTP transport with typed methods.
|
||||
*
|
||||
* Every request/response is logged via the onLog callback so
|
||||
* the console UI can display raw JSON-RPC traffic.
|
||||
*/
|
||||
|
||||
import { postJsonRpc, sendDelete, extractJsonRpcMessages } from '../mcp.js';
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: Date;
|
||||
direction: 'request' | 'response' | 'error';
|
||||
method?: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface McpResource {
|
||||
uri: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface McpPrompt {
|
||||
name: string;
|
||||
description?: string;
|
||||
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
|
||||
}
|
||||
|
||||
export interface InitializeResult {
|
||||
protocolVersion: string;
|
||||
serverInfo: { name: string; version: string };
|
||||
capabilities: Record<string, unknown>;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
export interface CallToolResult {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface ReadResourceResult {
|
||||
contents: Array<{ uri: string; mimeType?: string; text?: string }>;
|
||||
}
|
||||
|
||||
export class McpSession {
|
||||
private sessionId?: string;
|
||||
private nextId = 1;
|
||||
private log: LogEntry[] = [];
|
||||
|
||||
onLog?: (entry: LogEntry) => void;
|
||||
|
||||
constructor(
|
||||
private readonly endpointUrl: string,
|
||||
private readonly token?: string,
|
||||
) {}
|
||||
|
||||
getSessionId(): string | undefined {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
getLog(): LogEntry[] {
|
||||
return this.log;
|
||||
}
|
||||
|
||||
async initialize(): Promise<InitializeResult> {
|
||||
const request = {
|
||||
jsonrpc: '2.0',
|
||||
id: this.nextId++,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'mcpctl-console', version: '1.0.0' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = await this.send(request);
|
||||
|
||||
// Send initialized notification
|
||||
const notification = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
};
|
||||
await this.sendNotification(notification);
|
||||
|
||||
return result as InitializeResult;
|
||||
}
|
||||
|
||||
async listTools(): Promise<McpTool[]> {
|
||||
const result = await this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: this.nextId++,
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
}) as { tools: McpTool[] };
|
||||
return result.tools ?? [];
|
||||
}
|
||||
|
||||
async callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
|
||||
return await this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: this.nextId++,
|
||||
method: 'tools/call',
|
||||
params: { name, arguments: args },
|
||||
}) as CallToolResult;
|
||||
}
|
||||
|
||||
async listResources(): Promise<McpResource[]> {
|
||||
const result = await this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: this.nextId++,
|
||||
method: 'resources/list',
|
||||
params: {},
|
||||
}) as { resources: McpResource[] };
|
||||
return result.resources ?? [];
|
||||
}
|
||||
|
||||
async readResource(uri: string): Promise<ReadResourceResult> {
|
||||
return await this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: this.nextId++,
|
||||
method: 'resources/read',
|
||||
params: { uri },
|
||||
}) as ReadResourceResult;
|
||||
}
|
||||
|
||||
async listPrompts(): Promise<McpPrompt[]> {
|
||||
const result = await this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: this.nextId++,
|
||||
method: 'prompts/list',
|
||||
params: {},
|
||||
}) as { prompts: McpPrompt[] };
|
||||
return result.prompts ?? [];
|
||||
}
|
||||
|
||||
async getPrompt(name: string, args?: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: this.nextId++,
|
||||
method: 'prompts/get',
|
||||
params: { name, arguments: args ?? {} },
|
||||
});
|
||||
}
|
||||
|
||||
async sendRaw(json: string): Promise<string> {
|
||||
this.addLog('request', undefined, JSON.parse(json));
|
||||
|
||||
const result = await postJsonRpc(this.endpointUrl, json, this.sessionId, this.token);
|
||||
|
||||
if (!this.sessionId) {
|
||||
const sid = result.headers['mcp-session-id'];
|
||||
if (typeof sid === 'string') {
|
||||
this.sessionId = sid;
|
||||
}
|
||||
}
|
||||
|
||||
const messages = extractJsonRpcMessages(result.headers['content-type'], result.body);
|
||||
const combined = messages.join('\n');
|
||||
|
||||
for (const msg of messages) {
|
||||
try {
|
||||
this.addLog('response', undefined, JSON.parse(msg));
|
||||
} catch {
|
||||
this.addLog('response', undefined, msg);
|
||||
}
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.sessionId) {
|
||||
await sendDelete(this.endpointUrl, this.sessionId, this.token);
|
||||
this.sessionId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async send(request: Record<string, unknown>): Promise<unknown> {
|
||||
const method = request.method as string;
|
||||
this.addLog('request', method, request);
|
||||
|
||||
const body = JSON.stringify(request);
|
||||
let result;
|
||||
try {
|
||||
result = await postJsonRpc(this.endpointUrl, body, this.sessionId, this.token);
|
||||
} catch (err) {
|
||||
this.addLog('error', method, { error: err instanceof Error ? err.message : String(err) });
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Capture session ID
|
||||
if (!this.sessionId) {
|
||||
const sid = result.headers['mcp-session-id'];
|
||||
if (typeof sid === 'string') {
|
||||
this.sessionId = sid;
|
||||
}
|
||||
}
|
||||
|
||||
const messages = extractJsonRpcMessages(result.headers['content-type'], result.body);
|
||||
const firstMsg = messages[0];
|
||||
if (!firstMsg) {
|
||||
throw new Error(`Empty response for ${method}`);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(firstMsg) as { result?: unknown; error?: { code: number; message: string } };
|
||||
this.addLog('response', method, parsed);
|
||||
|
||||
if (parsed.error) {
|
||||
throw new Error(`MCP error ${parsed.error.code}: ${parsed.error.message}`);
|
||||
}
|
||||
|
||||
return parsed.result;
|
||||
}
|
||||
|
||||
private async sendNotification(notification: Record<string, unknown>): Promise<void> {
|
||||
const body = JSON.stringify(notification);
|
||||
this.addLog('request', notification.method as string, notification);
|
||||
try {
|
||||
await postJsonRpc(this.endpointUrl, body, this.sessionId, this.token);
|
||||
} catch {
|
||||
// Notifications are fire-and-forget
|
||||
}
|
||||
}
|
||||
|
||||
private addLog(direction: LogEntry['direction'], method: string | undefined, body: unknown): void {
|
||||
const entry: LogEntry = { timestamp: new Date(), direction, method, body };
|
||||
this.log.push(entry);
|
||||
this.onLog?.(entry);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export interface McpBridgeOptions {
|
||||
stderr: NodeJS.WritableStream;
|
||||
}
|
||||
|
||||
function postJsonRpc(
|
||||
export function postJsonRpc(
|
||||
url: string,
|
||||
body: string,
|
||||
sessionId: string | undefined,
|
||||
@@ -61,7 +61,7 @@ function postJsonRpc(
|
||||
});
|
||||
}
|
||||
|
||||
function sendDelete(
|
||||
export function sendDelete(
|
||||
url: string,
|
||||
sessionId: string,
|
||||
token: string | undefined,
|
||||
@@ -99,7 +99,7 @@ function sendDelete(
|
||||
* Extract JSON-RPC messages from an HTTP response body.
|
||||
* Handles both plain JSON and SSE (text/event-stream) formats.
|
||||
*/
|
||||
function extractJsonRpcMessages(contentType: string | undefined, body: string): string[] {
|
||||
export function extractJsonRpcMessages(contentType: string | undefined, body: string): string[] {
|
||||
if (contentType?.includes('text/event-stream')) {
|
||||
// Parse SSE: extract data: lines
|
||||
const messages: string[] = [];
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
||||
import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js';
|
||||
import { createMcpCommand } from './commands/mcp.js';
|
||||
import { createPatchCommand } from './commands/patch.js';
|
||||
import { createConsoleCommand } from './commands/console/index.js';
|
||||
import { ApiClient, ApiError } from './api-client.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
import { loadCredentials } from './auth/index.js';
|
||||
@@ -173,6 +174,10 @@ export function createProgram(): Command {
|
||||
getProject: () => program.opts().project as string | undefined,
|
||||
}), { hidden: true });
|
||||
|
||||
program.addCommand(createConsoleCommand({
|
||||
getProject: () => program.opts().project as string | undefined,
|
||||
}));
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
|
||||
464
src/cli/tests/commands/console-session.test.ts
Normal file
464
src/cli/tests/commands/console-session.test.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import { McpSession } from '../../src/commands/console/mcp-session.js';
|
||||
import type { LogEntry } from '../../src/commands/console/mcp-session.js';
|
||||
|
||||
// ---- Mock MCP server ----
|
||||
|
||||
let mockServer: http.Server;
|
||||
let mockPort: number;
|
||||
let sessionCounter = 0;
|
||||
|
||||
interface RecordedRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const recorded: RecordedRequest[] = [];
|
||||
|
||||
function makeJsonRpcResponse(id: number | string | null, result: unknown) {
|
||||
return JSON.stringify({ jsonrpc: '2.0', id, result });
|
||||
}
|
||||
|
||||
function makeJsonRpcError(id: number | string, code: number, message: string) {
|
||||
return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
mockServer = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf-8');
|
||||
recorded.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers, body });
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Assign session ID on first request
|
||||
const sid = req.headers['mcp-session-id'] ?? `session-${++sessionCounter}`;
|
||||
res.setHeader('mcp-session-id', sid);
|
||||
res.setHeader('content-type', 'application/json');
|
||||
|
||||
let parsed: { method?: string; id?: number | string };
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
res.writeHead(400);
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const method = parsed.method;
|
||||
const id = parsed.id;
|
||||
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
res.writeHead(200);
|
||||
res.end(makeJsonRpcResponse(id!, {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'test-server', version: '1.0.0' },
|
||||
}));
|
||||
break;
|
||||
case 'notifications/initialized':
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
break;
|
||||
case 'tools/list':
|
||||
res.writeHead(200);
|
||||
res.end(makeJsonRpcResponse(id!, {
|
||||
tools: [
|
||||
{ name: 'begin_session', description: 'Begin a session', inputSchema: { type: 'object' } },
|
||||
{ name: 'query_grafana', description: 'Query Grafana', inputSchema: { type: 'object', properties: { query: { type: 'string' } } } },
|
||||
],
|
||||
}));
|
||||
break;
|
||||
case 'tools/call':
|
||||
res.writeHead(200);
|
||||
res.end(makeJsonRpcResponse(id!, {
|
||||
content: [{ type: 'text', text: 'tool result' }],
|
||||
}));
|
||||
break;
|
||||
case 'resources/list':
|
||||
res.writeHead(200);
|
||||
res.end(makeJsonRpcResponse(id!, {
|
||||
resources: [
|
||||
{ uri: 'config://main', name: 'Main Config', mimeType: 'application/json' },
|
||||
],
|
||||
}));
|
||||
break;
|
||||
case 'resources/read':
|
||||
res.writeHead(200);
|
||||
res.end(makeJsonRpcResponse(id!, {
|
||||
contents: [{ uri: 'config://main', mimeType: 'application/json', text: '{"key": "value"}' }],
|
||||
}));
|
||||
break;
|
||||
case 'prompts/list':
|
||||
res.writeHead(200);
|
||||
res.end(makeJsonRpcResponse(id!, {
|
||||
prompts: [
|
||||
{ name: 'system-prompt', description: 'System prompt' },
|
||||
],
|
||||
}));
|
||||
break;
|
||||
case 'prompts/get':
|
||||
res.writeHead(200);
|
||||
res.end(makeJsonRpcResponse(id!, {
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }],
|
||||
}));
|
||||
break;
|
||||
case 'error-method':
|
||||
res.writeHead(200);
|
||||
res.end(makeJsonRpcError(id!, -32601, 'Method not found'));
|
||||
break;
|
||||
default:
|
||||
// Raw/unknown method
|
||||
res.writeHead(200);
|
||||
res.end(makeJsonRpcResponse(id ?? null, { echo: method }));
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer.listen(0, '127.0.0.1', () => {
|
||||
const addr = mockServer.address();
|
||||
if (addr && typeof addr === 'object') {
|
||||
mockPort = addr.port;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockServer.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
recorded.length = 0;
|
||||
sessionCounter = 0;
|
||||
});
|
||||
|
||||
function makeSession(token?: string) {
|
||||
return new McpSession(`http://127.0.0.1:${mockPort}/projects/test/mcp`, token);
|
||||
}
|
||||
|
||||
describe('McpSession', () => {
|
||||
describe('initialize', () => {
|
||||
it('sends initialize and notifications/initialized', async () => {
|
||||
const session = makeSession();
|
||||
const result = await session.initialize();
|
||||
|
||||
expect(result.protocolVersion).toBe('2024-11-05');
|
||||
expect(result.serverInfo.name).toBe('test-server');
|
||||
expect(result.capabilities).toHaveProperty('tools');
|
||||
|
||||
// Should have sent 2 requests: initialize + notifications/initialized
|
||||
expect(recorded.length).toBe(2);
|
||||
expect(JSON.parse(recorded[0].body).method).toBe('initialize');
|
||||
expect(JSON.parse(recorded[1].body).method).toBe('notifications/initialized');
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it('captures session ID from response', async () => {
|
||||
const session = makeSession();
|
||||
expect(session.getSessionId()).toBeUndefined();
|
||||
|
||||
await session.initialize();
|
||||
expect(session.getSessionId()).toBeDefined();
|
||||
expect(session.getSessionId()).toMatch(/^session-/);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it('sends correct client info', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
|
||||
const initBody = JSON.parse(recorded[0].body);
|
||||
expect(initBody.params.clientInfo).toEqual({ name: 'mcpctl-console', version: '1.0.0' });
|
||||
expect(initBody.params.protocolVersion).toBe('2024-11-05');
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listTools', () => {
|
||||
it('returns tools array', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
|
||||
const tools = await session.listTools();
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools[0].name).toBe('begin_session');
|
||||
expect(tools[1].name).toBe('query_grafana');
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('callTool', () => {
|
||||
it('sends tool name and arguments', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
|
||||
const result = await session.callTool('query_grafana', { query: 'cpu usage' });
|
||||
expect(result.content).toHaveLength(1);
|
||||
expect(result.content[0].text).toBe('tool result');
|
||||
|
||||
// Find the tools/call request
|
||||
const callReq = recorded.find((r) => {
|
||||
try {
|
||||
return JSON.parse(r.body).method === 'tools/call';
|
||||
} catch { return false; }
|
||||
});
|
||||
expect(callReq).toBeDefined();
|
||||
const callBody = JSON.parse(callReq!.body);
|
||||
expect(callBody.params.name).toBe('query_grafana');
|
||||
expect(callBody.params.arguments).toEqual({ query: 'cpu usage' });
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listResources', () => {
|
||||
it('returns resources array', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
|
||||
const resources = await session.listResources();
|
||||
expect(resources).toHaveLength(1);
|
||||
expect(resources[0].uri).toBe('config://main');
|
||||
expect(resources[0].name).toBe('Main Config');
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readResource', () => {
|
||||
it('sends uri and returns contents', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
|
||||
const result = await session.readResource('config://main');
|
||||
expect(result.contents).toHaveLength(1);
|
||||
expect(result.contents[0].text).toBe('{"key": "value"}');
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listPrompts', () => {
|
||||
it('returns prompts array', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
|
||||
const prompts = await session.listPrompts();
|
||||
expect(prompts).toHaveLength(1);
|
||||
expect(prompts[0].name).toBe('system-prompt');
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrompt', () => {
|
||||
it('sends prompt name and returns result', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
|
||||
const result = await session.getPrompt('system-prompt') as { messages: unknown[] };
|
||||
expect(result.messages).toHaveLength(1);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendRaw', () => {
|
||||
it('sends raw JSON and returns response string', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
|
||||
const raw = JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'custom/echo', params: {} });
|
||||
const result = await session.sendRaw(raw);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.result.echo).toBe('custom/echo');
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('sends DELETE to close session', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
expect(session.getSessionId()).toBeDefined();
|
||||
|
||||
await session.close();
|
||||
|
||||
const deleteReq = recorded.find((r) => r.method === 'DELETE');
|
||||
expect(deleteReq).toBeDefined();
|
||||
expect(deleteReq!.headers['mcp-session-id']).toBeDefined();
|
||||
});
|
||||
|
||||
it('clears session ID after close', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
await session.close();
|
||||
expect(session.getSessionId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('no-ops if no session ID', async () => {
|
||||
const session = makeSession();
|
||||
await session.close(); // Should not throw
|
||||
expect(recorded.filter((r) => r.method === 'DELETE')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging', () => {
|
||||
it('records log entries for requests and responses', async () => {
|
||||
const session = makeSession();
|
||||
const entries: LogEntry[] = [];
|
||||
session.onLog = (entry) => entries.push(entry);
|
||||
|
||||
await session.initialize();
|
||||
|
||||
// initialize request + response + notification request
|
||||
const requestEntries = entries.filter((e) => e.direction === 'request');
|
||||
const responseEntries = entries.filter((e) => e.direction === 'response');
|
||||
|
||||
expect(requestEntries.length).toBeGreaterThanOrEqual(2); // initialize + notification
|
||||
expect(responseEntries.length).toBeGreaterThanOrEqual(1); // initialize response
|
||||
expect(requestEntries[0].method).toBe('initialize');
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it('getLog returns all entries', async () => {
|
||||
const session = makeSession();
|
||||
expect(session.getLog()).toHaveLength(0);
|
||||
|
||||
await session.initialize();
|
||||
expect(session.getLog().length).toBeGreaterThan(0);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it('logs errors on failure', async () => {
|
||||
const session = makeSession();
|
||||
const entries: LogEntry[] = [];
|
||||
session.onLog = (entry) => entries.push(entry);
|
||||
|
||||
await session.initialize();
|
||||
|
||||
try {
|
||||
// Send a method that returns a JSON-RPC error
|
||||
await session.callTool('error-method', {});
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
// Should have an error log entry or a response with error
|
||||
const errorOrResponse = entries.filter((e) => e.direction === 'response' || e.direction === 'error');
|
||||
expect(errorOrResponse.length).toBeGreaterThan(0);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication', () => {
|
||||
it('sends Authorization header when token provided', async () => {
|
||||
const session = makeSession('my-test-token');
|
||||
await session.initialize();
|
||||
|
||||
expect(recorded[0].headers['authorization']).toBe('Bearer my-test-token');
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it('does not send Authorization header without token', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
|
||||
expect(recorded[0].headers['authorization']).toBeUndefined();
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON-RPC errors', () => {
|
||||
it('throws on JSON-RPC error response', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
|
||||
// The mock server returns an error for method 'error-method'
|
||||
// We need to send a raw request that triggers it
|
||||
// callTool sends method 'tools/call', so use sendRaw for direct control
|
||||
const raw = JSON.stringify({ jsonrpc: '2.0', id: 50, method: 'error-method', params: {} });
|
||||
// sendRaw doesn't parse errors — it returns raw text. Use the private send indirectly.
|
||||
// Actually, callTool only sends tools/call. Let's verify the error path differently.
|
||||
// The mock routes tools/call to a success response, so we test via session internals.
|
||||
|
||||
// Instead, test that sendRaw returns the error response as-is
|
||||
const result = await session.sendRaw(raw);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.error).toBeDefined();
|
||||
expect(parsed.error.code).toBe(-32601);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('request ID incrementing', () => {
|
||||
it('increments request IDs for each call', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
await session.listTools();
|
||||
await session.listResources();
|
||||
|
||||
const ids = recorded
|
||||
.filter((r) => r.method === 'POST')
|
||||
.map((r) => {
|
||||
try { return JSON.parse(r.body).id; } catch { return undefined; }
|
||||
})
|
||||
.filter((id) => id !== undefined);
|
||||
|
||||
// Should have unique, ascending IDs (1, 2, 3)
|
||||
const numericIds = ids.filter((id): id is number => typeof id === 'number');
|
||||
expect(numericIds.length).toBeGreaterThanOrEqual(3);
|
||||
for (let i = 1; i < numericIds.length; i++) {
|
||||
expect(numericIds[i]).toBeGreaterThan(numericIds[i - 1]);
|
||||
}
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session ID propagation', () => {
|
||||
it('sends session ID in subsequent requests', async () => {
|
||||
const session = makeSession();
|
||||
await session.initialize();
|
||||
|
||||
// First request should not have session ID
|
||||
expect(recorded[0].headers['mcp-session-id']).toBeUndefined();
|
||||
|
||||
// After initialize, session ID is set — subsequent requests should include it
|
||||
await session.listTools();
|
||||
|
||||
const toolsReq = recorded.find((r) => {
|
||||
try { return JSON.parse(r.body).method === 'tools/list'; } catch { return false; }
|
||||
});
|
||||
expect(toolsReq).toBeDefined();
|
||||
expect(toolsReq!.headers['mcp-session-id']).toBeDefined();
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,11 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
"types": ["node"],
|
||||
"jsx": "react-jsx",
|
||||
"exactOptionalPropertyTypes": false
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"references": [
|
||||
{ "path": "../shared" },
|
||||
{ "path": "../db" }
|
||||
|
||||
Reference in New Issue
Block a user