Skip to content

Commit dfe0256

Browse files
committed
feat: add mcp add command for mcp servers
1 parent 25ad427 commit dfe0256

4 files changed

Lines changed: 171 additions & 55 deletions

File tree

src/commands/mcp.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,86 @@ import { Command } from "commander";
33
import { render } from "ink";
44
import { MCPList } from "../components/mcp/mcp-list.js";
55
import { MCPTest } from "../components/mcp/mcp-test.js";
6+
import { getConfigManager, getDefaultConfigPath } from "../config/manager.js";
7+
import type { MCPServerConfig, TransportType } from "../mcp/types.js";
8+
import { TRANSPORT_TYPES } from "../mcp/constants.js";
9+
import { parseKeyValue } from "../mcp/utils.js";
10+
import { loadMCPConfig } from "../mcp/config.js";
11+
import chalk from "chalk";
612

713
export function createMCPCommand(): Command {
814
const mcp = new Command("mcp");
915
mcp.description("Manage MCP (Model Context Protocol) servers");
1016

17+
mcp
18+
.command("add")
19+
.description("Add a new MCP server")
20+
.argument("<name>", "Server name")
21+
.option(
22+
"-t, --transport <type>",
23+
"Transport type: stdio | sse | streamable-http",
24+
"stdio",
25+
)
26+
.option(
27+
"-C, --command <command>",
28+
"Command to run the server (for stdio transport)",
29+
)
30+
.option(
31+
"-a, --args [args...]",
32+
"Arguments for the server command (for stdio transport)",
33+
[],
34+
)
35+
.option("-u, --url <url>", "URL for SSE/HTTP transport")
36+
.option("-e, --env [envs...]", "Environment variables (KEY=VALUE)")
37+
.option("-H, --header [headers...]", "HTTP headers (KEY=VALUE)")
38+
.option("--timeout <ms>", "Operation timeout in ms", (v) => parseInt(v, 10))
39+
.option("--enabled", "Add server as enabled", true)
40+
.action(async function (this: Command, name: string, opts: any) {
41+
const { servers } = loadMCPConfig();
42+
const configManager = getConfigManager();
43+
44+
let config: MCPServerConfig;
45+
switch (opts.transport as TransportType) {
46+
case TRANSPORT_TYPES.STDIO:
47+
config = {
48+
type: "stdio",
49+
command: opts.command,
50+
args: opts.args,
51+
env: parseKeyValue(opts.env),
52+
timeout: opts.timeout,
53+
enabled: opts.enabled,
54+
};
55+
break;
56+
case TRANSPORT_TYPES.SSE:
57+
config = {
58+
type: "sse",
59+
url: opts.url,
60+
headers: parseKeyValue(opts.header),
61+
timeout: opts.timeout,
62+
enabled: opts.enabled,
63+
};
64+
break;
65+
case TRANSPORT_TYPES.STREAMABLE_HTTP:
66+
config = {
67+
type: "streamable-http",
68+
url: opts.url,
69+
headers: parseKeyValue(opts.header),
70+
timeout: opts.timeout,
71+
enabled: opts.enabled,
72+
};
73+
break;
74+
default:
75+
throw new Error(`Unknown transport type: ${opts.transport}`);
76+
}
77+
78+
const existing = (servers as Record<string, any>) || {};
79+
const updated = { ...existing, [name]: config };
80+
configManager.saveProjectSettings({ mcpServers: updated });
81+
82+
const path = getDefaultConfigPath();
83+
console.log(chalk.green(`✓ Added MCP server: ${name} to ${path}`));
84+
});
85+
1186
mcp
1287
.command("list")
1388
.description("List configured MCP servers (checks health)")

src/components/ink-table.tsx

Lines changed: 74 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// via https://github.com/maticzav/ink-table
2-
// inlined here because of https://github.com/maticzav/ink-table/issues/258
2+
// patched: max column width 20 + auto-wrap long values
33

44
import React from "react";
55
import { Box, Text } from "ink";
@@ -40,6 +40,7 @@ export type TableProps<T extends ScalarDict> = {
4040
* Component used to render the skeleton of the table.
4141
*/
4242
skeleton: (props: React.PropsWithChildren<unknown>) => React.JSX.Element;
43+
maxCellWidth: number;
4344
};
4445

4546
/* Table */
@@ -60,6 +61,7 @@ export default class Table<T extends ScalarDict> extends React.Component<
6061
header: this.props.header || Header,
6162
cell: this.props.cell || Cell,
6263
skeleton: this.props.skeleton || Skeleton,
64+
maxCellWidth: this.props.maxCellWidth || 40,
6365
};
6466
}
6567

