subforge-mcp 0.1.0 → 1.1.0
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/README.md +46 -7
- package/dist/index.js +344 -0
- 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
|
|
42
|
-
|
|
|
43
|
-
| `list_models`
|
|
44
|
-
| `chat`
|
|
45
|
-
| `load_model`
|
|
46
|
-
| `
|
|
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
2
|
import { readFileSync } 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,6 +388,292 @@ 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
679
|
// module by the test suite.
|