@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.
@@ -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(fullCatalogEstimatedTokens, usedEstimatedTokens);
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) => sum + estimateTokensFromBytes(skill.fileBytes, charsPerToken),
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(skill.fileBytes, charsPerToken),
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(skill.fileBytes, charsPerToken),
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((id) => !skillById.has(id));
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((skill) => !selectedIdSet.has(skill.id) && !loadedIdSet.has(skill.id))
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: JSON.stringify(payload, null, 2),
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) => sum + estimateTokensFromBytes(skill.fileBytes, charsPerToken),
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
- * Uses the SDK's StreamableHTTPServerTransport for remote connections.
5
- * Binds to localhost by default (security: prevents DNS rebinding).
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 { createServer, type Server } from "node:http";
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" });
@@ -13,7 +13,11 @@ const LEVEL_ORDER: Record<LogLevel, number> = {
13
13
  error: 3,
14
14
  };
15
15
 
16
- let currentLevel: LogLevel = "info";
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(skillDir: string): Promise<string[]> {
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
- if (!entry.name.toLowerCase().endsWith(".md")) continue;
195
- if (entry.name.toLowerCase() === "skill.md") continue;
196
- targets.push(entry.name);
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
 
@@ -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(skillId: string): "workflow" | "agent" | null {
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(skillFile: string): Promise<string | null> {
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].trim().replace(/^['"]|['"]$/g, "").toLowerCase();
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubis/foundry",
3
- "version": "0.3.49",
3
+ "version": "0.3.51",
4
4
  "description": "Cubis Foundry CLI for workflow-first AI agent environments",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,17 +1,80 @@
1
1
  ---
2
2
  name: postman
3
- description: Automate API testing and collection management with Postman MCP. Use for workspace, collection, environment, and mock operations.
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
- - `POSTMAN_API_KEY_<PROFILE>` for authenticated Postman access.
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
- - Prefer tool discovery (`getEnabledTools`) before making assumptions about available tool sets.
80
+ - Never print or persist raw key values in logs, docs, or responses.