mini-coder 0.5.12 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp.ts ADDED
@@ -0,0 +1,609 @@
1
+ /**
2
+ * Model Context Protocol client discovery and tool integration.
3
+ *
4
+ * mini-coder tracks configured Streamable HTTP MCP servers, connects enabled
5
+ * ones when needed, imports their tools, and exposes those tools through the
6
+ * normal pi-ai tool interface.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { Static, Tool, ToolCall, TSchema } from "@mariozechner/pi-ai";
12
+ import { Type, validateToolArguments } from "@mariozechner/pi-ai";
13
+ import { Client } from "@modelcontextprotocol/sdk/client";
14
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
15
+ import type { ToolHandler, ToolUpdateCallback } from "./agent.ts";
16
+ import { getErrorMessage } from "./errors.ts";
17
+ import type { McpSettings } from "./settings.ts";
18
+ import { type ToolExecResult, textResult } from "./tool-common.ts";
19
+
20
+ const MCP_DISCOVERY_TIMEOUT_MS = 3_000;
21
+
22
+ interface McpListedTool {
23
+ /** JSON Schema describing the tool input object. */
24
+ inputSchema: {
25
+ properties?: Record<string, object>;
26
+ required?: string[];
27
+ type: "object";
28
+ [key: string]: unknown;
29
+ };
30
+ /** Human-readable tool description when provided by the server. */
31
+ description?: string;
32
+ /** Server-defined MCP tool name. */
33
+ name: string;
34
+ }
35
+
36
+ type McpCallToolResult =
37
+ | {
38
+ /** Tool content blocks returned by the MCP server. */
39
+ content: McpToolResultContent[];
40
+ /** Optional structured data returned alongside content. */
41
+ structuredContent?: unknown;
42
+ /** Whether the server marked this result as an error. */
43
+ isError?: boolean;
44
+ }
45
+ | {
46
+ /** Compatibility payload from older MCP tool result formats. */
47
+ toolResult: unknown;
48
+ };
49
+
50
+ type McpToolResultContent =
51
+ | {
52
+ type: "text";
53
+ text: string;
54
+ }
55
+ | {
56
+ type: "image";
57
+ data: string;
58
+ mimeType: string;
59
+ }
60
+ | {
61
+ type: "audio";
62
+ data: string;
63
+ mimeType: string;
64
+ }
65
+ | {
66
+ type: "resource";
67
+ resource:
68
+ | {
69
+ uri: string;
70
+ text: string;
71
+ mimeType?: string;
72
+ }
73
+ | {
74
+ uri: string;
75
+ blob: string;
76
+ mimeType?: string;
77
+ };
78
+ }
79
+ | {
80
+ type: "resource_link";
81
+ uri: string;
82
+ name: string;
83
+ description?: string;
84
+ mimeType?: string;
85
+ size?: number;
86
+ title?: string;
87
+ };
88
+
89
+ type McpProgress = {
90
+ /** Current completed work units reported by the server. */
91
+ progress: number;
92
+ /** Total work units when the server reports them. */
93
+ total?: number;
94
+ /** Optional human-readable progress message. */
95
+ message?: string;
96
+ };
97
+
98
+ interface McpTransportLike {
99
+ close(): Promise<void>;
100
+ }
101
+
102
+ interface McpClientLike {
103
+ connect(
104
+ transport: McpTransportLike,
105
+ options?: { timeout?: number },
106
+ ): Promise<void>;
107
+ listTools(
108
+ params?: { cursor?: string },
109
+ options?: { timeout?: number },
110
+ ): Promise<{
111
+ tools: McpListedTool[];
112
+ nextCursor?: string;
113
+ }>;
114
+ callTool(
115
+ params: {
116
+ name: string;
117
+ arguments?: Record<string, unknown>;
118
+ },
119
+ resultSchema?: undefined,
120
+ options?: {
121
+ signal?: AbortSignal;
122
+ onprogress?: (progress: McpProgress) => void;
123
+ },
124
+ ): Promise<McpCallToolResult>;
125
+ }
126
+
127
+ interface McpRuntime {
128
+ createClient(): McpClientLike;
129
+ createTransport(url: URL): McpTransportLike;
130
+ }
131
+
132
+ const defaultMcpRuntime: McpRuntime = {
133
+ createClient() {
134
+ return new Client(
135
+ {
136
+ name: "mini-coder",
137
+ version: "0.0.0",
138
+ },
139
+ { capabilities: {} },
140
+ );
141
+ },
142
+ createTransport(url) {
143
+ return new StreamableHTTPClientTransport(url);
144
+ },
145
+ };
146
+
147
+ async function noopClose(): Promise<void> {}
148
+
149
+ /** A configured MCP server and its current runtime connection state. */
150
+ export interface McpServerState {
151
+ /** User-configured server identifier from `settings.json`. */
152
+ name: string;
153
+ /** Absolute Streamable HTTP endpoint URL. */
154
+ url: string;
155
+ /** Whether this server should be available to future turns. */
156
+ enabled: boolean;
157
+ /** Whether mini-coder currently has an active connection to this server. */
158
+ connected: boolean;
159
+ /** pi-ai tool definitions derived from the server's tool list. */
160
+ tools: Tool[];
161
+ /** Tool name → handler map for calling the remote MCP tools. */
162
+ toolHandlers: Map<string, ToolHandler>;
163
+ /** Close the underlying MCP transport when connected. */
164
+ close(): Promise<void>;
165
+ }
166
+
167
+ interface McpConnectionState {
168
+ /** Non-fatal warnings encountered while importing tools. */
169
+ warnings: string[];
170
+ /** pi-ai tool definitions derived from the server's tool list. */
171
+ tools: Tool[];
172
+ /** Tool name → handler map for calling the remote MCP tools. */
173
+ toolHandlers: Map<string, ToolHandler>;
174
+ /** Close the underlying MCP transport. */
175
+ close(): Promise<void>;
176
+ }
177
+
178
+ interface McpDiscoveryResult {
179
+ /** MCP servers kept in runtime state after startup discovery. */
180
+ servers: McpServerState[];
181
+ /** Non-fatal startup warnings for skipped or unreachable servers. */
182
+ warnings: string[];
183
+ }
184
+
185
+ function createDisconnectedMcpServer(
186
+ entry: NonNullable<McpSettings["servers"]>[number],
187
+ ): McpServerState {
188
+ return {
189
+ name: entry.name,
190
+ url: entry.url,
191
+ enabled: entry.enabled,
192
+ connected: false,
193
+ tools: [],
194
+ toolHandlers: new Map(),
195
+ close: noopClose,
196
+ };
197
+ }
198
+
199
+ function resetMcpServerConnection(server: McpServerState): void {
200
+ server.connected = false;
201
+ server.tools = [];
202
+ server.toolHandlers = new Map();
203
+ server.close = noopClose;
204
+ }
205
+
206
+ function parseMcpServerUrl(rawUrl: string): URL | null {
207
+ try {
208
+ return new URL(rawUrl);
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Connect a configured MCP server and populate its imported tools.
216
+ *
217
+ * On failure the server is left disconnected and the returned warnings describe
218
+ * why the connection was skipped.
219
+ *
220
+ * @param server - Runtime MCP server state to connect.
221
+ * @param runtime - Internal runtime injection for tests.
222
+ * @returns Non-fatal warnings produced while connecting or importing tools.
223
+ */
224
+ export async function connectMcpServer(
225
+ server: McpServerState,
226
+ runtime: McpRuntime = defaultMcpRuntime,
227
+ ): Promise<string[]> {
228
+ if (server.connected) {
229
+ return [];
230
+ }
231
+
232
+ const connection = await createMcpServerConnection(
233
+ server.name,
234
+ server.url,
235
+ runtime,
236
+ );
237
+
238
+ if (!("tools" in connection)) {
239
+ resetMcpServerConnection(server);
240
+ return [connection.warning];
241
+ }
242
+
243
+ server.connected = true;
244
+ server.tools = connection.tools;
245
+ server.toolHandlers = connection.toolHandlers;
246
+ server.close = connection.close;
247
+ return connection.warnings;
248
+ }
249
+
250
+ /**
251
+ * Disconnect an MCP server and clear its imported tool state.
252
+ *
253
+ * @param server - Runtime MCP server state to disconnect.
254
+ */
255
+ export async function disconnectMcpServer(
256
+ server: McpServerState,
257
+ ): Promise<void> {
258
+ if (!server.connected) {
259
+ return;
260
+ }
261
+
262
+ const close = server.close;
263
+ resetMcpServerConnection(server);
264
+ await close();
265
+ }
266
+
267
+ /**
268
+ * Connect enabled MCP servers at startup while keeping disabled ones in state.
269
+ *
270
+ * Invalid or unreachable servers do not fail startup. Enabled servers that
271
+ * cannot connect are skipped with warnings. Disabled servers stay disconnected
272
+ * until the user turns them back on.
273
+ *
274
+ * @param settings - Optional MCP settings from `settings.json`.
275
+ * @param runtime - Internal runtime injection for tests.
276
+ * @returns MCP server state plus any non-fatal startup warnings.
277
+ */
278
+ export async function discoverMcpServers(
279
+ settings: McpSettings | undefined,
280
+ runtime: McpRuntime = defaultMcpRuntime,
281
+ ): Promise<McpDiscoveryResult> {
282
+ const servers: McpServerState[] = [];
283
+ const warnings: string[] = [];
284
+
285
+ for (const entry of settings?.servers ?? []) {
286
+ if (!parseMcpServerUrl(entry.url)) {
287
+ warnings.push(`MCP server "${entry.name}": invalid URL (${entry.url})`);
288
+ continue;
289
+ }
290
+
291
+ const server = createDisconnectedMcpServer(entry);
292
+ if (!entry.enabled) {
293
+ servers.push(server);
294
+ continue;
295
+ }
296
+
297
+ const connectWarnings = await connectMcpServer(server, runtime);
298
+ warnings.push(...connectWarnings);
299
+ if (!server.connected) {
300
+ continue;
301
+ }
302
+
303
+ servers.push(server);
304
+ }
305
+
306
+ return { servers, warnings };
307
+ }
308
+
309
+ async function createMcpServerConnection(
310
+ serverName: string,
311
+ serverUrl: string,
312
+ runtime: McpRuntime,
313
+ ): Promise<McpConnectionState | { warning: string }> {
314
+ const url = parseMcpServerUrl(serverUrl);
315
+ if (!url) {
316
+ return {
317
+ warning: `MCP server "${serverName}": invalid URL (${serverUrl})`,
318
+ };
319
+ }
320
+
321
+ const transport = runtime.createTransport(url);
322
+ const client = runtime.createClient();
323
+
324
+ try {
325
+ await client.connect(transport, { timeout: MCP_DISCOVERY_TIMEOUT_MS });
326
+ const listedTools = await listAllTools(client);
327
+
328
+ if (listedTools.length === 0) {
329
+ await closeQuietly(transport);
330
+ return {
331
+ warning: `MCP server "${serverName}" exposes no tools and was skipped.`,
332
+ };
333
+ }
334
+
335
+ const warnings: string[] = [];
336
+ const tools: Tool[] = [];
337
+ const toolHandlers = new Map<string, ToolHandler>();
338
+
339
+ for (const listedTool of listedTools) {
340
+ const { tool, handler } = createMcpTool(serverName, listedTool, client);
341
+ if (toolHandlers.has(tool.name)) {
342
+ warnings.push(
343
+ `MCP server "${serverName}": duplicate tool name "${listedTool.name}" was skipped.`,
344
+ );
345
+ continue;
346
+ }
347
+ tools.push(tool);
348
+ toolHandlers.set(tool.name, handler);
349
+ }
350
+
351
+ if (tools.length === 0) {
352
+ await closeQuietly(transport);
353
+ return {
354
+ warning: `MCP server "${serverName}" exposes no usable tools and was skipped.`,
355
+ };
356
+ }
357
+
358
+ return {
359
+ warnings,
360
+ tools,
361
+ toolHandlers,
362
+ close: () => transport.close(),
363
+ };
364
+ } catch (error) {
365
+ await closeQuietly(transport);
366
+ return {
367
+ warning: `MCP server "${serverName}": ${getErrorMessage(error)} (${serverUrl})`,
368
+ };
369
+ }
370
+ }
371
+
372
+ async function listAllTools(client: McpClientLike): Promise<McpListedTool[]> {
373
+ const tools: McpListedTool[] = [];
374
+ let cursor: string | undefined;
375
+
376
+ do {
377
+ const page = await client.listTools(cursor ? { cursor } : undefined, {
378
+ timeout: MCP_DISCOVERY_TIMEOUT_MS,
379
+ });
380
+ tools.push(...page.tools);
381
+ cursor = page.nextCursor;
382
+ } while (cursor);
383
+
384
+ return tools;
385
+ }
386
+
387
+ function createMcpTool(
388
+ serverName: string,
389
+ listedTool: McpListedTool,
390
+ client: McpClientLike,
391
+ ): {
392
+ tool: Tool;
393
+ handler: ToolHandler;
394
+ } {
395
+ const toolName = `${serverName}__${listedTool.name}`;
396
+ const tool: Tool = {
397
+ name: toolName,
398
+ description: listedTool.description
399
+ ? `[MCP ${serverName}] ${listedTool.description}`
400
+ : `[MCP ${serverName}] ${listedTool.name}`,
401
+ parameters: Type.Unsafe<Record<string, unknown>>(listedTool.inputSchema),
402
+ };
403
+
404
+ const handler: ToolHandler = async (args, _cwd, signal, onUpdate) => {
405
+ const validatedArgs = validateMcpToolArgs(tool, args) as Record<
406
+ string,
407
+ unknown
408
+ >;
409
+
410
+ try {
411
+ const result = await client.callTool(
412
+ {
413
+ name: listedTool.name,
414
+ arguments: validatedArgs,
415
+ },
416
+ undefined,
417
+ {
418
+ ...(signal ? { signal } : {}),
419
+ ...(onUpdate
420
+ ? {
421
+ onprogress: (progress: McpProgress) => {
422
+ emitMcpProgressUpdate(
423
+ serverName,
424
+ listedTool.name,
425
+ progress,
426
+ onUpdate,
427
+ );
428
+ },
429
+ }
430
+ : {}),
431
+ },
432
+ );
433
+
434
+ return convertMcpToolResult(result);
435
+ } catch (error) {
436
+ return textResult(
437
+ `MCP server "${serverName}" tool "${listedTool.name}" failed: ${getErrorMessage(error)}`,
438
+ true,
439
+ );
440
+ }
441
+ };
442
+
443
+ return { tool, handler };
444
+ }
445
+
446
+ function emitMcpProgressUpdate(
447
+ serverName: string,
448
+ toolName: string,
449
+ progress: McpProgress,
450
+ onUpdate: ToolUpdateCallback,
451
+ ): void {
452
+ const status =
453
+ progress.total != null
454
+ ? `${progress.progress}/${progress.total}`
455
+ : `${progress.progress}`;
456
+ const suffix = progress.message ? ` — ${progress.message}` : "";
457
+ onUpdate(
458
+ textResult(
459
+ `MCP ${serverName}/${toolName} progress: ${status}${suffix}`,
460
+ false,
461
+ ),
462
+ );
463
+ }
464
+
465
+ function convertMcpToolResult(result: McpCallToolResult): ToolExecResult {
466
+ if ("toolResult" in result) {
467
+ return textResult(formatUnknownValue(result.toolResult), false);
468
+ }
469
+
470
+ const content: ToolExecResult["content"] = [];
471
+
472
+ for (const item of result.content) {
473
+ switch (item.type) {
474
+ case "text":
475
+ content.push({ type: "text", text: item.text });
476
+ break;
477
+ case "image":
478
+ content.push({
479
+ type: "image",
480
+ data: item.data,
481
+ mimeType: item.mimeType,
482
+ });
483
+ break;
484
+ case "audio":
485
+ content.push({
486
+ type: "text",
487
+ text: `Audio output (${item.mimeType}) is not renderable in mini-coder tool results.`,
488
+ });
489
+ break;
490
+ case "resource":
491
+ content.push({
492
+ type: "text",
493
+ text: formatResourceContent(item.resource),
494
+ });
495
+ break;
496
+ case "resource_link":
497
+ content.push({
498
+ type: "text",
499
+ text: formatResourceLinkContent(item),
500
+ });
501
+ break;
502
+ }
503
+ }
504
+
505
+ if (content.length === 0) {
506
+ if (result.structuredContent !== undefined) {
507
+ content.push({
508
+ type: "text",
509
+ text: formatUnknownValue(result.structuredContent),
510
+ });
511
+ } else {
512
+ content.push({
513
+ type: "text",
514
+ text: "MCP tool returned no content.",
515
+ });
516
+ }
517
+ }
518
+
519
+ return {
520
+ content,
521
+ details:
522
+ result.structuredContent === undefined
523
+ ? undefined
524
+ : { structuredContent: result.structuredContent },
525
+ isError: result.isError ?? false,
526
+ };
527
+ }
528
+
529
+ function formatResourceContent(
530
+ resource:
531
+ | {
532
+ uri: string;
533
+ text: string;
534
+ mimeType?: string;
535
+ }
536
+ | {
537
+ uri: string;
538
+ blob: string;
539
+ mimeType?: string;
540
+ },
541
+ ): string {
542
+ if ("text" in resource) {
543
+ return resource.mimeType
544
+ ? `Resource ${resource.uri} (${resource.mimeType})\n\n${resource.text}`
545
+ : `Resource ${resource.uri}\n\n${resource.text}`;
546
+ }
547
+
548
+ return resource.mimeType
549
+ ? `Resource blob ${resource.uri} (${resource.mimeType})`
550
+ : `Resource blob ${resource.uri}`;
551
+ }
552
+
553
+ function formatResourceLinkContent(item: {
554
+ uri: string;
555
+ name: string;
556
+ description?: string;
557
+ mimeType?: string;
558
+ size?: number;
559
+ title?: string;
560
+ }): string {
561
+ const lines = [
562
+ item.title ? `Resource link: ${item.title}` : `Resource link: ${item.name}`,
563
+ `URI: ${item.uri}`,
564
+ ];
565
+
566
+ if (item.description) {
567
+ lines.push(`Description: ${item.description}`);
568
+ }
569
+ if (item.mimeType) {
570
+ lines.push(`MIME type: ${item.mimeType}`);
571
+ }
572
+ if (item.size != null) {
573
+ lines.push(`Size: ${item.size}`);
574
+ }
575
+
576
+ return lines.join("\n");
577
+ }
578
+
579
+ function formatUnknownValue(value: unknown): string {
580
+ if (typeof value === "string") {
581
+ return value;
582
+ }
583
+
584
+ try {
585
+ return JSON.stringify(value, null, 2);
586
+ } catch {
587
+ return String(value);
588
+ }
589
+ }
590
+
591
+ function validateMcpToolArgs<TParameters extends TSchema>(
592
+ tool: Tool<TParameters>,
593
+ args: Record<string, unknown>,
594
+ ): Static<TParameters> {
595
+ return validateToolArguments(tool, {
596
+ type: "toolCall",
597
+ id: tool.name,
598
+ name: tool.name,
599
+ arguments: args,
600
+ } satisfies ToolCall) as Static<TParameters>;
601
+ }
602
+
603
+ async function closeQuietly(transport: McpTransportLike): Promise<void> {
604
+ try {
605
+ await transport.close();
606
+ } catch {
607
+ // Best effort only.
608
+ }
609
+ }
package/src/prompt.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * System prompt construction.
3
3
  *
4
4
  * Assembles the full system prompt from the core prompt template plus
5
- * dynamic context: AGENTS.md files, skill catalog, plugin suffixes,
6
- * and the current environment block.
5
+ * dynamic context: AGENTS.md files, the skill catalog, and the current
6
+ * environment block.
7
7
  *
8
8
  * @module
9
9
  */
@@ -44,8 +44,6 @@ interface BuildSystemPromptOpts {
44
44
  agentsMd?: AgentsMdFile[];
45
45
  /** Discovered agent skills. */
46
46
  skills?: Skill[];
47
- /** Plugin system prompt suffixes. */
48
- pluginSuffixes?: string[];
49
47
  }
50
48
 
51
49
  // ---------------------------------------------------------------------------
@@ -253,7 +251,13 @@ function buildCorePrompt(opts: BuildSystemPromptOpts): string {
253
251
  "- Avoid creating extra files, systems or documentation outside of what was asked.",
254
252
  "- Check requirements, and plan your changes before editing code.",
255
253
  "- Implement the necessary changes, following good practices and proper error handling.",
254
+ "- Prefer the smallest path that leaves the requested end state already true; do not stop at helper scripts, instructions, or half-finished setup when the user asked for the live result itself.",
256
255
  "- Always verify your changes using compilation, testing, and manual verification when possible.",
256
+ "- Before you finish, re-check the explicit deliverables and current state. If the user named files, paths, ports, services, commands, or output values, make sure they already exist and work now.",
257
+ "- If the request includes structural constraints on files or outputs (for example allowed commands, required lines, exact formats, or counts), treat those as acceptance criteria too and verify them directly against what you produced, not just through downstream behavior.",
258
+ "- Treat concrete command sequences and expected outputs in the user's request as acceptance criteria for the end state. If you verify that flow during the task, do not roll the environment back afterward unless the user explicitly asked for a reset.",
259
+ "- If a check or tool result contradicts your expectation, trust the evidence and resolve the mismatch before you answer.",
260
+ "- When multiple outputs or end states seem plausible, do not guess or swap in a cleaner alternative after verification. Run the smallest check that distinguishes them, and if you change the state later, verify again.",
257
261
  "- When verifying with build or test commands, avoid leaving generated binaries or scratch artifacts in the requested output location; use temporary paths or remove them before finishing.",
258
262
  "- Do not leave helpers, tests, or any other form of temporary files; clean up after yourself and leave no trace.",
259
263
  "- Ensure you match the requested output exactly. This applies to file names, directory structure, number of files, output formats, and all other details.",
@@ -268,7 +272,9 @@ function buildCorePrompt(opts: BuildSystemPromptOpts): string {
268
272
  "- A todo item is only complete if the requested work is actually finished and verified to the degree the task requires.",
269
273
  "- Use `cancelled` to remove tasks that are no longer relevant.",
270
274
  "- Skip todo tools for single trivial tasks and purely conversational/informational requests.",
271
- '- You have the option to delegate tasks to copies of yourself with `mc -p "subtask prompt"` in the shell.',
275
+ "- Use the `delegate` tool for bounded subtasks when another focused agent pass would help.",
276
+ "- Prefer `delegate` over shelling out to `mc -p` unless you specifically need to exercise the CLI itself.",
277
+ "- Do not re-delegate the whole task, spin on repeated self-review prompts, or ask a delegated child to delegate again.",
272
278
  "- Delegate when you are orchestrating a large to-do/plan execution.",
273
279
  "",
274
280
  );
@@ -287,7 +293,6 @@ function buildCorePrompt(opts: BuildSystemPromptOpts): string {
287
293
  * 1. Core prompt template (including the current environment block)
288
294
  * 2. AGENTS.md content (project-specific)
289
295
  * 3. Skills catalog (XML)
290
- * 4. Plugin suffixes
291
296
  *
292
297
  * @param opts - Prompt construction options.
293
298
  * @returns The assembled system prompt string.
@@ -313,12 +318,5 @@ export function buildSystemPrompt(opts: BuildSystemPromptOpts): string {
313
318
  if (catalog) sections.push(catalog);
314
319
  }
315
320
 
316
- // 4. Plugin suffixes
317
- if (opts.pluginSuffixes) {
318
- for (const suffix of opts.pluginSuffixes) {
319
- sections.push(suffix);
320
- }
321
- }
322
-
323
321
  return sections.join("\n\n");
324
322
  }