@cubis/foundry 0.3.49 → 0.3.50
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/bin/cubis.js +470 -107
- package/mcp/Dockerfile +1 -0
- package/mcp/dist/index.js +277 -49
- package/mcp/src/index.ts +37 -15
- package/mcp/src/server.ts +17 -12
- package/mcp/src/telemetry/tokenBudget.ts +8 -2
- package/mcp/src/tools/skillBrowseCategory.ts +6 -1
- package/mcp/src/tools/skillBudgetReport.ts +18 -5
- package/mcp/src/tools/skillGet.ts +12 -0
- package/mcp/src/tools/skillListCategories.ts +4 -0
- package/mcp/src/tools/skillSearch.ts +6 -1
- package/mcp/src/transports/streamableHttp.ts +247 -4
- package/mcp/src/utils/logger.ts +5 -1
- package/mcp/src/vault/manifest.ts +22 -5
- package/mcp/src/vault/scanner.ts +16 -3
- package/package.json +1 -1
|
@@ -67,6 +67,7 @@ export interface SkillToolMetrics {
|
|
|
67
67
|
charsPerToken: number;
|
|
68
68
|
fullCatalogEstimatedTokens: number;
|
|
69
69
|
responseEstimatedTokens: number;
|
|
70
|
+
responseCharacterCount: number;
|
|
70
71
|
selectedSkillsEstimatedTokens: number | null;
|
|
71
72
|
loadedSkillEstimatedTokens: number | null;
|
|
72
73
|
estimatedSavingsVsFullCatalog: number;
|
|
@@ -80,24 +81,30 @@ export function buildSkillToolMetrics({
|
|
|
80
81
|
responseEstimatedTokens,
|
|
81
82
|
selectedSkillsEstimatedTokens = null,
|
|
82
83
|
loadedSkillEstimatedTokens = null,
|
|
84
|
+
responseCharacterCount = 0,
|
|
83
85
|
}: {
|
|
84
86
|
charsPerToken: number;
|
|
85
87
|
fullCatalogEstimatedTokens: number;
|
|
86
88
|
responseEstimatedTokens: number;
|
|
87
89
|
selectedSkillsEstimatedTokens?: number | null;
|
|
88
90
|
loadedSkillEstimatedTokens?: number | null;
|
|
91
|
+
responseCharacterCount?: number;
|
|
89
92
|
}): SkillToolMetrics {
|
|
90
93
|
const usedEstimatedTokens =
|
|
91
94
|
loadedSkillEstimatedTokens ??
|
|
92
95
|
selectedSkillsEstimatedTokens ??
|
|
93
96
|
responseEstimatedTokens;
|
|
94
|
-
const savings = estimateSavings(
|
|
97
|
+
const savings = estimateSavings(
|
|
98
|
+
fullCatalogEstimatedTokens,
|
|
99
|
+
usedEstimatedTokens,
|
|
100
|
+
);
|
|
95
101
|
|
|
96
102
|
return {
|
|
97
103
|
estimatorVersion: TOKEN_ESTIMATOR_VERSION,
|
|
98
104
|
charsPerToken: normalizeCharsPerToken(charsPerToken),
|
|
99
105
|
fullCatalogEstimatedTokens: Math.max(0, fullCatalogEstimatedTokens),
|
|
100
106
|
responseEstimatedTokens: Math.max(0, responseEstimatedTokens),
|
|
107
|
+
responseCharacterCount: Math.max(0, responseCharacterCount),
|
|
101
108
|
selectedSkillsEstimatedTokens:
|
|
102
109
|
selectedSkillsEstimatedTokens === null
|
|
103
110
|
? null
|
|
@@ -111,4 +118,3 @@ export function buildSkillToolMetrics({
|
|
|
111
118
|
estimated: true,
|
|
112
119
|
};
|
|
113
120
|
}
|
|
114
|
-
|
|
@@ -47,7 +47,8 @@ export async function handleSkillBrowseCategory(
|
|
|
47
47
|
const payload = { category, skills, count: skills.length };
|
|
48
48
|
const text = JSON.stringify(payload, null, 2);
|
|
49
49
|
const selectedSkillsEstimatedTokens = matching.reduce(
|
|
50
|
-
(sum, skill) =>
|
|
50
|
+
(sum, skill) =>
|
|
51
|
+
sum + estimateTokensFromBytes(skill.fileBytes, charsPerToken),
|
|
51
52
|
0,
|
|
52
53
|
);
|
|
53
54
|
const metrics = buildSkillToolMetrics({
|
|
@@ -55,6 +56,7 @@ export async function handleSkillBrowseCategory(
|
|
|
55
56
|
fullCatalogEstimatedTokens: manifest.fullCatalogEstimatedTokens,
|
|
56
57
|
responseEstimatedTokens: estimateTokensFromText(text, charsPerToken),
|
|
57
58
|
selectedSkillsEstimatedTokens,
|
|
59
|
+
responseCharacterCount: text.length,
|
|
58
60
|
});
|
|
59
61
|
|
|
60
62
|
return {
|
|
@@ -67,5 +69,8 @@ export async function handleSkillBrowseCategory(
|
|
|
67
69
|
structuredContent: {
|
|
68
70
|
metrics,
|
|
69
71
|
},
|
|
72
|
+
_meta: {
|
|
73
|
+
metrics,
|
|
74
|
+
},
|
|
70
75
|
};
|
|
71
76
|
}
|
|
@@ -49,7 +49,10 @@ export function handleSkillBudgetReport(
|
|
|
49
49
|
return {
|
|
50
50
|
id: skill.id,
|
|
51
51
|
category: skill.category,
|
|
52
|
-
estimatedTokens: estimateTokensFromBytes(
|
|
52
|
+
estimatedTokens: estimateTokensFromBytes(
|
|
53
|
+
skill.fileBytes,
|
|
54
|
+
charsPerToken,
|
|
55
|
+
),
|
|
53
56
|
};
|
|
54
57
|
})
|
|
55
58
|
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
|
@@ -61,7 +64,10 @@ export function handleSkillBudgetReport(
|
|
|
61
64
|
return {
|
|
62
65
|
id: skill.id,
|
|
63
66
|
category: skill.category,
|
|
64
|
-
estimatedTokens: estimateTokensFromBytes(
|
|
67
|
+
estimatedTokens: estimateTokensFromBytes(
|
|
68
|
+
skill.fileBytes,
|
|
69
|
+
charsPerToken,
|
|
70
|
+
),
|
|
65
71
|
};
|
|
66
72
|
})
|
|
67
73
|
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
|
@@ -69,7 +75,9 @@ export function handleSkillBudgetReport(
|
|
|
69
75
|
const unknownSelectedSkillIds = selectedSkillIds.filter(
|
|
70
76
|
(id) => !skillById.has(id),
|
|
71
77
|
);
|
|
72
|
-
const unknownLoadedSkillIds = loadedSkillIds.filter(
|
|
78
|
+
const unknownLoadedSkillIds = loadedSkillIds.filter(
|
|
79
|
+
(id) => !skillById.has(id),
|
|
80
|
+
);
|
|
73
81
|
|
|
74
82
|
const selectedSkillsEstimatedTokens = selectedSkills.reduce(
|
|
75
83
|
(sum, skill) => sum + skill.estimatedTokens,
|
|
@@ -92,7 +100,9 @@ export function handleSkillBudgetReport(
|
|
|
92
100
|
const selectedIdSet = new Set(selectedSkills.map((skill) => skill.id));
|
|
93
101
|
const loadedIdSet = new Set(loadedSkills.map((skill) => skill.id));
|
|
94
102
|
const skippedSkills = manifest.skills
|
|
95
|
-
.filter(
|
|
103
|
+
.filter(
|
|
104
|
+
(skill) => !selectedIdSet.has(skill.id) && !loadedIdSet.has(skill.id),
|
|
105
|
+
)
|
|
96
106
|
.map((skill) => skill.id)
|
|
97
107
|
.sort((a, b) => a.localeCompare(b));
|
|
98
108
|
|
|
@@ -116,13 +126,16 @@ export function handleSkillBudgetReport(
|
|
|
116
126
|
},
|
|
117
127
|
};
|
|
118
128
|
|
|
129
|
+
const text = JSON.stringify(payload, null, 2);
|
|
130
|
+
|
|
119
131
|
return {
|
|
120
132
|
content: [
|
|
121
133
|
{
|
|
122
134
|
type: "text" as const,
|
|
123
|
-
text
|
|
135
|
+
text,
|
|
124
136
|
},
|
|
125
137
|
],
|
|
126
138
|
structuredContent: payload,
|
|
139
|
+
_meta: payload,
|
|
127
140
|
};
|
|
128
141
|
}
|
|
@@ -66,6 +66,13 @@ export async function handleSkillGet(
|
|
|
66
66
|
].join("\n")
|
|
67
67
|
: "";
|
|
68
68
|
const content = `${skillContent}${referenceSection}`;
|
|
69
|
+
|
|
70
|
+
if (content.trim().length === 0) {
|
|
71
|
+
invalidInput(
|
|
72
|
+
`Skill "${id}" has empty content (SKILL.md is empty or whitespace-only). This skill may be corrupt or incomplete.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
69
76
|
const loadedSkillEstimatedTokens = estimateTokensFromText(
|
|
70
77
|
content,
|
|
71
78
|
charsPerToken,
|
|
@@ -75,6 +82,7 @@ export async function handleSkillGet(
|
|
|
75
82
|
fullCatalogEstimatedTokens: manifest.fullCatalogEstimatedTokens,
|
|
76
83
|
responseEstimatedTokens: loadedSkillEstimatedTokens,
|
|
77
84
|
loadedSkillEstimatedTokens,
|
|
85
|
+
responseCharacterCount: content.length,
|
|
78
86
|
});
|
|
79
87
|
|
|
80
88
|
return {
|
|
@@ -88,5 +96,9 @@ export async function handleSkillGet(
|
|
|
88
96
|
references: references.map((ref) => ({ path: ref.relativePath })),
|
|
89
97
|
metrics,
|
|
90
98
|
},
|
|
99
|
+
_meta: {
|
|
100
|
+
references: references.map((ref) => ({ path: ref.relativePath })),
|
|
101
|
+
metrics,
|
|
102
|
+
},
|
|
91
103
|
};
|
|
92
104
|
}
|
|
@@ -37,6 +37,7 @@ export function handleSkillListCategories(
|
|
|
37
37
|
charsPerToken,
|
|
38
38
|
fullCatalogEstimatedTokens: manifest.fullCatalogEstimatedTokens,
|
|
39
39
|
responseEstimatedTokens: estimateTokensFromText(text, charsPerToken),
|
|
40
|
+
responseCharacterCount: text.length,
|
|
40
41
|
});
|
|
41
42
|
|
|
42
43
|
return {
|
|
@@ -49,5 +50,8 @@ export function handleSkillListCategories(
|
|
|
49
50
|
structuredContent: {
|
|
50
51
|
metrics,
|
|
51
52
|
},
|
|
53
|
+
_meta: {
|
|
54
|
+
metrics,
|
|
55
|
+
},
|
|
52
56
|
};
|
|
53
57
|
}
|
|
@@ -65,7 +65,8 @@ export async function handleSkillSearch(
|
|
|
65
65
|
const payload = { query, results, count: results.length };
|
|
66
66
|
const text = JSON.stringify(payload, null, 2);
|
|
67
67
|
const selectedSkillsEstimatedTokens = matches.reduce(
|
|
68
|
-
(sum, skill) =>
|
|
68
|
+
(sum, skill) =>
|
|
69
|
+
sum + estimateTokensFromBytes(skill.fileBytes, charsPerToken),
|
|
69
70
|
0,
|
|
70
71
|
);
|
|
71
72
|
const metrics = buildSkillToolMetrics({
|
|
@@ -73,6 +74,7 @@ export async function handleSkillSearch(
|
|
|
73
74
|
fullCatalogEstimatedTokens: manifest.fullCatalogEstimatedTokens,
|
|
74
75
|
responseEstimatedTokens: estimateTokensFromText(text, charsPerToken),
|
|
75
76
|
selectedSkillsEstimatedTokens,
|
|
77
|
+
responseCharacterCount: text.length,
|
|
76
78
|
});
|
|
77
79
|
|
|
78
80
|
return {
|
|
@@ -85,5 +87,8 @@ export async function handleSkillSearch(
|
|
|
85
87
|
structuredContent: {
|
|
86
88
|
metrics,
|
|
87
89
|
},
|
|
90
|
+
_meta: {
|
|
91
|
+
metrics,
|
|
92
|
+
},
|
|
88
93
|
};
|
|
89
94
|
}
|
|
@@ -1,19 +1,263 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cubis Foundry MCP Server – Streamable HTTP transport adapter.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Multi-session architecture: each initialize handshake creates a new
|
|
5
|
+
* StreamableHTTPServerTransport + McpServer pair. Subsequent requests with
|
|
6
|
+
* the returned `mcp-session-id` header are routed to the correct session.
|
|
7
|
+
* This eliminates the "Server already initialized" error when smoke tests
|
|
8
|
+
* or multiple clients hit the same container without restarting.
|
|
9
|
+
*
|
|
10
|
+
* Idle sessions are cleaned up after SESSION_TTL_MS (30 min default).
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
createServer,
|
|
15
|
+
type Server,
|
|
16
|
+
type IncomingMessage,
|
|
17
|
+
type ServerResponse,
|
|
18
|
+
} from "node:http";
|
|
9
19
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
20
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
21
|
import { logger } from "../utils/logger.js";
|
|
11
22
|
|
|
23
|
+
/** Default session idle TTL in ms (30 minutes). */
|
|
24
|
+
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
/** Cleanup sweep interval in ms (5 minutes). */
|
|
27
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
28
|
+
|
|
12
29
|
export interface HttpTransportOptions {
|
|
13
30
|
port: number;
|
|
14
31
|
host: string;
|
|
15
32
|
}
|
|
16
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Callback invoked to create a new McpServer, register tools, and connect
|
|
36
|
+
* it to the provided transport. Called once per session.
|
|
37
|
+
*/
|
|
38
|
+
export type McpServerFactory = (
|
|
39
|
+
transport: StreamableHTTPServerTransport,
|
|
40
|
+
) => Promise<McpServer>;
|
|
41
|
+
|
|
42
|
+
interface SessionEntry {
|
|
43
|
+
transport: StreamableHTTPServerTransport;
|
|
44
|
+
server: McpServer;
|
|
45
|
+
lastActivity: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MultiSessionHttpServer {
|
|
49
|
+
httpServer: Server;
|
|
50
|
+
/** Gracefully close all sessions and the HTTP server. */
|
|
51
|
+
closeAll(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read the raw body from an IncomingMessage so we can inspect it before
|
|
56
|
+
* handing it to the SDK transport.
|
|
57
|
+
*/
|
|
58
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const chunks: Buffer[] = [];
|
|
61
|
+
req.on("data", (c: Buffer) => chunks.push(c));
|
|
62
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
63
|
+
req.on("error", reject);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createMultiSessionHttpServer(
|
|
68
|
+
options: HttpTransportOptions,
|
|
69
|
+
serverFactory: McpServerFactory,
|
|
70
|
+
): MultiSessionHttpServer {
|
|
71
|
+
const sessions = new Map<string, SessionEntry>();
|
|
72
|
+
|
|
73
|
+
// ── Periodic cleanup of idle sessions ───────────────────
|
|
74
|
+
const cleanupTimer = setInterval(() => {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
for (const [id, entry] of sessions) {
|
|
77
|
+
if (now - entry.lastActivity > SESSION_TTL_MS) {
|
|
78
|
+
logger.info(
|
|
79
|
+
`Session ${id.slice(0, 8)} expired after idle (active: ${sessions.size - 1})`,
|
|
80
|
+
);
|
|
81
|
+
entry.transport.close().catch(() => {});
|
|
82
|
+
entry.server.close().catch(() => {});
|
|
83
|
+
sessions.delete(id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}, CLEANUP_INTERVAL_MS);
|
|
87
|
+
cleanupTimer.unref(); // Don't prevent process exit
|
|
88
|
+
|
|
89
|
+
// ── HTTP request handler ────────────────────────────────
|
|
90
|
+
const httpServer = createServer(
|
|
91
|
+
async (req: IncomingMessage, res: ServerResponse) => {
|
|
92
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
93
|
+
if (url.pathname !== "/mcp") {
|
|
94
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
95
|
+
res.end("Not Found");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// DELETE = session termination (per MCP spec)
|
|
100
|
+
if (req.method === "DELETE") {
|
|
101
|
+
const sid = req.headers["mcp-session-id"] as string | undefined;
|
|
102
|
+
if (sid && sessions.has(sid)) {
|
|
103
|
+
const entry = sessions.get(sid)!;
|
|
104
|
+
await entry.transport.close().catch(() => {});
|
|
105
|
+
await entry.server.close().catch(() => {});
|
|
106
|
+
sessions.delete(sid);
|
|
107
|
+
logger.info(
|
|
108
|
+
`Session ${sid.slice(0, 8)} terminated (active: ${sessions.size})`,
|
|
109
|
+
);
|
|
110
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
111
|
+
res.end("Session closed");
|
|
112
|
+
} else {
|
|
113
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
114
|
+
res.end("Session not found");
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// GET = SSE stream for an existing session
|
|
120
|
+
if (req.method === "GET") {
|
|
121
|
+
const sid = req.headers["mcp-session-id"] as string | undefined;
|
|
122
|
+
if (sid && sessions.has(sid)) {
|
|
123
|
+
const entry = sessions.get(sid)!;
|
|
124
|
+
entry.lastActivity = Date.now();
|
|
125
|
+
await entry.transport.handleRequest(req, res);
|
|
126
|
+
} else {
|
|
127
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
128
|
+
res.end("Missing or invalid mcp-session-id");
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// POST = either initialize (new session) or call (existing session)
|
|
134
|
+
if (req.method === "POST") {
|
|
135
|
+
const sid = req.headers["mcp-session-id"] as string | undefined;
|
|
136
|
+
|
|
137
|
+
// Existing session — route to its transport
|
|
138
|
+
if (sid && sessions.has(sid)) {
|
|
139
|
+
const entry = sessions.get(sid)!;
|
|
140
|
+
entry.lastActivity = Date.now();
|
|
141
|
+
await entry.transport.handleRequest(req, res);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Peek at the body to determine if this is an initialize request
|
|
146
|
+
const rawBody = await readBody(req);
|
|
147
|
+
let parsed: unknown;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(rawBody);
|
|
150
|
+
} catch {
|
|
151
|
+
logger.warn(`Bad JSON in POST from ${req.socket.remoteAddress}`);
|
|
152
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
153
|
+
res.end(
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
jsonrpc: "2.0",
|
|
156
|
+
error: { code: -32700, message: "Parse error" },
|
|
157
|
+
id: null,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const isInit =
|
|
164
|
+
parsed &&
|
|
165
|
+
typeof parsed === "object" &&
|
|
166
|
+
"method" in parsed &&
|
|
167
|
+
(parsed as Record<string, unknown>).method === "initialize";
|
|
168
|
+
|
|
169
|
+
if (!isInit) {
|
|
170
|
+
// Non-init request without valid session
|
|
171
|
+
logger.warn(
|
|
172
|
+
`POST without session: method=${(parsed as Record<string, unknown>)?.method ?? "unknown"}`,
|
|
173
|
+
);
|
|
174
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
175
|
+
res.end(
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
jsonrpc: "2.0",
|
|
178
|
+
error: {
|
|
179
|
+
code: -32600,
|
|
180
|
+
message: "Invalid Request: missing or unknown mcp-session-id",
|
|
181
|
+
},
|
|
182
|
+
id: (parsed as Record<string, unknown>)?.id ?? null,
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── New session: create transport + server ──────────
|
|
189
|
+
const transport = new StreamableHTTPServerTransport({
|
|
190
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const server = await serverFactory(transport);
|
|
195
|
+
|
|
196
|
+
// Re-inject the pre-read body so the transport can process it.
|
|
197
|
+
// The SDK's StreamableHTTPServerTransport accepts a parsedBody
|
|
198
|
+
// parameter to avoid re-reading the stream.
|
|
199
|
+
await transport.handleRequest(req, res, parsed);
|
|
200
|
+
|
|
201
|
+
// After handleRequest, the transport.sessionId is set by the SDK.
|
|
202
|
+
const sessionId = transport.sessionId;
|
|
203
|
+
if (sessionId) {
|
|
204
|
+
sessions.set(sessionId, {
|
|
205
|
+
transport,
|
|
206
|
+
server,
|
|
207
|
+
lastActivity: Date.now(),
|
|
208
|
+
});
|
|
209
|
+
logger.info(
|
|
210
|
+
`New session ${sessionId.slice(0, 8)} (active: ${sessions.size})`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
logger.error(`Failed to create MCP session: ${error}`);
|
|
215
|
+
if (!res.headersSent) {
|
|
216
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
217
|
+
res.end(
|
|
218
|
+
JSON.stringify({
|
|
219
|
+
jsonrpc: "2.0",
|
|
220
|
+
error: {
|
|
221
|
+
code: -32603,
|
|
222
|
+
message: "Internal error creating session",
|
|
223
|
+
},
|
|
224
|
+
id: (parsed as Record<string, unknown>)?.id ?? null,
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Unsupported method
|
|
233
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
234
|
+
res.end("Method Not Allowed");
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
httpServer.listen(options.port, options.host, () => {
|
|
239
|
+
logger.info(
|
|
240
|
+
`Streamable HTTP transport listening on http://${options.host}:${options.port}/mcp (multi-session)`,
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
async function closeAll(): Promise<void> {
|
|
245
|
+
clearInterval(cleanupTimer);
|
|
246
|
+
for (const [id, entry] of sessions) {
|
|
247
|
+
await entry.transport.close().catch(() => {});
|
|
248
|
+
await entry.server.close().catch(() => {});
|
|
249
|
+
sessions.delete(id);
|
|
250
|
+
logger.debug(`Closed session ${id} during shutdown`);
|
|
251
|
+
}
|
|
252
|
+
httpServer.close();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { httpServer, closeAll };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Legacy single-session export (kept for backward compat) ──
|
|
259
|
+
|
|
260
|
+
/** @deprecated Use createMultiSessionHttpServer instead. */
|
|
17
261
|
export function createStreamableHttpTransport(options: HttpTransportOptions): {
|
|
18
262
|
transport: StreamableHTTPServerTransport;
|
|
19
263
|
httpServer: Server;
|
|
@@ -23,7 +267,6 @@ export function createStreamableHttpTransport(options: HttpTransportOptions): {
|
|
|
23
267
|
});
|
|
24
268
|
|
|
25
269
|
const httpServer = createServer(async (req, res) => {
|
|
26
|
-
// Only handle the /mcp endpoint
|
|
27
270
|
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
28
271
|
if (url.pathname !== "/mcp") {
|
|
29
272
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
package/mcp/src/utils/logger.ts
CHANGED
|
@@ -13,7 +13,11 @@ const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
|
13
13
|
error: 3,
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
let currentLevel: LogLevel =
|
|
16
|
+
let currentLevel: LogLevel =
|
|
17
|
+
(process.env.LOG_LEVEL?.toLowerCase() as LogLevel) || "info";
|
|
18
|
+
if (!LEVEL_ORDER[currentLevel]) {
|
|
19
|
+
currentLevel = "info";
|
|
20
|
+
}
|
|
17
21
|
|
|
18
22
|
export function setLogLevel(level: LogLevel): void {
|
|
19
23
|
currentLevel = level;
|
|
@@ -182,18 +182,35 @@ async function readReferencedMarkdownFiles(
|
|
|
182
182
|
return references;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
async function collectSiblingMarkdownTargets(
|
|
185
|
+
async function collectSiblingMarkdownTargets(
|
|
186
|
+
skillDir: string,
|
|
187
|
+
): Promise<string[]> {
|
|
186
188
|
const entries = await readdir(skillDir, { withFileTypes: true }).catch(
|
|
187
189
|
() => [],
|
|
188
190
|
);
|
|
189
191
|
const targets: string[] = [];
|
|
190
192
|
|
|
191
193
|
for (const entry of entries) {
|
|
192
|
-
if (!entry.isFile()) continue;
|
|
193
194
|
if (entry.name.startsWith(".")) continue;
|
|
194
|
-
|
|
195
|
-
if (entry.
|
|
196
|
-
|
|
195
|
+
|
|
196
|
+
if (entry.isDirectory()) {
|
|
197
|
+
// One level deep: scan subdirectories like references/, steering/, parts/
|
|
198
|
+
const subEntries = await readdir(path.join(skillDir, entry.name), {
|
|
199
|
+
withFileTypes: true,
|
|
200
|
+
}).catch(() => []);
|
|
201
|
+
for (const sub of subEntries) {
|
|
202
|
+
if (!sub.isFile()) continue;
|
|
203
|
+
if (sub.name.startsWith(".")) continue;
|
|
204
|
+
if (!sub.name.toLowerCase().endsWith(".md")) continue;
|
|
205
|
+
targets.push(`${entry.name}/${sub.name}`);
|
|
206
|
+
if (targets.length >= MAX_REFERENCED_FILES) break;
|
|
207
|
+
}
|
|
208
|
+
} else if (entry.isFile()) {
|
|
209
|
+
if (!entry.name.toLowerCase().endsWith(".md")) continue;
|
|
210
|
+
if (entry.name.toLowerCase() === "skill.md") continue;
|
|
211
|
+
targets.push(entry.name);
|
|
212
|
+
}
|
|
213
|
+
|
|
197
214
|
if (targets.length >= MAX_REFERENCED_FILES) break;
|
|
198
215
|
}
|
|
199
216
|
|
package/mcp/src/vault/scanner.ts
CHANGED
|
@@ -40,6 +40,12 @@ export async function scanVaultRoots(
|
|
|
40
40
|
const skillStat = await stat(skillFile).catch(() => null);
|
|
41
41
|
if (!skillStat?.isFile()) continue;
|
|
42
42
|
|
|
43
|
+
// Skip empty SKILL.md files — they provide no instructions
|
|
44
|
+
if (skillStat.size === 0) {
|
|
45
|
+
logger.warn(`Skipping empty SKILL.md: ${skillFile}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
const wrapperKind = await detectWrapperKind(entry, skillFile);
|
|
44
50
|
if (wrapperKind) {
|
|
45
51
|
logger.debug(
|
|
@@ -67,14 +73,18 @@ const WRAPPER_PREFIXES = ["workflow-", "agent-"] as const;
|
|
|
67
73
|
const WRAPPER_KINDS = new Set(["workflow", "agent"]);
|
|
68
74
|
const FRONTMATTER_PREVIEW_BYTES = 8192;
|
|
69
75
|
|
|
70
|
-
function extractWrapperKindFromId(
|
|
76
|
+
function extractWrapperKindFromId(
|
|
77
|
+
skillId: string,
|
|
78
|
+
): "workflow" | "agent" | null {
|
|
71
79
|
const lower = skillId.toLowerCase();
|
|
72
80
|
if (lower.startsWith(WRAPPER_PREFIXES[0])) return "workflow";
|
|
73
81
|
if (lower.startsWith(WRAPPER_PREFIXES[1])) return "agent";
|
|
74
82
|
return null;
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
async function readFrontmatterPreview(
|
|
85
|
+
async function readFrontmatterPreview(
|
|
86
|
+
skillFile: string,
|
|
87
|
+
): Promise<string | null> {
|
|
78
88
|
const handle = await open(skillFile, "r").catch(() => null);
|
|
79
89
|
if (!handle) return null;
|
|
80
90
|
|
|
@@ -116,7 +126,10 @@ function extractMetadataWrapper(frontmatter: string): string | null {
|
|
|
116
126
|
const match = line.match(/^\s+wrapper\s*:\s*(.+)\s*$/);
|
|
117
127
|
if (!match) continue;
|
|
118
128
|
|
|
119
|
-
const value = match[1]
|
|
129
|
+
const value = match[1]
|
|
130
|
+
.trim()
|
|
131
|
+
.replace(/^['"]|['"]$/g, "")
|
|
132
|
+
.toLowerCase();
|
|
120
133
|
if (WRAPPER_KINDS.has(value)) {
|
|
121
134
|
return value;
|
|
122
135
|
}
|