subforge-mcp 1.0.0 → 1.1.1

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.
Files changed (3) hide show
  1. package/README.md +46 -7
  2. package/dist/index.js +350 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,16 +38,55 @@ claude mcp add subforge -- npx -y subforge-mcp
38
38
 
39
39
  ## MCP Tools
40
40
 
41
- | Tool | Description |
42
- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
43
- | `list_models` | List models currently loaded into memory in LM Studio |
44
- | `chat` | Send a message to a loaded model; keeps per-`session_id` conversation history |
45
- | `load_model` | Load a downloaded-but-unloaded model into memory — requires client elicitation support and explicit user consent |
46
- | `reset_chat` | Clear conversation history for a session (or all sessions) |
41
+ | Tool | Description |
42
+ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
+ | `list_models` | List models currently loaded into memory in LM Studio |
44
+ | `chat` | Send a message to a loaded model; keeps per-`session_id` conversation history |
45
+ | `load_model` | Load a downloaded-but-unloaded model into memory — requires client elicitation support and explicit user consent |
46
+ | `unload_model` | Unload a currently-loaded model from memory — the counterpart to `load_model`; does not require elicitation/consent |
47
+ | `reset_chat` | Clear conversation history for a session (or all sessions) |
48
+ | `delegate_task` | Delegate a mechanical, bounded coding task (rename, find/replace) to a local model running its own read/list/edit-file loop against `dir` — file content never enters the calling conversation, only the final summary does |
47
49
 
48
50
  `chat` and `load_model` never implicitly load a model: `chat` refuses if the
49
51
  requested model isn't already loaded, and `load_model` is the only tool that
50
- can bring one into memory, gated behind an MCP elicitation prompt.
52
+ can bring one into memory, gated behind an MCP elicitation prompt. `unload_model`
53
+ is exempt from that gate by design — freeing memory is low-risk and reversible
54
+ (load the model again any time), so it doesn't ask for consent.
55
+
56
+ ## Delegating tasks to a local model
57
+
58
+ `delegate_task` hands a small, mechanical, multi-step coding task (a variable
59
+ rename, a boring find/replace, a boilerplate first draft) to a local LM
60
+ Studio model, which runs its own read → edit → read → done loop directly
61
+ against the filesystem. File contents and the tool back-and-forth never enter
62
+ the calling conversation — only a short final summary does — which is what
63
+ makes this worth using: rote, bounded edits that would otherwise burn context
64
+ reading and re-writing file contents happen entirely on the local model's
65
+ side.
66
+
67
+ **Safety model.** The local model gets exactly three tools — `list_dir`,
68
+ `read_file`, `edit_file` — all resolved through `safeResolvePath`, which
69
+ refuses any path that would resolve outside the caller-supplied `dir`. There
70
+ is no shell/Bash tool in its loop, ever. `edit_file` is a search-and-replace
71
+ (exactly one match of `old_string` required), not a whole-file overwrite, so
72
+ a bad or ambiguous edit fails loudly and leaves the file untouched instead of
73
+ silently clobbering it. It also cannot create new files.
74
+
75
+ **Reliability caveat.** Local models are much weaker than Claude — think
76
+ "fast and free but low-reliability," not a peer. They're fine for
77
+ boilerplate, renames, and simple mechanical transforms; they are not a good
78
+ fit for anything requiring cross-file understanding, nuanced judgment, or
79
+ high-stakes/hard-to-verify correctness. Always review the returned summary
80
+ before trusting it.
81
+
82
+ **Speed tip.** For reasoning models (e.g. Qwen3) doing simple mechanical
83
+ tasks, the "thinking" pass before each tool call adds latency with little
84
+ quality benefit. The `think: false` input tries a best-effort
85
+ `chat_template_kwargs: { enable_thinking: false }` passthrough on each
86
+ request, but this isn't guaranteed to be honored — the reliable lever is
87
+ disabling it directly in LM Studio's Jinja prompt template (add
88
+ `{%- set enable_thinking = false %}` at the top of the template, editable
89
+ under that model's settings in LM Studio).
51
90
 
52
91
  ## Development
53
92
 
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
3
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
7
  import { z } from "zod";
@@ -315,6 +317,62 @@ server.registerTool("load_model", {
315
317
  .describe("Idle time-to-live in seconds; LM Studio auto-unloads the model after this much inactivity"),
316
318
  },
317
319
  }, loadModelHandler);
