subforge-mcp 0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -0
  3. package/dist/index.js +340 -0
  4. package/package.json +74 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathan Marcelino
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # subforge-mcp
2
+
3
+ An MCP (Model Context Protocol) server that lets AI clients talk to models
4
+ running locally in [LM Studio](https://lmstudio.ai/) — list what's loaded,
5
+ chat with per-session history, and (with user consent) load a downloaded
6
+ model into memory.
7
+
8
+ ## Install
9
+
10
+ Requires LM Studio running locally with its local server started
11
+ (LM Studio → Developer → Start Server).
12
+
13
+ **Claude Code:**
14
+
15
+ ```bash
16
+ claude mcp add subforge -- npx -y subforge-mcp
17
+ ```
18
+
19
+ **Claude Desktop / VS Code (`mcp.json`):**
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "subforge": {
25
+ "type": "stdio",
26
+ "command": "npx",
27
+ "args": ["-y", "subforge-mcp"]
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ | Variable | Default | Notes |
36
+ | ------------------- | ----------------------- | --------------------------------------------------------- |
37
+ | `LMSTUDIO_BASE_URL` | `http://localhost:1234` | Base URL of the LM Studio local server (REST + WebSocket) |
38
+
39
+ ## MCP Tools
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) |
47
+
48
+ `chat` and `load_model` never implicitly load a model: `chat` refuses if the
49
+ 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.
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ pnpm install
56
+ pnpm run build # compile TypeScript to dist/
57
+ pnpm test # run the test suite (node:test)
58
+ pnpm run typecheck # type-check src/ + tests/
59
+ pnpm run lint # oxlint (type-aware)
60
+ ```
61
+
62
+ ### Project structure
63
+
64
+ ```
65
+ subforge-mcp/
66
+ ├── src/
67
+ │ └── index.ts # server + tool registrations
68
+ └── tests/
69
+ ├── helpers.ts # fetch/extra mocks shared by tests
70
+ └── *.test.ts
71
+ ```
72
+
73
+ ## Releases
74
+
75
+ This project is trunk-based: every change lands on `main` via a squash-merged
76
+ pull request titled as a [Conventional Commit](https://www.conventionalcommits.org/)
77
+ (`feat: ...`, `fix: ...`, `feat!: ...`, etc.). Merging to `main` triggers
78
+ [semantic-release](https://github.com/semantic-release/semantic-release),
79
+ which determines the next version from commit history, updates
80
+ [`CHANGELOG.md`](./CHANGELOG.md), tags a GitHub release, and publishes to npm.
81
+
82
+ ## License
83
+
84
+ [MIT](./LICENSE)
package/dist/index.js ADDED
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import { LMStudioClient } from "@lmstudio/sdk";
7
+ /**
8
+ * Creates a step notifier bound to one tool call. Each call sends a
9
+ * `notifications/message` (logging) notification, and — if the caller
10
+ * supplied a `progressToken` in `_meta` — an accompanying
11
+ * `notifications/progress` notification with an incrementing counter.
12
+ */
13
+ function createStepNotifier(extra) {
14
+ let progress = 0;
15
+ const progressToken = extra._meta?.progressToken;
16
+ return async (message) => {
17
+ progress += 1;
18
+ await extra.sendNotification({
19
+ method: "notifications/message",
20
+ params: { level: "info", data: message },
21
+ });
22
+ if (progressToken !== undefined) {
23
+ await extra.sendNotification({
24
+ method: "notifications/progress",
25
+ params: { progressToken, progress },
26
+ });
27
+ }
28
+ };
29
+ }
30
+ export const BASE_URL = (process.env.LMSTUDIO_BASE_URL ?? "http://localhost:1234").replace(/\/$/, "");
31
+ export const sessions = new Map();
32
+ export async function lmFetch(path, init) {
33
+ let res;
34
+ try {
35
+ res = await fetch(`${BASE_URL}${path}`, init);
36
+ }
37
+ catch (err) {
38
+ throw new Error(`Cannot reach LM Studio at ${BASE_URL}. Is the local server running (LM Studio > Developer > Start Server)? ${err instanceof Error ? err.message : String(err)}`);
39
+ }
40
+ if (!res.ok) {
41
+ const body = await res.text().catch(() => "");
42
+ throw new Error(`LM Studio request failed: ${res.status} ${res.statusText} ${body}`);
43
+ }
44
+ return res.json();
45
+ }
46
+ export async function getLoadedModels() {
47
+ const data = await lmFetch("/api/v0/models");
48
+ const models = data.data ?? [];
49
+ return models.filter((m) => m.state === "loaded");
50
+ }
51
+ // --- lmstudio-js (WebSocket) client, used ONLY by the load_model tool ---
52
+ //
53
+ // LM Studio's REST API has no explicit "load model" endpoint: hitting
54
+ // /v1/chat/completions with an unloaded model silently JIT-loads it, which is
55
+ // exactly the implicit behavior this server avoids elsewhere. The official
56
+ // @lmstudio/sdk package talks to LM Studio over WebSocket and exposes an
57
+ // explicit client.llm.load(...) with progress feedback, so it's the only
58
+ // path used to bring a new model into memory — and only after elicitation.
59
+ const WS_BASE_URL = BASE_URL.replace(/^http/, "ws");
60
+ let lmStudioClient;
61
+ // MCP over stdio reserves stdout exclusively for JSON-RPC messages. The SDK's
62
+ // default logger writes to `console` (stdout for info/warn/log), which would
63
+ // corrupt the transport — so every log level is redirected to stderr instead.
64
+ const stderrLogger = {
65
+ info: (...args) => console.error(...args),
66
+ warn: (...args) => console.error(...args),
67
+ error: (...args) => console.error(...args),
68
+ debug: (...args) => console.error(...args),
69
+ };
70
+ function getLmStudioClient() {
71
+ if (!lmStudioClient) {
72
+ lmStudioClient = new LMStudioClient({ baseUrl: WS_BASE_URL, logger: stderrLogger });
73
+ }
74
+ return lmStudioClient;
75
+ }
76
+ /**
77
+ * Test-only injection point: lets the test suite substitute a fake
78
+ * LMStudioClient (mocking the WebSocket SDK) without ever constructing a
79
+ * real one. Not used by production code paths.
80
+ */
81
+ export function __setLmStudioClientForTesting(client) {
82
+ lmStudioClient = client;
83
+ }
84
+ export function describeLmStudioWsError(err) {
85
+ // A genuine connection failure (e.g. LM Studio not running) surfaces as an
86
+ // AggregateError with code ECONNREFUSED and an empty `message`. Other SDK
87
+ // errors (e.g. load guardrails, bad model key) carry a real message that
88
+ // should be shown as-is rather than being masked by a generic connectivity
89
+ // message.
90
+ const code = err?.code;
91
+ const message = err instanceof Error ? err.message : String(err);
92
+ if (code === "ECONNREFUSED" || !message) {
93
+ return `Cannot reach LM Studio at ${WS_BASE_URL} (WebSocket). Is the local server running (LM Studio > Developer > Start Server)?`;
94
+ }
95
+ return `LM Studio request failed: ${message}`;
96
+ }
97
+ // dist/index.js sits one level below the package root, where package.json
98
+ // (and its version, bumped by semantic-release on every release) lives.
99
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
100
+ export const server = new McpServer({
101
+ name: "subforge-mcp",
102
+ version: packageJson.version,
103
+ }, {
104
+ capabilities: {
105
+ logging: {},
106
+ },
107
+ });
108
+ export async function listModelsHandler(_args, extra) {
109
+ const notify = createStepNotifier(extra);
110
+ await notify("Checking LM Studio for loaded models...");
111
+ const loaded = await getLoadedModels();
112
+ await notify(`Found ${loaded.length} loaded model(s).`);
113
+ return {
114
+ content: [
115
+ {
116
+ type: "text",
117
+ text: loaded.length
118
+ ? loaded.map((m) => m.id).join("\n")
119
+ : "No models are currently loaded in LM Studio. Load a model in the LM Studio app first, then try again.",
120
+ },
121
+ ],
122
+ };
123
+ }
124
+ server.registerTool("list_models", {
125
+ title: "List LM Studio models",
126
+ description: "List models currently loaded into memory in the local LM Studio server.",
127
+ inputSchema: {},
128
+ }, listModelsHandler);
129
+ export async function chatHandler({ message, model, session_id, system_prompt, temperature, max_tokens }, extra) {
130
+ const notify = createStepNotifier(extra);
131
+ const key = session_id ?? "default";
132
+ let history = sessions.get(key);
133
+ if (!history) {
134
+ history = [];
135
+ if (system_prompt)
136
+ history.push({ role: "system", content: system_prompt });
137
+ sessions.set(key, history);
138
+ }
139
+ await notify(`Verifying model '${model}' is loaded...`);
140
+ const loaded = await getLoadedModels();
141
+ if (!loaded.some((m) => m.id === model)) {
142
+ const loadedList = loaded.length
143
+ ? loaded.map((m) => m.id).join(", ")
144
+ : "(none currently loaded)";
145
+ throw new Error(`Model '${model}' is not currently loaded in LM Studio. Currently loaded models: ${loadedList}. ` +
146
+ `Load '${model}' in the LM Studio app first — this tool will not trigger an implicit load.`);
147
+ }
148
+ history.push({ role: "user", content: message });
149
+ await notify(`Sending request to LM Studio (${history.length} messages in history)...`);
150
+ let elapsedSeconds = 0;
151
+ const heartbeat = setInterval(() => {
152
+ elapsedSeconds += 3;
153
+ void notify(`Still waiting for a response... (${elapsedSeconds}s elapsed)`);
154
+ }, 3000);
155
+ let data;
156
+ try {
157
+ data = await lmFetch("/v1/chat/completions", {
158
+ method: "POST",
159
+ headers: { "Content-Type": "application/json" },
160
+ body: JSON.stringify({
161
+ model,
162
+ messages: history,
163
+ temperature: temperature ?? 0.7,
164
+ ...(max_tokens ? { max_tokens } : {}),
165
+ }),
166
+ });
167
+ }
168
+ finally {
169
+ clearInterval(heartbeat);
170
+ }
171
+ const reply = data.choices?.[0]?.message?.content ?? "";
172
+ history.push({ role: "assistant", content: reply });
173
+ await notify(`Received reply (${reply.length} characters).`);
174
+ return { content: [{ type: "text", text: reply }] };
175
+ }
176
+ server.registerTool("chat", {
177
+ title: "Chat with local LM Studio model",
178
+ description: "Send a message to a local model running in LM Studio. Maintains conversation history per session_id across calls; call reset_chat to clear it.",
179
+ inputSchema: {
180
+ message: z.string().describe("User message to send"),
181
+ model: z.string().describe("Model id, e.g. from list_models"),
182
+ session_id: z
183
+ .string()
184
+ .default("default")
185
+ .describe("Conversation id to keep history separate across topics"),
186
+ system_prompt: z
187
+ .string()
188
+ .optional()
189
+ .describe("System prompt; only applied when starting a new session"),
190
+ temperature: z.number().min(0).max(2).default(0.7).optional(),
191
+ max_tokens: z.number().int().positive().optional(),
192
+ },
193
+ }, chatHandler);
194
+ export async function loadModelHandler({ model, ttl_seconds }, extra) {
195
+ const notify = createStepNotifier(extra);
196
+ await notify("Checking whether the connected client supports elicitation...");
197
+ const clientCapabilities = server.server.getClientCapabilities();
198
+ if (!clientCapabilities?.elicitation) {
199
+ throw new Error("The connected MCP client did not declare the 'elicitation' capability, so load_model cannot " +
200
+ "obtain consent to load a model. Refusing to load anything. Connect with a client that " +
201
+ "supports elicitation/create to use this tool.");
202
+ }
203
+ const client = getLmStudioClient();
204
+ await notify(`Looking up downloaded LM Studio models to validate '${model}'...`);
205
+ let downloaded;
206
+ try {
207
+ downloaded = await client.system.listDownloadedModels("llm");
208
+ }
209
+ catch (err) {
210
+ throw new Error(describeLmStudioWsError(err));
211
+ }
212
+ const match = downloaded.find((m) => m.modelKey === model || m.path === model);
213
+ if (!match) {
214
+ const available = downloaded.length
215
+ ? downloaded.map((m) => m.modelKey).join(", ")
216
+ : "(none downloaded)";
217
+ throw new Error(`Model '${model}' was not found among downloaded LM Studio models. Downloaded models: ${available}`);
218
+ }
219
+ await notify(`Checking whether '${match.modelKey}' is already loaded...`);
220
+ let loadedInstances;
221
+ try {
222
+ loadedInstances = await client.llm.listLoaded();
223
+ }
224
+ catch (err) {
225
+ throw new Error(describeLmStudioWsError(err));
226
+ }
227
+ const alreadyLoaded = loadedInstances.find((m) => m.path === match.path || m.modelKey === match.modelKey);
228
+ if (alreadyLoaded) {
229
+ return {
230
+ content: [
231
+ {
232
+ type: "text",
233
+ text: `Model '${match.modelKey}' is already loaded (identifier '${alreadyLoaded.identifier}'). No action needed; skipping elicitation.`,
234
+ },
235
+ ],
236
+ };
237
+ }
238
+ const sizeGb = (match.sizeBytes / 1024 ** 3).toFixed(2);
239
+ await notify(`Requesting user confirmation to load '${match.modelKey}' (${sizeGb} GB)...`);
240
+ const elicitResult = await server.server.elicitInput({
241
+ mode: "form",
242
+ message: `Load model '${match.modelKey}' (${sizeGb} GB) into LM Studio memory? ` +
243
+ `This will consume RAM/VRAM${ttl_seconds
244
+ ? ` and will auto-unload after ${ttl_seconds}s of inactivity.`
245
+ : " and will remain loaded until explicitly unloaded."}`,
246
+ requestedSchema: {
247
+ type: "object",
248
+ properties: {
249
+ confirm: {
250
+ type: "boolean",
251
+ title: "Load model",
252
+ description: `Load '${match.modelKey}' into memory now?`,
253
+ default: true,
254
+ },
255
+ },
256
+ required: ["confirm"],
257
+ },
258
+ });
259
+ if (elicitResult.action !== "accept" || elicitResult.content?.confirm !== true) {
260
+ const verb = elicitResult.action === "decline" ? "declined" : "cancelled";
261
+ return {
262
+ content: [
263
+ {
264
+ type: "text",
265
+ text: `User ${verb} loading model '${match.modelKey}'. No changes were made.`,
266
+ },
267
+ ],
268
+ };
269
+ }
270
+ await notify(`Loading '${match.modelKey}' into memory...`);
271
+ let lastReportedPercent = -1;
272
+ let llm;
273
+ try {
274
+ llm = await client.llm.load(match.modelKey, {
275
+ ttl: ttl_seconds,
276
+ verbose: false,
277
+ onProgress: (progress) => {
278
+ const percent = Math.round(progress * 100);
279
+ if (percent !== lastReportedPercent && percent % 10 === 0) {
280
+ lastReportedPercent = percent;
281
+ void notify(`Loading '${match.modelKey}'... ${percent}%`);
282
+ }
283
+ },
284
+ });
285
+ }
286
+ catch (err) {
287
+ throw new Error(describeLmStudioWsError(err));
288
+ }
289
+ await notify(`Model '${llm.identifier}' loaded successfully.`);
290
+ return {
291
+ content: [
292
+ {
293
+ type: "text",
294
+ text: `Model '${match.modelKey}' loaded successfully as '${llm.identifier}'.` +
295
+ (ttl_seconds ? ` It will auto-unload after ${ttl_seconds}s of inactivity.` : ""),
296
+ },
297
+ ],
298
+ };
299
+ }
300
+ server.registerTool("load_model", {
301
+ title: "Load a model into LM Studio memory",
302
+ description: "Explicitly load a downloaded-but-not-yet-loaded model into LM Studio memory. Requires user " +
303
+ "consent via MCP elicitation before loading — this is the only tool in this server that can " +
304
+ "bring a new model into memory. Refuses if the connected client does not support elicitation, " +
305
+ "or if the user declines.",
306
+ inputSchema: {
307
+ model: z
308
+ .string()
309
+ .describe("Model id/key to load, e.g. a modelKey or path from a downloaded models listing"),
310
+ ttl_seconds: z
311
+ .number()
312
+ .int()
313
+ .positive()
314
+ .optional()
315
+ .describe("Idle time-to-live in seconds; LM Studio auto-unloads the model after this much inactivity"),
316
+ },
317
+ }, loadModelHandler);
318
+ export async function resetChatHandler({ session_id }) {
319
+ if (session_id) {
320
+ sessions.delete(session_id);
321
+ return { content: [{ type: "text", text: `Session '${session_id}' cleared.` }] };
322
+ }
323
+ sessions.clear();
324
+ return { content: [{ type: "text", text: "All sessions cleared." }] };
325
+ }
326
+ server.registerTool("reset_chat", {
327
+ title: "Reset chat session",
328
+ description: "Clear the conversation history for a session_id (or all sessions).",
329
+ inputSchema: {
330
+ session_id: z.string().optional().describe("Session to clear; omit to clear all sessions"),
331
+ },
332
+ }, resetChatHandler);
333
+ // Only start the stdio transport when this file is run directly (e.g. `node
334
+ // 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]}`;
337
+ if (isEntryPoint) {
338
+ const transport = new StdioServerTransport();
339
+ await server.connect(transport);
340
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "subforge-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for local LM Studio models",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Nathan Marcelino",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/nathan-vm/subforge-mcp.git"
11
+ },
12
+ "homepage": "https://github.com/nathan-vm/subforge-mcp#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/nathan-vm/subforge-mcp/issues"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "lm-studio",
20
+ "llm"
21
+ ],
22
+ "bin": {
23
+ "subforge-mcp": "./dist/index.js"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "engines": {
32
+ "node": ">=22",
33
+ "pnpm": ">=11"
34
+ },
35
+ "scripts": {
36
+ "prepare": "husky",
37
+ "build": "tsc",
38
+ "prepublishOnly": "pnpm run build",
39
+ "start": "node dist/index.js",
40
+ "dev": "tsc --watch",
41
+ "test": "node --test",
42
+ "typecheck": "tsc -p tsconfig.test.json",
43
+ "lint": "oxlint --type-aware .",
44
+ "lint:fix": "oxlint --type-aware --fix .",
45
+ "format": "prettier --write .",
46
+ "format:check": "prettier --check ."
47
+ },
48
+ "lint-staged": {
49
+ "*.ts": [
50
+ "oxlint --type-aware --fix",
51
+ "prettier --write"
52
+ ],
53
+ "*.{json,md,yml,yaml}": "prettier --write"
54
+ },
55
+ "dependencies": {
56
+ "@lmstudio/sdk": "^1.5.0",
57
+ "@modelcontextprotocol/sdk": "^1.30.0",
58
+ "zod": "^4.6.5"
59
+ },
60
+ "devDependencies": {
61
+ "@commitlint/cli": "^21.2.2",
62
+ "@commitlint/config-conventional": "^21.2.2",
63
+ "@semantic-release/changelog": "^7.0.0",
64
+ "@semantic-release/git": "^11.0.1",
65
+ "@types/node": "^22.20.3",
66
+ "husky": "^9.1.7",
67
+ "lint-staged": "^17.5.1",
68
+ "oxlint": "^1.83.0",
69
+ "oxlint-tsgolint": "^7.0.2001",
70
+ "prettier": "^3.9.6",
71
+ "semantic-release": "^25.0.9",
72
+ "typescript": "^7.0.2"
73
+ }
74
+ }