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.
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/index.js +340 -0
- 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
|
+
}
|