@@ -86,19 +88,21 @@ export default class Table<T extends ScalarDict> extends React.Component<
8688
* Returns a list of column names and their widths.
8789
*/
8890
getColumns(): Column<T>[] {
89-
const { columns, padding } = this.getConfig();
91+
const { columns, padding, maxCellWidth } = this.getConfig();
9092

9193
const widths: Column<T>[] = columns.map((key) => {
9294
const header = String(key).length;
9395
/* Get the width of each cell in the column */
9496
const data = this.props.data.map((data) => {
9597
const value = data[key];
96-
9798
if (value == undefined || value == null) return 0;
9899
return String(value).length;
99100
});
100101

101-
const width = Math.max(...data, header) + padding * 2;
102+
let width = Math.max(...data, header) + padding * 2;
103+
104+
// enforce max width
105+
width = Math.min(width, maxCellWidth);
102106

103107
/* Construct a cell */
104108
return {
@@ -272,65 +276,81 @@ type Column<T> = {
272276
width: number;
273277
};
274278

279+
/**
280+
* Word-wrap utility: splits text into chunks of max length
281+
*/
282+
function wrapText(str: string, max: number): string[] {
283+
const result: string[] = [];
284+
let i = 0;
285+
while (i < str.length) {
286+
result.push(str.slice(i, i + max));
287+
i += max;
288+
}
289+
return result.length ? result : [""];
290+
}
291+
275292
/**
276293
* Constructs a Row element from the configuration.
277294
*/
278295
function row<T extends ScalarDict>(
279296
config: RowConfig,
280297
): (props: RowProps<T>) => React.JSX.Element {
281298
/* This is a component builder. We return a function. */
282-
283299
const skeleton = config.skeleton;
284300

285301
/* Row */
286-
return (props) => (
287-
<Box flexDirection="row">
288-
{/* Left */}
289-
<skeleton.component>{skeleton.left}</skeleton.component>
290-
{/* Data */}
291-
{...intersperse(
292-
(i) => {
293-
const key = `${props.key}-hseparator-${i}`;
294-
295-
// The horizontal separator.
296-
return (
297-
<skeleton.component key={key}>{skeleton.cross}</skeleton.component>
298-
);
299-
},
300-
301-
// Values.
302-
props.columns.map((column, colI) => {
303-
// content
304-
const value = props.data[column.column];
305-
306-
if (value == undefined || value == null) {
307-
const key = `${props.key}-empty-${column.key}`;
308-
309-
return (
310-
<config.cell key={key} column={colI}>
311-
{skeleton.line.repeat(column.width)}
312-
</config.cell>
313-
);
314-
} else {
315-
const key = `${props.key}-cell-${column.key}`;
316-
317-
// margins
318-
const ml = config.padding;
319-
const mr = column.width - String(value).length - config.padding;
320-
321-
return (
322-
/* prettier-ignore */
323-
<config.cell key={key} column={colI}>
324-
{`${skeleton.line.repeat(ml)}${String(value)}${skeleton.line.repeat(mr)}`}
325-
</config.cell>
326-
);
327-
}
328-
}),
329-
)}
330-
{/* Right */}
331-
<skeleton.component>{skeleton.right}</skeleton.component>
332-
</Box>
333-
);
302+
return (props) => {
303+
// compute wrapped values for each column
304+
const wrappedColumns = props.columns.map((column) => {
305+
const rawValue = props.data[column.column];
306+
const contentMax = column.width - config.padding * 2;
307+
const displayValue = rawValue == null ? "" : String(rawValue);
308+
return wrapText(displayValue, Math.max(contentMax, 1));
309+
});
310+
311+
// figure out max number of lines for this row
312+
const maxLines = Math.max(...wrappedColumns.map((c) => c.length), 1);
313+
314+
return (
315+
<Box flexDirection="column">
316+
{Array.from({ length: maxLines }).map((_, lineIndex) => (
317+
<Box key={`${props.key}-line-${lineIndex}`} flexDirection="row">
318+
{/* Left */}
319+
<skeleton.component>{skeleton.left}</skeleton.component>
320+
{/* Data */}
321+
{intersperse(
322+
(i) => (
323+
<skeleton.component
324+
key={`${props.key}-hseparator-${i}-line-${lineIndex}`}
325+
>
326+
{skeleton.cross}
327+
</skeleton.component>
328+
),
329+
props.columns.map((column, colI) => {
330+
const lines = wrappedColumns[colI] || [];
331+
const content = lines[lineIndex] ?? ""; // empty if no line
332+
const ml = config.padding;
333+
const mr = column.width - content.length - config.padding;
334+
335+
return (
336+
<config.cell
337+
key={`${props.key}-cell-${column.key}-${lineIndex}`}
338+
column={colI}
339+
>
340+
{`${skeleton.line.repeat(ml)}${content}${skeleton.line.repeat(
341+
Math.max(mr, 0),
342+
)}`}
343+
</config.cell>
344+
);
345+
}),
346+
)}
347+
{/* Right */}
348+
<skeleton.component>{skeleton.right}</skeleton.component>
349+
</Box>
350+
))}
351+
</Box>
352+
);
353+
};
334354
}
335355

336356
/**
@@ -352,7 +372,7 @@ export function Cell(props: CellProps) {
352372
}
353373

354374
/**
355-
* Redners the scaffold of the table.
375+
* Renders the scaffold of the table.
356376
*/
357377
export function Skeleton(props: React.PropsWithChildren<unknown>) {
358378
return <Text bold>{props.children}</Text>;

src/components/mcp/mcp-list.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function MCPList({ verbose = false }: { verbose?: boolean }) {
2424

2525
return (
2626
<Box flexDirection="column">
27-
<Table data={rows} />
27+
<Table data={rows} maxCellWidth={45} />
2828
<MCPSummary summary={summary} verbose={verbose} />
2929
</Box>
3030
);

src/mcp/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,24 @@ export function mergeEnvironment(
5353
...configEnvironment,
5454
};
5555
}
56+
57+
export function parseKeyValue(entries?: string[]): Record<string, string> {
58+
if (!entries || entries.length === 0) return {};
59+
60+
return entries.reduce<Record<string, string>>((acc, entry) => {
61+
const idx = entry.indexOf("=");
62+
if (idx === -1) {
63+
throw new Error(`Invalid option '${entry}', must be in KEY=VALUE format`);
64+
}
65+
66+
const key = entry.slice(0, idx).trim();
67+
const value = entry.slice(idx + 1).trim();
68+
69+
if (!key) {
70+
throw new Error(`Invalid option '${entry}', missing KEY before '='`);
71+
}
72+
73+
acc[key] = value;
74+
return acc;
75+
}, {});
76+
}

0 commit comments

Comments
 (0)