320
+ export async function unloadModelHandler({ model }, extra) {
321
+ const notify = createStepNotifier(extra);
322
+ const client = getLmStudioClient();
323
+ await notify(`Checking whether '${model}' is loaded...`);
324
+ let loadedInstances;
325
+ try {
326
+ loadedInstances = await client.llm.listLoaded();
327
+ }
328
+ catch (err) {
329
+ throw new Error(describeLmStudioWsError(err));
330
+ }
331
+ const match = loadedInstances.find((m) => m.identifier === model || m.modelKey === model || m.path === model);
332
+ if (!match) {
333
+ const loadedList = loadedInstances.length
334
+ ? loadedInstances.map((m) => m.identifier).join(", ")
335
+ : "(none currently loaded)";
336
+ return {
337
+ content: [
338
+ {
339
+ type: "text",
340
+ text: `Model '${model}' is not currently loaded in LM Studio. Currently loaded models: ${loadedList}.`,
341
+ },
342
+ ],
343
+ };
344
+ }
345
+ await notify(`Unloading '${match.identifier}'...`);
346
+ try {
347
+ await client.llm.unload(match.identifier);
348
+ }
349
+ catch (err) {
350
+ throw new Error(describeLmStudioWsError(err));
351
+ }
352
+ await notify(`Model '${match.identifier}' unloaded successfully.`);
353
+ return {
354
+ content: [
355
+ {
356
+ type: "text",
357
+ text: `Model '${match.identifier}' unloaded successfully.`,
358
+ },
359
+ ],
360
+ };
361
+ }
362
+ server.registerTool("unload_model", {
363
+ title: "Unload a model from LM Studio memory",
364
+ description: "Explicitly unload a currently-loaded model from LM Studio memory. This is the counterpart to " +
365
+ "load_model. Unlike load_model, this does NOT require user consent via MCP elicitation: " +
366
+ "unloading only frees RAM/VRAM and is safely reversible (load it again any time), so it isn't " +
367
+ "gated behind a confirmation prompt. Returns a plain informative result (not an error) if the " +
368
+ "requested model isn't currently loaded.",
369
+ inputSchema: {
370
+ model: z
371
+ .string()
372
+ .describe("Model to unload — matched against a currently-loaded model's identifier, modelKey, or " +
373
+ "path (e.g. the identifier returned by load_model or list_models)."),
374
+ },
375
+ }, unloadModelHandler);
318
376
  export async function resetChatHandler({ session_id }) {
319
377
  if (session_id) {
320
378
  sessions.delete(session_id);
@@ -330,10 +388,299 @@ server.registerTool("reset_chat", {
330
388
  session_id: z.string().optional().describe("Session to clear; omit to clear all sessions"),
331
389
  },
332
390
  }, resetChatHandler);
391
+ // --- delegate_task: a scoped, filesystem-grounded agentic loop that hands a ---
392
+ // small mechanical coding task to a local model, keeping file contents and
393
+ // the back-and-forth out of the calling conversation entirely.
394
+ // Lexical-only path check: this does not resolve symlinks, so a symlink
395
+ // inside `dir` that points outside it would not be caught here. That's
396
+ // acceptable because `dir` is supplied by the user configuring this tool,
397
+ // not by untrusted input. Separately (and structurally, not enforced by this
398
+ // function): no Bash/shell tool is ever defined for the delegate_task loop,
399
+ // so there is no way to escape `dir` via a shell command even if a path did.
400
+ export function safeResolvePath(dir, requestedPath) {
401
+ const rootDir = path.resolve(dir);
402
+ const candidate = path.resolve(rootDir, requestedPath);
403
+ const rel = path.relative(rootDir, candidate);
404
+ if (rel !== "" && (rel.startsWith(`..${path.sep}`) || rel === ".." || path.isAbsolute(rel))) {
405
+ throw new Error(`Path '${requestedPath}' escapes the allowed directory '${rootDir}'.`);
406
+ }
407
+ return candidate;
408
+ }
409
+ export const MAX_FILE_BYTES = 100 * 1024;
410
+ const TOOL_DEFS = [
411
+ {
412
+ type: "function",
413
+ function: {
414
+ name: "list_dir",
415
+ description: "List files and subdirectories directly inside a directory (non-recursive), relative to the task's root directory.",
416
+ parameters: {
417
+ type: "object",
418
+ properties: {
419
+ path: {
420
+ type: "string",
421
+ description: "Directory path relative to the root directory. Use '.' for the root itself.",
422
+ },
423
+ },
424
+ required: ["path"],
425
+ },
426
+ },
427
+ },
428
+ {
429
+ type: "function",
430
+ function: {
431
+ name: "read_file",
432
+ description: "Read the full text contents of a file, relative to the task's root directory. Fails if the file exceeds the size limit or does not exist.",
433
+ parameters: {
434
+ type: "object",
435
+ properties: {
436
+ path: { type: "string", description: "File path relative to the root directory." },
437
+ },
438
+ required: ["path"],
439
+ },
440
+ },
441
+ },
442
+ {
443
+ type: "function",
444
+ function: {
445
+ name: "edit_file",
446
+ description: "Apply a search-and-replace edit to an existing text file: replaces exactly one occurrence of old_string with new_string. Fails loudly (no change made) if old_string is not found, or is found more than once — in that case, include more surrounding context in old_string to uniquely identify the location. Cannot create new files.",
447
+ parameters: {
448
+ type: "object",
449
+ properties: {
450
+ path: { type: "string", description: "File path relative to the root directory." },
451
+ old_string: {
452
+ type: "string",
453
+ description: "Exact text to find; must appear exactly once in the file.",
454
+ },
455
+ new_string: { type: "string", description: "Text to replace it with." },
456
+ },
457
+ required: ["path", "old_string", "new_string"],
458
+ },
459
+ },
460
+ },
461
+ ];
462
+ /**
463
+ * Executes one model-requested tool call against the filesystem, scoped to
464
+ * `rootDir`. Every ordinary failure (bad path, missing file, no/ambiguous
465
+ * match, bad JSON, unknown tool name) is caught and turned into an
466
+ * `"Error: ..."` string returned as the tool result — never thrown — so the
467
+ * agentic loop keeps going and the model gets a chance to self-correct.
468
+ * Successful `edit_file` calls record their (relative) path into
469
+ * `touchedFiles`, used as a fallback summary if the model's final answer
470
+ * comes back empty (see `delegateTaskHandler`).
471
+ */
472
+ async function runTool(rootDir, name, argsJson, touchedFiles) {
473
+ let args;
474
+ try {
475
+ args = JSON.parse(argsJson);
476
+ }
477
+ catch (err) {
478
+ return `Error: invalid JSON arguments: ${err instanceof Error ? err.message : String(err)}`;
479
+ }
480
+ const requiredFieldsByTool = {
481
+ list_dir: ["path"],
482
+ read_file: ["path"],
483
+ edit_file: ["path", "old_string", "new_string"],
484
+ };
485
+ const required = requiredFieldsByTool[name];
486
+ if (required) {
487
+ const missing = required.filter((f) => typeof args?.[f] !== "string" || args[f] === "");
488
+ if (missing.length > 0) {
489
+ return `Error: missing or empty required argument(s) for ${name}: ${missing.join(", ")}.`;
490
+ }
491
+ }
492
+ try {
493
+ switch (name) {
494
+ case "list_dir": {
495
+ const resolved = safeResolvePath(rootDir, args.path);
496
+ const entries = await fs.readdir(resolved, { withFileTypes: true });
497
+ if (entries.length === 0)
498
+ return "(empty directory)";
499
+ return entries
500
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
501
+ .sort()
502
+ .join("\n");
503
+ }
504
+ case "read_file": {
505
+ const resolved = safeResolvePath(rootDir, args.path);
506
+ const stat = await fs.stat(resolved);
507
+ if (stat.size > MAX_FILE_BYTES) {
508
+ return `Error: file exceeds the 100KB size guard (${stat.size} bytes): ${args.path}`;
509
+ }
510
+ return await fs.readFile(resolved, "utf8");
511
+ }
512
+ case "edit_file": {
513
+ const resolved = safeResolvePath(rootDir, args.path);
514
+ const stat = await fs.stat(resolved);
515
+ if (stat.size > MAX_FILE_BYTES) {
516
+ return `Error: file exceeds the 100KB size guard (${stat.size} bytes): ${args.path}`;
517
+ }
518
+ const content = await fs.readFile(resolved, "utf8");
519
+ const occurrences = content.split(args.old_string).length - 1;
520
+ if (occurrences === 0) {
521
+ return `Error: old_string not found in ${args.path}. Nothing was changed.`;
522
+ }
523
+ if (occurrences > 1) {
524
+ return (`Error: old_string matches ${occurrences} times in ${args.path}; add more context ` +
525
+ `to uniquely identify it. Nothing was changed.`);
526
+ }
527
+ const updated = content.replace(args.old_string, args.new_string);
528
+ await fs.writeFile(resolved, updated, "utf8");
529
+ touchedFiles.add(args.path);
530
+ return `Replaced 1 occurrence in ${args.path}.`;
531
+ }
532
+ default:
533
+ return `Error: unknown tool '${name}'.`;
534
+ }
535
+ }
536
+ catch (err) {
537
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
538
+ }
539
+ }
540
+ function buildSystemPrompt(rootDir) {
541
+ return (`You are a coding assistant working inside the directory '${rootDir}'. You have three tools: ` +
542
+ `list_dir, read_file, and edit_file — all paths you pass to them must be relative to this ` +
543
+ `directory (e.g. 'src/utils.ts', or '.' for the root itself). Use them as needed to explore, ` +
544
+ `read, and make the requested change. When you are done — or if you determine no change is ` +
545
+ `needed — respond with a concise final text summary of what you did and make no further tool ` +
546
+ `calls.`);
547
+ }
548
+ /** Strips a leaked `<think>...</think>` reasoning block from final model output, if present. */
549
+ function stripThinking(content) {
550
+ return content.replace(/<think>.*?<\/think>/gis, "").trim();
551
+ }
552
+ /**
553
+ * Builds the text returned to the caller once the model stops requesting
554
+ * tool calls. Some reasoning models (e.g. Qwen3 via LM Studio) put their
555
+ * entire response in a separate `reasoning_content` field and leave
556
+ * `message.content` blank/whitespace-only even on a fully successful task —
557
+ * so an empty `content` here does NOT mean nothing happened. Falls back to
558
+ * a summary built from the files the loop actually touched, rather than
559
+ * silently returning empty text.
560
+ */
561
+ function finalSummary(content, touchedFiles) {
562
+ const stripped = stripThinking(content);
563
+ if (stripped)
564
+ return stripped;
565
+ if (touchedFiles.size === 0) {
566
+ return "Task completed with no summary text from the model, and no files were changed.";
567
+ }
568
+ return (`Task completed with no summary text from the model. Files changed: ` +
569
+ `${[...touchedFiles].join(", ")}.`);
570
+ }
571
+ export async function delegateTaskHandler({ task, model, dir, max_turns = 15, think = true }, extra) {
572
+ const notify = createStepNotifier(extra);
573
+ await notify(`Validating directory '${dir}'...`);
574
+ const rootDir = path.resolve(dir);
575
+ const stat = await fs.stat(rootDir).catch(() => undefined);
576
+ if (!stat?.isDirectory()) {
577
+ throw new Error(`'${dir}' does not exist or is not a directory. Refusing to start.`);
578
+ }
579
+ await notify(`Verifying model '${model}' is loaded...`);
580
+ const loaded = await getLoadedModels();
581
+ if (!loaded.some((m) => m.id === model)) {
582
+ const loadedList = loaded.length
583
+ ? loaded.map((m) => m.id).join(", ")
584
+ : "(none currently loaded)";
585
+ throw new Error(`Model '${model}' is not currently loaded in LM Studio. Currently loaded models: ${loadedList}. ` +
586
+ `Load '${model}' in the LM Studio app first.`);
587
+ }
588
+ const messages = [
589
+ { role: "system", content: buildSystemPrompt(rootDir) },
590
+ { role: "user", content: task },
591
+ ];
592
+ const touchedFiles = new Set();
593
+ for (let turn = 1; turn <= max_turns; turn++) {
594
+ await notify(`Turn ${turn}/${max_turns}: sending request to LM Studio...`);
595
+ const data = await lmFetch("/v1/chat/completions", {
596
+ method: "POST",
597
+ headers: { "Content-Type": "application/json" },
598
+ body: JSON.stringify({
599
+ model,
600
+ messages,
601
+ tools: TOOL_DEFS,
602
+ tool_choice: "auto",
603
+ temperature: 0.1,
604
+ ...(think === false ? { chat_template_kwargs: { enable_thinking: false } } : {}),
605
+ }),
606
+ });
607
+ const message = data.choices?.[0]?.message;
608
+ if (!message)
609
+ throw new Error("LM Studio returned no message in choices[0].");
610
+ messages.push(message);
611
+ const toolCalls = message.tool_calls ?? [];
612
+ if (toolCalls.length === 0) {
613
+ await notify(`Model returned a final answer with no further tool calls.`);
614
+ return {
615
+ content: [
616
+ { type: "text", text: finalSummary(message.content ?? "", touchedFiles) },
617
+ ],
618
+ };
619
+ }
620
+ for (const call of toolCalls) {
621
+ await notify(`Turn ${turn}: calling ${call.function.name}(${call.function.arguments})`);
622
+ const resultText = await runTool(rootDir, call.function.name, call.function.arguments, touchedFiles);
623
+ messages.push({ role: "tool", tool_call_id: call.id, content: resultText });
624
+ }
625
+ }
626
+ await notify(`Reached max_turns (${max_turns}) without a final answer.`);
627
+ return {
628
+ content: [
629
+ {
630
+ type: "text",
631
+ text: `Ran out of turns (${max_turns}) before the model finished. It may have made partial ` +
632
+ `edits inside '${rootDir}' — review before relying on this. Increase max_turns to let it finish.`,
633
+ },
634
+ ],
635
+ };
636
+ }
637
+ server.registerTool("delegate_task", {
638
+ title: "Delegate a mechanical coding task to a local model",
639
+ description: "Delegate a small, mechanical, multi-step coding task (a variable rename, a boring " +
640
+ "find/replace, a boilerplate first-draft) to a local model in LM Studio. The local model " +
641
+ "runs its own read/list/edit-file loop directly against the filesystem, scoped to `dir` — " +
642
+ "file contents and the back-and-forth never enter this conversation, only the final summary " +
643
+ "does, so this saves context on rote work. Pass only a plain-language `task`, never file " +
644
+ "contents. Do NOT use this for anything requiring nuanced judgment, understanding " +
645
+ "relationships across many files, awareness of the current conversation, or high-stakes/hard-" +
646
+ "to-verify correctness — treat the local model as fast and free but low-reliability " +
647
+ "('Haiku-tier assistant, not a replacement'); review the summary before trusting it blindly. " +
648
+ "Scope tasks the way you'd scope a surgical 1-2 file edit, not a refactor spanning the tree.",
649
+ inputSchema: {
650
+ task: z
651
+ .string()
652
+ .min(1)
653
+ .describe("Plain-language instruction for the local model, e.g. 'rename variable foo to bar in " +
654
+ "utils.ts'. Do not include file contents — the model reads files itself via its own tools."),
655
+ model: z.string().describe("LM Studio model id (from list_models); must already be loaded."),
656
+ dir: z
657
+ .string()
658
+ .describe("Absolute path to the directory the local model's file tools are scoped to. All " +
659
+ "read_file/list_dir/edit_file calls that would resolve outside this directory are refused."),
660
+ max_turns: z
661
+ .number()
662
+ .int()
663
+ .positive()
664
+ .max(50)
665
+ .default(15)
666
+ .optional()
667
+ .describe("Cap on agentic-loop iterations before returning a best-effort summary."),
668
+ think: z
669
+ .boolean()
670
+ .default(true)
671
+ .optional()
672
+ .describe("Best-effort hint to disable the model's reasoning/thinking output for simple tasks " +
673
+ "(set false for speed). Not guaranteed — LM Studio's Jinja prompt template is the " +
674
+ "reliable control for this; see README."),
675
+ },
676
+ }, delegateTaskHandler);
333
677
  // Only start the stdio transport when this file is run directly (e.g. `node
334
678
  // dist/index.js` or via the `subforge-mcp` bin), not when it's imported as a
335
- // module by the test suite.
336
- const isEntryPoint = process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`;
679
+ // module by the test suite. Comparing realpaths (rather than the raw argv[1])
680
+ // matters because npm/pnpm always invoke the bin through a symlink, which
681
+ // import.meta.url resolves through but process.argv[1] does not — a naive
682
+ // string comparison would never match for a globally-installed package.
683
+ const isEntryPoint = process.argv[1] !== undefined && import.meta.url === `file://${realpathSync(process.argv[1])}`;
337
684
  if (isEntryPoint) {
338
685
  const transport = new StdioServerTransport();
339
686
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subforge-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "MCP server for local LM Studio models",
5
5
  "type": "module",
6
6
  "license": "MIT",