@cubis/foundry 0.3.49 → 0.3.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- 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
- package/workflows/skills/postman/SKILL.md +67 -4
- package/workflows/workflows/agent-environment-setup/platforms/antigravity/rules/GEMINI.md +10 -1
- package/workflows/workflows/agent-environment-setup/platforms/codex/rules/AGENTS.md +10 -1
- package/workflows/workflows/agent-environment-setup/platforms/copilot/rules/AGENTS.md +10 -1
- package/workflows/workflows/agent-environment-setup/platforms/copilot/rules/copilot-instructions.md +10 -1
- package/workflows/workflows/agent-environment-setup/platforms/copilot/skills/postman/SKILL.md +67 -4
- package/workflows/workflows/agent-environment-setup/platforms/cursor/skills/postman/SKILL.md +67 -4
- package/workflows/workflows/agent-environment-setup/platforms/windsurf/skills/postman/SKILL.md +67 -4
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,80 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: postman
|
|
3
|
-
description:
|
|
3
|
+
description: Use Postman MCP tools for workspace, collection, environment, and run workflows with explicit default-workspace handling.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Postman MCP
|
|
7
7
|
|
|
8
8
|
Use this skill when you need to work with Postman through MCP tools.
|
|
9
9
|
|
|
10
|
+
## MCP-First Rule
|
|
11
|
+
|
|
12
|
+
- Prefer Postman MCP tools (`postman.*`) for all Postman operations.
|
|
13
|
+
- Do not use Newman/Postman CLI fallback unless the user explicitly asks for fallback.
|
|
14
|
+
- If required Postman MCP tools are unavailable, stop and report the MCP discovery issue with remediation steps.
|
|
15
|
+
|
|
10
16
|
## Required Environment Variables
|
|
11
17
|
|
|
12
|
-
-
|
|
18
|
+
- Active profile key alias must be set (typically `POSTMAN_API_KEY_DEFAULT`).
|
|
19
|
+
- `POSTMAN_API_KEY_<PROFILE>` aliases are also valid if the active profile uses them.
|
|
20
|
+
|
|
21
|
+
## Preflight Checklist
|
|
22
|
+
|
|
23
|
+
1. Read Postman status first:
|
|
24
|
+
- Call `postman_get_status` (`scope: auto` unless user requires a scope).
|
|
25
|
+
2. Validate connectivity and mode:
|
|
26
|
+
- If not configured, report missing env alias/config and stop.
|
|
27
|
+
- If mode is not `full`, call `postman_set_mode` with `mode: full`.
|
|
28
|
+
3. Discover upstream tools:
|
|
29
|
+
- Prefer `postman.getEnabledTools` when available.
|
|
30
|
+
- Confirm required tool names before proceeding (for example `getWorkspaces`, `getCollections`, `runCollection`).
|
|
31
|
+
|
|
32
|
+
## Default Workspace ID Policy
|
|
33
|
+
|
|
34
|
+
Resolve workspace in this order:
|
|
35
|
+
|
|
36
|
+
1. User-provided workspace ID.
|
|
37
|
+
2. `postman_get_status.defaultWorkspaceId`.
|
|
38
|
+
3. Auto-detect from `postman.getWorkspaces`:
|
|
39
|
+
- If exactly one workspace exists, use it and state that choice.
|
|
40
|
+
4. If multiple workspaces and no default:
|
|
41
|
+
- Ask user to choose one.
|
|
42
|
+
- Recommend persisting it with:
|
|
43
|
+
- `cbx workflows config --scope global --workspace-id <workspace-id>`
|
|
44
|
+
|
|
45
|
+
When a Postman tool requires a workspace argument, always pass the resolved workspace ID explicitly.
|
|
46
|
+
|
|
47
|
+
## Common Workflows
|
|
48
|
+
|
|
49
|
+
### List/Inspect
|
|
50
|
+
|
|
51
|
+
- `postman.getWorkspaces`
|
|
52
|
+
- `postman.getCollections` (with resolved workspace ID)
|
|
53
|
+
- `postman.getEnvironments` (with resolved workspace ID)
|
|
54
|
+
|
|
55
|
+
### Collection Run
|
|
56
|
+
|
|
57
|
+
1. Resolve workspace ID (policy above).
|
|
58
|
+
2. Resolve `collectionId` and optional `environmentId`.
|
|
59
|
+
3. Call `postman.runCollection`.
|
|
60
|
+
4. Return a concise run summary:
|
|
61
|
+
- total requests
|
|
62
|
+
- passed/failed tests
|
|
63
|
+
- failing request/test names
|
|
64
|
+
- proposed fix path for failures
|
|
65
|
+
|
|
66
|
+
## Failure Handling
|
|
67
|
+
|
|
68
|
+
If dynamic Postman tools are missing (only `postman_get_*` / `postman_set_mode` visible):
|
|
69
|
+
|
|
70
|
+
1. Verify env alias expected by config is set.
|
|
71
|
+
2. Resync catalog:
|
|
72
|
+
- `cbx mcp tools sync --service postman --scope global`
|
|
73
|
+
- `cbx mcp tools list --service postman --scope global`
|
|
74
|
+
3. Recreate runtime if needed:
|
|
75
|
+
- `cbx mcp runtime up --scope global --name cbx-mcp --replace --port 3310 --skills-root ~/.agents/skills`
|
|
13
76
|
|
|
14
|
-
## Notes
|
|
77
|
+
## Security Notes
|
|
15
78
|
|
|
16
79
|
- Use environment variables for secrets. Do not inline API keys.
|
|
17
|
-
-
|
|
80
|
+
- Never print or persist raw key values in logs, docs, or responses.
|