koishi-plugin-chatluna-mcp-server 1.0.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 +25 -0
- package/lib/config.d.ts +20 -0
- package/lib/index.cjs +859 -0
- package/lib/index.mjs +838 -0
- package/lib/plugins/config.d.ts +3 -0
- package/lib/plugins.d.ts +3 -0
- package/lib/service/mcp.d.ts +42 -0
- package/lib/utils.d.ts +4 -0
- package/package.json +111 -0
- package/readme.md +24 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
import { createLogger } from "koishi-plugin-chatluna/utils/logger";
|
|
6
|
+
|
|
7
|
+
// src/plugins/config.ts
|
|
8
|
+
import { Schema } from "koishi";
|
|
9
|
+
import { computed, watch } from "koishi-plugin-chatluna";
|
|
10
|
+
import {
|
|
11
|
+
embeddingsSchema,
|
|
12
|
+
modelSchema
|
|
13
|
+
} from "koishi-plugin-chatluna/utils/schema";
|
|
14
|
+
async function apply(ctx, config) {
|
|
15
|
+
modelSchema(ctx);
|
|
16
|
+
embeddingsSchema(ctx);
|
|
17
|
+
const tools = ctx.chatluna.platform.getTools();
|
|
18
|
+
const toolSchema = computed(() => {
|
|
19
|
+
const names = tools.value;
|
|
20
|
+
if (names.length === 0) {
|
|
21
|
+
return [Schema.const("无").description("无")];
|
|
22
|
+
}
|
|
23
|
+
return names.map((name2) => Schema.const(name2).description(name2));
|
|
24
|
+
});
|
|
25
|
+
const watcher = watch(
|
|
26
|
+
toolSchema,
|
|
27
|
+
(schemas) => {
|
|
28
|
+
ctx.schema.set("chatluna-tool", Schema.union(schemas));
|
|
29
|
+
},
|
|
30
|
+
{ immediate: true }
|
|
31
|
+
);
|
|
32
|
+
ctx.effect(() => () => watcher.stop());
|
|
33
|
+
}
|
|
34
|
+
__name(apply, "apply");
|
|
35
|
+
|
|
36
|
+
// src/plugins.ts
|
|
37
|
+
async function plugins(ctx, parent) {
|
|
38
|
+
const middlewares = [apply];
|
|
39
|
+
for (const middleware of middlewares) {
|
|
40
|
+
await middleware(ctx, parent);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
__name(plugins, "plugins");
|
|
44
|
+
|
|
45
|
+
// src/service/mcp.ts
|
|
46
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
47
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
48
|
+
import {
|
|
49
|
+
isInitializeRequest
|
|
50
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
51
|
+
import { randomUUID } from "crypto";
|
|
52
|
+
import { emptyEmbeddings } from "koishi-plugin-chatluna/llm-core/model/in_memory";
|
|
53
|
+
|
|
54
|
+
// src/utils.ts
|
|
55
|
+
import { getMessageContent } from "koishi-plugin-chatluna/utils/string";
|
|
56
|
+
function normalizeEndpointPath(path) {
|
|
57
|
+
const trimmed = (path || "").trim();
|
|
58
|
+
if (!trimmed) {
|
|
59
|
+
return "/mcp";
|
|
60
|
+
}
|
|
61
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
62
|
+
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
|
63
|
+
return withSlash.slice(0, -1);
|
|
64
|
+
}
|
|
65
|
+
return withSlash;
|
|
66
|
+
}
|
|
67
|
+
__name(normalizeEndpointPath, "normalizeEndpointPath");
|
|
68
|
+
function getHeaderValue(headers, name2) {
|
|
69
|
+
const key = Object.keys(headers).find(
|
|
70
|
+
(header) => header.toLowerCase() === name2.toLowerCase()
|
|
71
|
+
);
|
|
72
|
+
if (!key) {
|
|
73
|
+
return void 0;
|
|
74
|
+
}
|
|
75
|
+
const value = headers[key];
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
return value[0];
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
__name(getHeaderValue, "getHeaderValue");
|
|
82
|
+
function parseJsonPayload(value) {
|
|
83
|
+
if (value == null) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (typeof value === "object") {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
if (typeof value !== "string") {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(value);
|
|
94
|
+
} catch {
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const decoded = Buffer.from(value, "base64").toString("utf-8");
|
|
98
|
+
return JSON.parse(decoded);
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
__name(parseJsonPayload, "parseJsonPayload");
|
|
104
|
+
function formatToolOutput(output) {
|
|
105
|
+
if (output == null) {
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
if (typeof output === "string") {
|
|
109
|
+
return output;
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(output)) {
|
|
112
|
+
return getMessageContent(output);
|
|
113
|
+
}
|
|
114
|
+
if (typeof output === "object" && "content" in output) {
|
|
115
|
+
return getMessageContent(
|
|
116
|
+
output.content
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (typeof output === "object" && "text" in output) {
|
|
120
|
+
const text = output.text;
|
|
121
|
+
if (typeof text === "string") {
|
|
122
|
+
return text;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return JSON.stringify(output, null, 2);
|
|
127
|
+
} catch {
|
|
128
|
+
return String(output);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
__name(formatToolOutput, "formatToolOutput");
|
|
132
|
+
|
|
133
|
+
// src/service/mcp.ts
|
|
134
|
+
import { isZodSchemaV3 } from "@langchain/core/utils/types";
|
|
135
|
+
import { z } from "zod/v3";
|
|
136
|
+
var PLACEHOLDER_TOOL = "chatluna_placeholder";
|
|
137
|
+
var TOOL_META_FIELD = "_meta_chatluna";
|
|
138
|
+
var McpServerBridge = class {
|
|
139
|
+
constructor(ctx, config, logger2) {
|
|
140
|
+
this.ctx = ctx;
|
|
141
|
+
this.config = config;
|
|
142
|
+
this.logger = logger2;
|
|
143
|
+
this.endpointPath = normalizeEndpointPath(config.endpointPath);
|
|
144
|
+
this.registerRoutes();
|
|
145
|
+
this.bindEvents();
|
|
146
|
+
this.ctx.logger.success("available at %c", this.buildAccessUrl());
|
|
147
|
+
}
|
|
148
|
+
static {
|
|
149
|
+
__name(this, "McpServerBridge");
|
|
150
|
+
}
|
|
151
|
+
endpointPath;
|
|
152
|
+
tools = /* @__PURE__ */ new Map();
|
|
153
|
+
sessions = /* @__PURE__ */ new Map();
|
|
154
|
+
refreshing = false;
|
|
155
|
+
refreshQueued = false;
|
|
156
|
+
modelCache = /* @__PURE__ */ new Map();
|
|
157
|
+
embeddingsCache = /* @__PURE__ */ new Map();
|
|
158
|
+
async init() {
|
|
159
|
+
await this.refreshTools();
|
|
160
|
+
}
|
|
161
|
+
async dispose() {
|
|
162
|
+
for (const [sessionId, state] of this.sessions) {
|
|
163
|
+
try {
|
|
164
|
+
await state.transport.close();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
this.logger.warn(
|
|
167
|
+
`Failed to close MCP transport ${sessionId}`,
|
|
168
|
+
error
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await state.server.close();
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this.logger.warn(
|
|
175
|
+
`Failed to close MCP server ${sessionId}`,
|
|
176
|
+
error
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
this.sessions.clear();
|
|
181
|
+
}
|
|
182
|
+
bindEvents() {
|
|
183
|
+
this.ctx.on("ready", async () => {
|
|
184
|
+
await this.refreshTools();
|
|
185
|
+
});
|
|
186
|
+
this.ctx.on("chatluna/tool-updated", async () => {
|
|
187
|
+
try {
|
|
188
|
+
await this.refreshTools();
|
|
189
|
+
} catch (error) {
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
registerRoutes() {
|
|
194
|
+
const isRequestAuthorized = /* @__PURE__ */ __name((req) => {
|
|
195
|
+
if (!this.config.auth?.enabled) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
const url = new URL(req.url ?? "", "http://localhost");
|
|
199
|
+
const key = url.searchParams.get("key");
|
|
200
|
+
return !!key && key === this.config.auth.key;
|
|
201
|
+
}, "isRequestAuthorized");
|
|
202
|
+
const handleSessionRequest = /* @__PURE__ */ __name(async (koa) => {
|
|
203
|
+
const { req, res } = koa;
|
|
204
|
+
try {
|
|
205
|
+
if (!isRequestAuthorized(req)) {
|
|
206
|
+
res.statusCode = 401;
|
|
207
|
+
res.end("Unauthorized");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const sessionHeader = req.headers["mcp-session-id"];
|
|
211
|
+
const sessionId = Array.isArray(sessionHeader) ? sessionHeader[0] : sessionHeader;
|
|
212
|
+
if (!sessionId || !this.sessions.has(sessionId)) {
|
|
213
|
+
res.statusCode = 400;
|
|
214
|
+
res.end("Invalid or missing session ID");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const state = this.sessions.get(sessionId);
|
|
218
|
+
await state.transport.handleRequest(req, res);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
this.logger.warn("Failed to handle MCP request", error);
|
|
221
|
+
if (!res.headersSent) {
|
|
222
|
+
res.statusCode = 500;
|
|
223
|
+
res.end("Internal server error");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}, "handleSessionRequest");
|
|
227
|
+
this.ctx.server.post(this.endpointPath, async (koa) => {
|
|
228
|
+
koa.respond = false;
|
|
229
|
+
const { req, res } = koa;
|
|
230
|
+
try {
|
|
231
|
+
if (!isRequestAuthorized(req)) {
|
|
232
|
+
res.statusCode = 401;
|
|
233
|
+
res.end("Unauthorized");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const sessionHeader = req.headers["mcp-session-id"];
|
|
237
|
+
const sessionId = Array.isArray(sessionHeader) ? sessionHeader[0] : sessionHeader;
|
|
238
|
+
const parsedBody = koa.request?.body;
|
|
239
|
+
if (sessionId && this.sessions.has(sessionId)) {
|
|
240
|
+
const state = this.sessions.get(sessionId);
|
|
241
|
+
await state.transport.handleRequest(req, res, parsedBody);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (parsedBody && isInitializeRequest(parsedBody)) {
|
|
245
|
+
const state = this.createSessionState();
|
|
246
|
+
await state.server.connect(state.transport);
|
|
247
|
+
await state.transport.handleRequest(req, res, parsedBody);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
res.writeHead(400).end(
|
|
251
|
+
JSON.stringify({
|
|
252
|
+
jsonrpc: "2.0",
|
|
253
|
+
error: {
|
|
254
|
+
code: -32e3,
|
|
255
|
+
message: "Bad Request: No valid session ID provided"
|
|
256
|
+
},
|
|
257
|
+
id: null
|
|
258
|
+
})
|
|
259
|
+
);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
this.logger.warn("Failed to handle MCP POST request", error);
|
|
262
|
+
if (!res.headersSent) {
|
|
263
|
+
res.writeHead(500).end(
|
|
264
|
+
JSON.stringify({
|
|
265
|
+
jsonrpc: "2.0",
|
|
266
|
+
error: {
|
|
267
|
+
code: -32603,
|
|
268
|
+
message: "Internal server error"
|
|
269
|
+
},
|
|
270
|
+
id: null
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
this.ctx.server.get(this.endpointPath, async (koa) => {
|
|
277
|
+
koa.respond = false;
|
|
278
|
+
await handleSessionRequest(koa);
|
|
279
|
+
});
|
|
280
|
+
this.ctx.server.delete(
|
|
281
|
+
this.endpointPath,
|
|
282
|
+
async (koa) => {
|
|
283
|
+
koa.respond = false;
|
|
284
|
+
await handleSessionRequest(koa);
|
|
285
|
+
}
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
buildAccessUrl() {
|
|
289
|
+
const base = this.ctx.server.selfUrl + this.config.endpointPath;
|
|
290
|
+
if (!this.config.auth?.enabled) {
|
|
291
|
+
return base;
|
|
292
|
+
}
|
|
293
|
+
const key = this.config.auth.key;
|
|
294
|
+
return `${base}?key=${encodeURIComponent(key)}`;
|
|
295
|
+
}
|
|
296
|
+
async refreshTools() {
|
|
297
|
+
if (this.refreshing) {
|
|
298
|
+
this.refreshQueued = true;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
this.refreshing = true;
|
|
302
|
+
try {
|
|
303
|
+
const nextTools = await this.collectTools();
|
|
304
|
+
this.tools = nextTools;
|
|
305
|
+
for (const state of this.sessions.values()) {
|
|
306
|
+
this.syncSessionTools(state);
|
|
307
|
+
}
|
|
308
|
+
} finally {
|
|
309
|
+
this.refreshing = false;
|
|
310
|
+
if (this.refreshQueued) {
|
|
311
|
+
this.refreshQueued = false;
|
|
312
|
+
await this.refreshTools();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async collectTools() {
|
|
317
|
+
const toolNames = this.ctx.chatluna.platform.getTools().value;
|
|
318
|
+
const blacklist = new Set(this.config.toolBlacklist ?? []);
|
|
319
|
+
const embeddings = await this.getEmbeddings();
|
|
320
|
+
const nextTools = /* @__PURE__ */ new Map();
|
|
321
|
+
for (const name2 of toolNames) {
|
|
322
|
+
if (blacklist.has(name2)) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const toolCreator = this.ctx.chatluna.platform.getTool(name2);
|
|
327
|
+
const tool = toolCreator.createTool({ embeddings });
|
|
328
|
+
nextTools.set(name2, {
|
|
329
|
+
name: name2,
|
|
330
|
+
description: tool.description ?? "",
|
|
331
|
+
schema: tool.schema,
|
|
332
|
+
tool
|
|
333
|
+
});
|
|
334
|
+
} catch (error) {
|
|
335
|
+
this.logger.warn(
|
|
336
|
+
`Failed to create ChatLuna tool ${name2}`,
|
|
337
|
+
error
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return nextTools;
|
|
342
|
+
}
|
|
343
|
+
createSessionState() {
|
|
344
|
+
const server = new McpServer(
|
|
345
|
+
{
|
|
346
|
+
name: "chatluna-mcp-server",
|
|
347
|
+
version: "1.0.0"
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
capabilities: {
|
|
351
|
+
logging: {}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
);
|
|
355
|
+
const state = {
|
|
356
|
+
server,
|
|
357
|
+
transport: new StreamableHTTPServerTransport({
|
|
358
|
+
sessionIdGenerator: /* @__PURE__ */ __name(() => randomUUID(), "sessionIdGenerator"),
|
|
359
|
+
onsessioninitialized: /* @__PURE__ */ __name((sessionId) => {
|
|
360
|
+
this.sessions.set(sessionId, state);
|
|
361
|
+
}, "onsessioninitialized"),
|
|
362
|
+
onsessionclosed: /* @__PURE__ */ __name((sessionId) => {
|
|
363
|
+
if (sessionId) {
|
|
364
|
+
this.sessions.delete(sessionId);
|
|
365
|
+
}
|
|
366
|
+
}, "onsessionclosed")
|
|
367
|
+
}),
|
|
368
|
+
tools: /* @__PURE__ */ new Map()
|
|
369
|
+
};
|
|
370
|
+
state.transport.onclose = async () => {
|
|
371
|
+
if (state.transport.sessionId) {
|
|
372
|
+
this.sessions.delete(state.transport.sessionId);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
this.registerInitialTools(state);
|
|
376
|
+
return state;
|
|
377
|
+
}
|
|
378
|
+
registerInitialTools(state) {
|
|
379
|
+
if (this.tools.size === 0) {
|
|
380
|
+
const placeholder = state.server.registerTool(
|
|
381
|
+
PLACEHOLDER_TOOL,
|
|
382
|
+
{
|
|
383
|
+
description: "internal placeholder",
|
|
384
|
+
inputSchema: this.extendToolSchema()
|
|
385
|
+
},
|
|
386
|
+
async () => ({
|
|
387
|
+
content: [
|
|
388
|
+
{
|
|
389
|
+
type: "text",
|
|
390
|
+
text: "No tools available"
|
|
391
|
+
}
|
|
392
|
+
]
|
|
393
|
+
})
|
|
394
|
+
);
|
|
395
|
+
placeholder.disable();
|
|
396
|
+
state.tools.set(PLACEHOLDER_TOOL, placeholder);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
for (const entry of this.tools.values()) {
|
|
400
|
+
this.registerTool(state, entry);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
syncSessionTools(state) {
|
|
404
|
+
if (this.tools.size === 0) {
|
|
405
|
+
if (!state.tools.has(PLACEHOLDER_TOOL)) {
|
|
406
|
+
const placeholder = state.server.registerTool(
|
|
407
|
+
PLACEHOLDER_TOOL,
|
|
408
|
+
{
|
|
409
|
+
description: "internal placeholder",
|
|
410
|
+
inputSchema: this.extendToolSchema()
|
|
411
|
+
},
|
|
412
|
+
async () => ({
|
|
413
|
+
content: [
|
|
414
|
+
{
|
|
415
|
+
type: "text",
|
|
416
|
+
text: "No tools available"
|
|
417
|
+
}
|
|
418
|
+
]
|
|
419
|
+
})
|
|
420
|
+
);
|
|
421
|
+
placeholder.disable();
|
|
422
|
+
state.tools.set(PLACEHOLDER_TOOL, placeholder);
|
|
423
|
+
}
|
|
424
|
+
for (const name2 of Array.from(state.tools.keys())) {
|
|
425
|
+
if (name2 !== PLACEHOLDER_TOOL) {
|
|
426
|
+
const registered = state.tools.get(name2);
|
|
427
|
+
registered?.remove?.();
|
|
428
|
+
state.tools.delete(name2);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (state.tools.has(PLACEHOLDER_TOOL)) {
|
|
434
|
+
const placeholder = state.tools.get(PLACEHOLDER_TOOL);
|
|
435
|
+
placeholder?.remove?.();
|
|
436
|
+
state.tools.delete(PLACEHOLDER_TOOL);
|
|
437
|
+
}
|
|
438
|
+
for (const entry of this.tools.values()) {
|
|
439
|
+
if (!state.tools.has(entry.name)) {
|
|
440
|
+
this.registerTool(state, entry);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const registered = state.tools.get(entry.name);
|
|
444
|
+
registered?.update?.({
|
|
445
|
+
description: entry.description,
|
|
446
|
+
paramsSchema: this.extendToolSchema(entry.schema)
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
for (const name2 of Array.from(state.tools.keys())) {
|
|
450
|
+
if (!this.tools.has(name2)) {
|
|
451
|
+
const registered = state.tools.get(name2);
|
|
452
|
+
registered?.remove?.();
|
|
453
|
+
state.tools.delete(name2);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
registerTool(state, entry) {
|
|
458
|
+
const registered = state.server.registerTool(
|
|
459
|
+
entry.name,
|
|
460
|
+
{
|
|
461
|
+
description: entry.description,
|
|
462
|
+
inputSchema: this.extendToolSchema(entry.schema)
|
|
463
|
+
},
|
|
464
|
+
async (argsOrExtra, extraMaybe) => {
|
|
465
|
+
const extra = extraMaybe ?? argsOrExtra;
|
|
466
|
+
const args = extraMaybe == null ? {} : argsOrExtra;
|
|
467
|
+
return await this.handleToolCall(entry.name, args, extra);
|
|
468
|
+
}
|
|
469
|
+
);
|
|
470
|
+
state.tools.set(entry.name, registered);
|
|
471
|
+
}
|
|
472
|
+
async handleToolCall(toolName, args, extra) {
|
|
473
|
+
const entry = this.tools.get(toolName);
|
|
474
|
+
if (!entry) {
|
|
475
|
+
return {
|
|
476
|
+
content: [
|
|
477
|
+
{
|
|
478
|
+
type: "text",
|
|
479
|
+
text: `Tool ${toolName} not available`
|
|
480
|
+
}
|
|
481
|
+
],
|
|
482
|
+
isError: true
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const {
|
|
487
|
+
args: cleanedArgs,
|
|
488
|
+
meta: inputMeta,
|
|
489
|
+
hasMeta: hasInputMeta
|
|
490
|
+
} = this.extractToolInputMeta(args);
|
|
491
|
+
const { payload: extraMeta, hasMeta: hasExtraMeta } = this.extractMeta(extra);
|
|
492
|
+
const meta = this.applyMetaDefaults(
|
|
493
|
+
hasExtraMeta ? extraMeta : hasInputMeta ? inputMeta : {}
|
|
494
|
+
);
|
|
495
|
+
const session = this.buildSession(meta.session);
|
|
496
|
+
const model = await this.getModel();
|
|
497
|
+
if (!model) {
|
|
498
|
+
throw new Error("Model not available");
|
|
499
|
+
}
|
|
500
|
+
const runnableConfig = {
|
|
501
|
+
configurable: {
|
|
502
|
+
model,
|
|
503
|
+
session,
|
|
504
|
+
conversationId: session.guildId ?? session.channelId,
|
|
505
|
+
userId: meta.userId ?? session.userId
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
const result = await entry.tool.invoke(
|
|
509
|
+
cleanedArgs ?? {},
|
|
510
|
+
runnableConfig
|
|
511
|
+
);
|
|
512
|
+
return {
|
|
513
|
+
content: [
|
|
514
|
+
{
|
|
515
|
+
type: "text",
|
|
516
|
+
text: formatToolOutput(result)
|
|
517
|
+
}
|
|
518
|
+
]
|
|
519
|
+
};
|
|
520
|
+
} catch (error) {
|
|
521
|
+
return {
|
|
522
|
+
content: [
|
|
523
|
+
{
|
|
524
|
+
type: "text",
|
|
525
|
+
text: error instanceof Error ? error.message : String(error)
|
|
526
|
+
}
|
|
527
|
+
],
|
|
528
|
+
isError: true
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
extractMeta(extra) {
|
|
533
|
+
const extraObj = extra;
|
|
534
|
+
const metaRoot = extraObj?._meta ?? null;
|
|
535
|
+
const headers = extraObj?.requestInfo?.headers ?? {};
|
|
536
|
+
const headerSession = parseJsonPayload(getHeaderValue(headers, "x-chatluna-session")) ?? parseJsonPayload(getHeaderValue(headers, "x-koishi-session"));
|
|
537
|
+
return this.extractMetaFromRoot(metaRoot, headerSession);
|
|
538
|
+
}
|
|
539
|
+
extractMetaFromRoot(metaRoot, headerSession) {
|
|
540
|
+
const payload = {};
|
|
541
|
+
const meta = this.normalizeMetaRoot(metaRoot);
|
|
542
|
+
const sessionMeta = parseJsonPayload(meta.session);
|
|
543
|
+
const session = sessionMeta ?? headerSession;
|
|
544
|
+
if (session) {
|
|
545
|
+
payload.session = session;
|
|
546
|
+
}
|
|
547
|
+
if (typeof meta.userId === "string") {
|
|
548
|
+
payload.userId = meta.userId;
|
|
549
|
+
}
|
|
550
|
+
const hasMeta = Object.keys(payload).length > 0;
|
|
551
|
+
return { payload, hasMeta };
|
|
552
|
+
}
|
|
553
|
+
normalizeMetaRoot(metaRoot) {
|
|
554
|
+
if (!metaRoot) {
|
|
555
|
+
return {};
|
|
556
|
+
}
|
|
557
|
+
return metaRoot;
|
|
558
|
+
}
|
|
559
|
+
extractToolInputMeta(args) {
|
|
560
|
+
if (!args || typeof args !== "object") {
|
|
561
|
+
return { args: {}, meta: {}, hasMeta: false };
|
|
562
|
+
}
|
|
563
|
+
if (!Object.prototype.hasOwnProperty.call(args, TOOL_META_FIELD)) {
|
|
564
|
+
return { args, meta: {}, hasMeta: false };
|
|
565
|
+
}
|
|
566
|
+
const cleanedArgs = { ...args };
|
|
567
|
+
const metaContainer = cleanedArgs[TOOL_META_FIELD];
|
|
568
|
+
delete cleanedArgs[TOOL_META_FIELD];
|
|
569
|
+
const metaRoot = parseJsonPayload(metaContainer);
|
|
570
|
+
const { payload, hasMeta } = this.extractMetaFromRoot(metaRoot, null);
|
|
571
|
+
return { args: cleanedArgs, meta: payload, hasMeta };
|
|
572
|
+
}
|
|
573
|
+
applyMetaDefaults(meta) {
|
|
574
|
+
const payload = { ...meta };
|
|
575
|
+
if (!payload.session) {
|
|
576
|
+
payload.session = this.buildDefaultSessionPayload();
|
|
577
|
+
return payload;
|
|
578
|
+
}
|
|
579
|
+
if (typeof payload.session === "string") {
|
|
580
|
+
const parsed = parseJsonPayload(payload.session);
|
|
581
|
+
if (parsed && Object.keys(parsed).length > 0) {
|
|
582
|
+
payload.session = parsed;
|
|
583
|
+
} else {
|
|
584
|
+
payload.session = this.buildDefaultSessionPayload();
|
|
585
|
+
}
|
|
586
|
+
return payload;
|
|
587
|
+
}
|
|
588
|
+
if (Object.keys(payload.session).length === 0) {
|
|
589
|
+
payload.session = this.buildDefaultSessionPayload();
|
|
590
|
+
}
|
|
591
|
+
return payload;
|
|
592
|
+
}
|
|
593
|
+
buildSession(sessionPayload) {
|
|
594
|
+
const payload = parseJsonPayload(sessionPayload) ?? sessionPayload ?? this.buildDefaultSessionPayload();
|
|
595
|
+
const bot = this.resolveBot(payload);
|
|
596
|
+
if (!bot) {
|
|
597
|
+
throw new Error("No bot available for MCP session");
|
|
598
|
+
}
|
|
599
|
+
const event = {
|
|
600
|
+
type: "message"
|
|
601
|
+
};
|
|
602
|
+
if (payload.userId) {
|
|
603
|
+
event.user = { id: payload.userId };
|
|
604
|
+
}
|
|
605
|
+
if (payload.channelId) {
|
|
606
|
+
event.channel = { id: payload.channelId, type: 0 };
|
|
607
|
+
}
|
|
608
|
+
if (payload.guildId) {
|
|
609
|
+
event.guild = { id: payload.guildId };
|
|
610
|
+
}
|
|
611
|
+
const session = bot.session(event);
|
|
612
|
+
if (payload.message) {
|
|
613
|
+
session.content = payload.message;
|
|
614
|
+
}
|
|
615
|
+
return session;
|
|
616
|
+
}
|
|
617
|
+
buildDefaultSessionPayload() {
|
|
618
|
+
const defaults = this.config.session ?? {
|
|
619
|
+
defaultBot: "",
|
|
620
|
+
userId: "",
|
|
621
|
+
channelId: "",
|
|
622
|
+
guildId: "",
|
|
623
|
+
message: ""
|
|
624
|
+
};
|
|
625
|
+
const botTarget = this.parseBotTarget(defaults.defaultBot);
|
|
626
|
+
return {
|
|
627
|
+
platform: botTarget?.platform,
|
|
628
|
+
selfId: botTarget?.selfId,
|
|
629
|
+
userId: defaults.userId || void 0,
|
|
630
|
+
channelId: defaults.channelId || void 0,
|
|
631
|
+
guildId: defaults.guildId || void 0,
|
|
632
|
+
message: defaults.message || void 0
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
resolveBot(payload) {
|
|
636
|
+
const fallback = this.parseBotTarget(this.config.session?.defaultBot);
|
|
637
|
+
const targetPlatform = payload.platform ?? fallback?.platform;
|
|
638
|
+
const targetSelfId = payload.selfId ?? fallback?.selfId;
|
|
639
|
+
const bots = this.getBotList();
|
|
640
|
+
let bot = bots.find(
|
|
641
|
+
(candidate) => (!targetPlatform || candidate.platform === targetPlatform) && (!targetSelfId || candidate.selfId === targetSelfId)
|
|
642
|
+
);
|
|
643
|
+
if (!bot && targetPlatform) {
|
|
644
|
+
bot = bots.find(
|
|
645
|
+
(candidate) => candidate.platform === targetPlatform
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return bot ?? bots[0];
|
|
649
|
+
}
|
|
650
|
+
getBotList() {
|
|
651
|
+
if (Array.isArray(this.ctx.bots)) {
|
|
652
|
+
return this.ctx.bots;
|
|
653
|
+
}
|
|
654
|
+
return Object.values(this.ctx.bots);
|
|
655
|
+
}
|
|
656
|
+
parseBotTarget(value) {
|
|
657
|
+
if (!value) {
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
const trimmed = value.trim();
|
|
661
|
+
if (!trimmed) {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
const parts = trimmed.split(":");
|
|
665
|
+
if (parts.length >= 2) {
|
|
666
|
+
return {
|
|
667
|
+
platform: parts[0],
|
|
668
|
+
selfId: parts.slice(1).join(":")
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
return { selfId: trimmed };
|
|
672
|
+
}
|
|
673
|
+
resolveModelName(override) {
|
|
674
|
+
if (override && override !== "无") {
|
|
675
|
+
return override;
|
|
676
|
+
}
|
|
677
|
+
if (this.config.defaultModel && this.config.defaultModel !== "无") {
|
|
678
|
+
return this.config.defaultModel;
|
|
679
|
+
}
|
|
680
|
+
if (this.ctx.chatluna.config.defaultModel && this.ctx.chatluna.config.defaultModel !== "无") {
|
|
681
|
+
return this.ctx.chatluna.config.defaultModel;
|
|
682
|
+
}
|
|
683
|
+
return void 0;
|
|
684
|
+
}
|
|
685
|
+
resolveEmbeddingsName() {
|
|
686
|
+
if (this.config.defaultEmbeddings && this.config.defaultEmbeddings !== "无") {
|
|
687
|
+
return this.config.defaultEmbeddings;
|
|
688
|
+
}
|
|
689
|
+
if (this.ctx.chatluna.config.defaultEmbeddings && this.ctx.chatluna.config.defaultEmbeddings !== "无") {
|
|
690
|
+
return this.ctx.chatluna.config.defaultEmbeddings;
|
|
691
|
+
}
|
|
692
|
+
return void 0;
|
|
693
|
+
}
|
|
694
|
+
async getModel(override) {
|
|
695
|
+
const modelName = this.resolveModelName(override);
|
|
696
|
+
if (!modelName) {
|
|
697
|
+
return void 0;
|
|
698
|
+
}
|
|
699
|
+
const cached = this.modelCache.get(modelName);
|
|
700
|
+
if (cached) {
|
|
701
|
+
return cached.value;
|
|
702
|
+
}
|
|
703
|
+
const modelRef = await this.ctx.chatluna.createChatModel(modelName);
|
|
704
|
+
this.modelCache.set(modelName, modelRef);
|
|
705
|
+
return modelRef.value;
|
|
706
|
+
}
|
|
707
|
+
async getEmbeddings() {
|
|
708
|
+
const embeddingsName = this.resolveEmbeddingsName();
|
|
709
|
+
if (!embeddingsName) {
|
|
710
|
+
return emptyEmbeddings;
|
|
711
|
+
}
|
|
712
|
+
const cached = this.embeddingsCache.get(embeddingsName);
|
|
713
|
+
if (cached) {
|
|
714
|
+
return cached.value ?? emptyEmbeddings;
|
|
715
|
+
}
|
|
716
|
+
const embeddingsRef = await this.ctx.chatluna.createEmbeddings(embeddingsName);
|
|
717
|
+
this.embeddingsCache.set(embeddingsName, embeddingsRef);
|
|
718
|
+
return embeddingsRef.value ?? emptyEmbeddings;
|
|
719
|
+
}
|
|
720
|
+
extendToolSchema(schema) {
|
|
721
|
+
const sessionSchema = z.object({
|
|
722
|
+
platform: z.string().optional().describe('Bot platform name, e.g. "discord".'),
|
|
723
|
+
selfId: z.string().optional().describe("Bot self ID on the target platform."),
|
|
724
|
+
userId: z.string().optional().describe("User ID for the tool call session."),
|
|
725
|
+
channelId: z.string().optional().describe("Channel ID for the tool call session."),
|
|
726
|
+
guildId: z.string().optional().describe("Guild ID for the tool call session."),
|
|
727
|
+
message: z.string().optional().describe("Message content injected into the session.")
|
|
728
|
+
}).strict().describe("Structured session data for the tool call.");
|
|
729
|
+
const metaSchema = z.object({
|
|
730
|
+
session: sessionSchema.describe("Session payload."),
|
|
731
|
+
userId: z.string().optional().describe("User ID override for tool runtime.")
|
|
732
|
+
}).strict().optional().describe(
|
|
733
|
+
"Meta payload for ChatLuna tool execution. (If needed the userId/guildId)"
|
|
734
|
+
);
|
|
735
|
+
if (!schema) {
|
|
736
|
+
return z.object({ [TOOL_META_FIELD]: metaSchema });
|
|
737
|
+
}
|
|
738
|
+
if (!isZodSchemaV3(schema)) {
|
|
739
|
+
return schema;
|
|
740
|
+
}
|
|
741
|
+
const typeName = schema._def?.typeName;
|
|
742
|
+
if (typeName !== "ZodObject") {
|
|
743
|
+
return schema;
|
|
744
|
+
}
|
|
745
|
+
const objectSchema = schema;
|
|
746
|
+
const shape = typeof objectSchema._def.shape === "function" ? objectSchema._def.shape() : objectSchema._def.shape;
|
|
747
|
+
const mergedShape = {
|
|
748
|
+
...shape,
|
|
749
|
+
[TOOL_META_FIELD]: metaSchema
|
|
750
|
+
};
|
|
751
|
+
let merged = z.object(mergedShape);
|
|
752
|
+
const unknownKeys = objectSchema._def.unknownKeys;
|
|
753
|
+
if (unknownKeys === "passthrough") {
|
|
754
|
+
merged = merged.passthrough();
|
|
755
|
+
} else if (unknownKeys === "strict") {
|
|
756
|
+
merged = merged.strict();
|
|
757
|
+
}
|
|
758
|
+
const catchall = objectSchema._def.catchall;
|
|
759
|
+
if (catchall) {
|
|
760
|
+
merged = merged.catchall(catchall);
|
|
761
|
+
}
|
|
762
|
+
return merged;
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
// src/config.ts
|
|
767
|
+
import { createHash } from "crypto";
|
|
768
|
+
import { Schema as Schema2 } from "koishi";
|
|
769
|
+
var DEFAULT_AUTH_KEY = (() => {
|
|
770
|
+
const seed = `${process.cwd()}|chatluna-mcp-server`;
|
|
771
|
+
const hash = createHash("sha256").update(seed).digest("hex");
|
|
772
|
+
const value = BigInt(`0x${hash}`) % BigInt(36 ** 8);
|
|
773
|
+
return value.toString(36).padStart(8, "0").toUpperCase();
|
|
774
|
+
})();
|
|
775
|
+
var Config = Schema2.intersect([
|
|
776
|
+
Schema2.object({
|
|
777
|
+
endpointPath: Schema2.string().description("MCP 服务端路径(例如 /mcp 或 /mcp-foo)").default("/mcp"),
|
|
778
|
+
auth: Schema2.object({
|
|
779
|
+
enabled: Schema2.boolean().description("是否启用 URL 鉴权(/mcp?key=xxxxxx)").default(true),
|
|
780
|
+
key: Schema2.string().description("URL 鉴权密钥(默认固定的八位字母数字)").default(DEFAULT_AUTH_KEY)
|
|
781
|
+
})
|
|
782
|
+
}).description("服务端配置"),
|
|
783
|
+
Schema2.object({
|
|
784
|
+
defaultModel: Schema2.dynamic("model").description("默认使用的模型(选“无”则使用 ChatLuna 默认模型)").default("无"),
|
|
785
|
+
defaultEmbeddings: Schema2.dynamic("embeddings").description("默认使用的向量模型(选“无”则使用 ChatLuna 默认值)").default("无"),
|
|
786
|
+
toolBlacklist: Schema2.array(Schema2.dynamic("chatluna-tool")).description("不公开到 MCP 的工具").default([
|
|
787
|
+
"built_question",
|
|
788
|
+
"built_user_confirm",
|
|
789
|
+
"built_user_toast",
|
|
790
|
+
"file_read",
|
|
791
|
+
"file_write",
|
|
792
|
+
"file_list",
|
|
793
|
+
"file_grep",
|
|
794
|
+
"file_glob",
|
|
795
|
+
"file_rename",
|
|
796
|
+
"file_multi_rename",
|
|
797
|
+
"file_multi_write",
|
|
798
|
+
"file_update",
|
|
799
|
+
"web_fetcher",
|
|
800
|
+
"web_poster"
|
|
801
|
+
])
|
|
802
|
+
}).description("工具配置"),
|
|
803
|
+
Schema2.object({
|
|
804
|
+
session: Schema2.object({
|
|
805
|
+
defaultBot: Schema2.string().description("默认 Bot(格式 platform:selfId)").default(""),
|
|
806
|
+
userId: Schema2.string().description("默认用户 ID").default(""),
|
|
807
|
+
channelId: Schema2.string().description("默认频道 ID").default(""),
|
|
808
|
+
guildId: Schema2.string().description("默认群组 ID").default(""),
|
|
809
|
+
message: Schema2.string().description("默认消息内容").default("")
|
|
810
|
+
})
|
|
811
|
+
}).description("会话配置")
|
|
812
|
+
]);
|
|
813
|
+
var name = "chatluna-mcp-server";
|
|
814
|
+
|
|
815
|
+
// src/index.ts
|
|
816
|
+
var logger;
|
|
817
|
+
function apply2(ctx, config) {
|
|
818
|
+
logger = createLogger(ctx, "chatluna-mcp-server");
|
|
819
|
+
ctx.inject(["chatluna"], async (ctx2) => {
|
|
820
|
+
await plugins(ctx2, config);
|
|
821
|
+
});
|
|
822
|
+
ctx.inject(["chatluna", "server"], async (ctx2) => {
|
|
823
|
+
const bridge = new McpServerBridge(ctx2, config, logger);
|
|
824
|
+
await bridge.init();
|
|
825
|
+
ctx2.effect(() => () => bridge.dispose());
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
__name(apply2, "apply");
|
|
829
|
+
var inject = {
|
|
830
|
+
required: ["chatluna", "server"]
|
|
831
|
+
};
|
|
832
|
+
export {
|
|
833
|
+
Config,
|
|
834
|
+
apply2 as apply,
|
|
835
|
+
inject,
|
|
836
|
+
logger,
|
|
837
|
+
name
|
|
838
|
+
};
|