toolcraft 0.0.2 → 0.0.3

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.
Files changed (85) hide show
  1. package/README.md +458 -58
  2. package/dist/cli.compile-check.js +1 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +768 -40
  5. package/dist/human-in-loop/approval-tasks.d.ts +31 -0
  6. package/dist/human-in-loop/approval-tasks.js +201 -0
  7. package/dist/human-in-loop/approvals-commands.d.ts +11 -0
  8. package/dist/human-in-loop/approvals-commands.js +191 -0
  9. package/dist/human-in-loop/config.d.ts +11 -0
  10. package/dist/human-in-loop/config.js +21 -0
  11. package/dist/human-in-loop/default-provider.d.ts +2 -0
  12. package/dist/human-in-loop/default-provider.js +26 -0
  13. package/dist/human-in-loop/gate.d.ts +4 -0
  14. package/dist/human-in-loop/gate.js +57 -0
  15. package/dist/human-in-loop/index.d.ts +7 -0
  16. package/dist/human-in-loop/index.js +4 -0
  17. package/dist/human-in-loop/runner.d.ts +3 -0
  18. package/dist/human-in-loop/runner.js +196 -0
  19. package/dist/human-in-loop/spawn.d.ts +3 -0
  20. package/dist/human-in-loop/spawn.js +16 -0
  21. package/dist/human-in-loop/state-machine.d.ts +4 -0
  22. package/dist/human-in-loop/state-machine.js +10 -0
  23. package/dist/human-in-loop/types.d.ts +41 -0
  24. package/dist/human-in-loop/types.js +13 -0
  25. package/dist/index.compile-check.js +24 -0
  26. package/dist/index.d.ts +32 -13
  27. package/dist/index.js +82 -17
  28. package/dist/json-schema-converter.d.ts +21 -0
  29. package/dist/json-schema-converter.js +432 -0
  30. package/dist/mcp-proxy.d.ts +8 -0
  31. package/dist/mcp-proxy.js +383 -0
  32. package/dist/mcp.compile-check.js +1 -0
  33. package/dist/mcp.d.ts +2 -0
  34. package/dist/mcp.js +103 -11
  35. package/dist/sdk.compile-check.js +77 -0
  36. package/dist/sdk.d.ts +14 -5
  37. package/dist/sdk.js +57 -6
  38. package/dist/user-error.d.ts +3 -0
  39. package/dist/user-error.js +6 -0
  40. package/node_modules/@poe-code/agent-human-in-loop/README.md +42 -0
  41. package/node_modules/@poe-code/agent-human-in-loop/dist/index.d.ts +5 -0
  42. package/node_modules/@poe-code/agent-human-in-loop/dist/index.js +3 -0
  43. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/mock.d.ts +2 -0
  44. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/mock.js +11 -0
  45. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/osascript-script.d.ts +4 -0
  46. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/osascript-script.js +40 -0
  47. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/osascript.d.ts +6 -0
  48. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/osascript.js +33 -0
  49. package/node_modules/@poe-code/agent-human-in-loop/dist/request-approval.d.ts +4 -0
  50. package/node_modules/@poe-code/agent-human-in-loop/dist/request-approval.js +4 -0
  51. package/node_modules/@poe-code/agent-human-in-loop/dist/types.d.ts +14 -0
  52. package/node_modules/@poe-code/agent-human-in-loop/dist/types.js +1 -0
  53. package/node_modules/@poe-code/agent-human-in-loop/package.json +25 -0
  54. package/node_modules/@poe-code/agent-mcp-config/dist/apply.d.ts +6 -0
  55. package/node_modules/@poe-code/agent-mcp-config/dist/apply.js +175 -0
  56. package/node_modules/@poe-code/agent-mcp-config/dist/configs.d.ts +22 -0
  57. package/node_modules/@poe-code/agent-mcp-config/dist/configs.js +74 -0
  58. package/node_modules/@poe-code/agent-mcp-config/dist/index.d.ts +3 -0
  59. package/node_modules/@poe-code/agent-mcp-config/dist/index.js +2 -0
  60. package/node_modules/@poe-code/agent-mcp-config/dist/shapes.d.ts +31 -0
  61. package/node_modules/@poe-code/agent-mcp-config/dist/shapes.js +87 -0
  62. package/node_modules/@poe-code/agent-mcp-config/dist/types.d.ts +25 -0
  63. package/node_modules/@poe-code/agent-mcp-config/dist/types.js +1 -0
  64. package/node_modules/@poe-code/agent-mcp-config/package.json +25 -0
  65. package/node_modules/@poe-code/task-list/README.md +114 -0
  66. package/node_modules/@poe-code/task-list/dist/backends/markdown-dir.d.ts +2 -0
  67. package/node_modules/@poe-code/task-list/dist/backends/markdown-dir.js +466 -0
  68. package/node_modules/@poe-code/task-list/dist/backends/utils.d.ts +8 -0
  69. package/node_modules/@poe-code/task-list/dist/backends/utils.js +58 -0
  70. package/node_modules/@poe-code/task-list/dist/backends/yaml-file.d.ts +2 -0
  71. package/node_modules/@poe-code/task-list/dist/backends/yaml-file.js +444 -0
  72. package/node_modules/@poe-code/task-list/dist/index.d.ts +4 -0
  73. package/node_modules/@poe-code/task-list/dist/index.js +4 -0
  74. package/node_modules/@poe-code/task-list/dist/open.d.ts +3 -0
  75. package/node_modules/@poe-code/task-list/dist/open.js +34 -0
  76. package/node_modules/@poe-code/task-list/dist/schema/store.schema.json +32 -0
  77. package/node_modules/@poe-code/task-list/dist/schema/task.schema.json +33 -0
  78. package/node_modules/@poe-code/task-list/dist/state-machine.d.ts +16 -0
  79. package/node_modules/@poe-code/task-list/dist/state-machine.js +67 -0
  80. package/node_modules/@poe-code/task-list/dist/state.d.ts +29 -0
  81. package/node_modules/@poe-code/task-list/dist/state.js +61 -0
  82. package/node_modules/@poe-code/task-list/dist/types.d.ts +116 -0
  83. package/node_modules/@poe-code/task-list/dist/types.js +37 -0
  84. package/node_modules/@poe-code/task-list/package.json +26 -0
  85. package/package.json +22 -7
@@ -0,0 +1,383 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createLogger } from "@poe-code/design-system";
5
+ import { HttpTransport, McpClient, StdioTransport } from "tiny-mcp-client";
6
+ import { convertJsonSchema } from "./json-schema-converter.js";
7
+ const GROUP_CONFIG_SYMBOL_DESCRIPTION = "toolcraft.group.config";
8
+ const MCP_PROXY_SCHEMA_URL = "https://poe-platform.github.io/poe-code/schemas/toolcraft/mcp-proxy.schema.json";
9
+ const DEFAULT_CLIENT_INFO = {
10
+ name: "toolcraft",
11
+ version: "0.0.1",
12
+ };
13
+ const proxyNodeSymbol = Symbol("toolcraft.mcpProxyNode");
14
+ const proxyConnectionSymbol = Symbol("toolcraft.mcpProxyConnection");
15
+ const shutdownDisposers = new Set();
16
+ function getInternalGroupConfig(group) {
17
+ const symbol = Object.getOwnPropertySymbols(group).find((candidate) => candidate.description === GROUP_CONFIG_SYMBOL_DESCRIPTION);
18
+ if (symbol === undefined) {
19
+ return {};
20
+ }
21
+ return (group[symbol] ??
22
+ {});
23
+ }
24
+ function isProxyNode(node) {
25
+ return node[proxyNodeSymbol] === true;
26
+ }
27
+ function markProxyNode(node) {
28
+ Object.defineProperty(node, proxyNodeSymbol, {
29
+ configurable: false,
30
+ enumerable: false,
31
+ value: true,
32
+ writable: false,
33
+ });
34
+ return node;
35
+ }
36
+ function cloneSecrets(secrets) {
37
+ return { ...secrets };
38
+ }
39
+ function cloneScope(scope) {
40
+ return scope === undefined ? undefined : [...scope];
41
+ }
42
+ function registerShutdownDispose(dispose) {
43
+ shutdownDisposers.add(dispose);
44
+ }
45
+ function getProxyConnection(group) {
46
+ return group[proxyConnectionSymbol];
47
+ }
48
+ function setProxyConnection(group, connection) {
49
+ group[proxyConnectionSymbol] = connection;
50
+ }
51
+ function createProxyGroup(parent, name) {
52
+ return markProxyNode({
53
+ kind: "group",
54
+ name,
55
+ description: undefined,
56
+ aliases: [],
57
+ scope: cloneScope(parent.scope),
58
+ secrets: cloneSecrets(parent.secrets),
59
+ requires: parent.requires,
60
+ children: [],
61
+ default: undefined,
62
+ });
63
+ }
64
+ function createProxyCommand(parent, tool, commandName, connection) {
65
+ const params = convertJsonSchema(tool.inputSchema);
66
+ if (params.kind !== "object") {
67
+ throw new Error(`upstream tool "${tool.name}" must define an object input schema`);
68
+ }
69
+ return markProxyNode({
70
+ kind: "command",
71
+ name: commandName,
72
+ description: tool.description,
73
+ aliases: [],
74
+ positional: [],
75
+ params,
76
+ secrets: cloneSecrets(parent.secrets),
77
+ scope: cloneScope(parent.scope) ?? ["cli", "sdk"],
78
+ confirm: false,
79
+ requires: parent.requires,
80
+ handler: async (ctx) => {
81
+ const client = await ensureConnected(connection);
82
+ return client.callTool({
83
+ name: tool.name,
84
+ arguments: ctx.params,
85
+ });
86
+ },
87
+ render: undefined,
88
+ });
89
+ }
90
+ function removeProxyChildren(group) {
91
+ group.children = group.children.filter((child) => !isProxyNode(child));
92
+ for (const child of group.children) {
93
+ if (child.kind === "group") {
94
+ removeProxyChildren(child);
95
+ }
96
+ }
97
+ }
98
+ function findChild(group, name) {
99
+ return group.children.find((child) => child.name === name);
100
+ }
101
+ function filterAllowlistedTools(tools, allowlist) {
102
+ if (allowlist === undefined) {
103
+ return tools;
104
+ }
105
+ const allowedNames = new Set(allowlist);
106
+ return tools.filter((tool) => allowedNames.has(tool.name));
107
+ }
108
+ function validateRenameMap(name, tools, rename) {
109
+ if (rename === undefined) {
110
+ return;
111
+ }
112
+ const toolNames = new Set(tools.map((tool) => tool.name));
113
+ for (const upstreamToolName of Object.keys(rename)) {
114
+ if (!toolNames.has(upstreamToolName)) {
115
+ throw new Error(`couldn't discover MCP ${name}: rename references unknown upstream tool "${upstreamToolName}"`);
116
+ }
117
+ }
118
+ }
119
+ function createConnection(name, config) {
120
+ const connection = {
121
+ name,
122
+ config,
123
+ async dispose() {
124
+ shutdownDisposers.delete(connection.dispose);
125
+ connection.connecting = undefined;
126
+ if (connection.client === undefined) {
127
+ return;
128
+ }
129
+ const client = connection.client;
130
+ connection.client = undefined;
131
+ await client.close();
132
+ },
133
+ };
134
+ registerShutdownDispose(connection.dispose);
135
+ return connection;
136
+ }
137
+ async function ensureConnected(connection) {
138
+ if (connection.client !== undefined && connection.client.state === "ready") {
139
+ return connection.client;
140
+ }
141
+ if (connection.connecting !== undefined) {
142
+ return connection.connecting;
143
+ }
144
+ connection.connecting = dialUpstream(connection.name, connection.config)
145
+ .then((client) => {
146
+ connection.client = client;
147
+ return client;
148
+ })
149
+ .finally(() => {
150
+ connection.connecting = undefined;
151
+ });
152
+ return connection.connecting;
153
+ }
154
+ async function readCache(cachePath) {
155
+ try {
156
+ const raw = await readFile(cachePath, "utf8");
157
+ const parsed = JSON.parse(raw);
158
+ if (parsed === null ||
159
+ typeof parsed !== "object" ||
160
+ !Array.isArray(parsed.tools) ||
161
+ parsed.upstream === undefined ||
162
+ typeof parsed.upstream.name !== "string" ||
163
+ typeof parsed.upstream.version !== "string") {
164
+ return undefined;
165
+ }
166
+ return {
167
+ $schema: typeof parsed.$schema === "string" ? parsed.$schema : MCP_PROXY_SCHEMA_URL,
168
+ fetchedAt: typeof parsed.fetchedAt === "string" ? parsed.fetchedAt : new Date(0).toISOString(),
169
+ tools: parsed.tools,
170
+ upstream: parsed.upstream,
171
+ version: parsed.version === 1 ? 1 : 1,
172
+ };
173
+ }
174
+ catch (error) {
175
+ const code = error.code;
176
+ if (code === "ENOENT" || error instanceof SyntaxError) {
177
+ return undefined;
178
+ }
179
+ return undefined;
180
+ }
181
+ }
182
+ async function writeCache(cachePath, cache) {
183
+ const directory = path.dirname(cachePath);
184
+ const tempPath = `${cachePath}.tmp`;
185
+ await mkdir(directory, { recursive: true });
186
+ await writeFile(tempPath, `${JSON.stringify(cache, null, 2)}\n`);
187
+ await rename(tempPath, cachePath);
188
+ }
189
+ async function fetchCache(name, config, cachePath) {
190
+ const logger = createLogger((message) => {
191
+ process.stderr.write(`${message}\n`);
192
+ });
193
+ logger.info(`MCP ${name}: connecting`);
194
+ const client = await dialUpstream(name, config);
195
+ try {
196
+ logger.info(`MCP ${name}: listing tools`);
197
+ const tools = [];
198
+ let cursor;
199
+ do {
200
+ const page = await client.listTools(cursor === undefined ? {} : { cursor });
201
+ tools.push(...page.tools);
202
+ cursor = page.nextCursor;
203
+ } while (cursor !== undefined);
204
+ logger.info(`MCP ${name}: found ${tools.length} tools`);
205
+ const upstream = client.serverInfo ?? {
206
+ name,
207
+ version: "unknown",
208
+ };
209
+ const cache = {
210
+ $schema: MCP_PROXY_SCHEMA_URL,
211
+ version: 1,
212
+ upstream,
213
+ fetchedAt: new Date().toISOString(),
214
+ tools,
215
+ };
216
+ await writeCache(cachePath, cache);
217
+ logger.info(`MCP ${name}: wrote ${cachePath}`);
218
+ return cache;
219
+ }
220
+ finally {
221
+ await client.close();
222
+ }
223
+ }
224
+ async function deleteCacheIfPresent(cachePath) {
225
+ try {
226
+ await unlink(cachePath);
227
+ }
228
+ catch (error) {
229
+ if (error.code !== "ENOENT") {
230
+ throw error;
231
+ }
232
+ }
233
+ }
234
+ function populateGroupFromTools(group, tools, rename, connection) {
235
+ removeProxyChildren(group);
236
+ for (const tool of tools) {
237
+ const targetPath = rename?.[tool.name] ?? tool.name;
238
+ const segments = rename !== undefined && Object.prototype.hasOwnProperty.call(rename, tool.name)
239
+ ? targetPath.split(".")
240
+ : [tool.name];
241
+ const commandName = segments[segments.length - 1];
242
+ if (commandName === undefined || commandName.length === 0) {
243
+ throw new Error(`command path "${targetPath}" collides with an existing child`);
244
+ }
245
+ let parent = group;
246
+ for (const segment of segments.slice(0, -1)) {
247
+ const existing = findChild(parent, segment);
248
+ if (existing === undefined) {
249
+ const created = createProxyGroup(parent, segment);
250
+ parent.children.push(created);
251
+ parent = created;
252
+ continue;
253
+ }
254
+ if (existing.kind !== "group") {
255
+ throw new Error(`command path "${targetPath}" collides with an existing child`);
256
+ }
257
+ parent = existing;
258
+ }
259
+ if (findChild(parent, commandName) !== undefined) {
260
+ throw new Error(`command path "${targetPath}" collides with an existing child`);
261
+ }
262
+ parent.children.push(createProxyCommand(parent, tool, commandName, connection));
263
+ }
264
+ }
265
+ function isRefreshRequested(name, refresh) {
266
+ if (refresh === "all") {
267
+ return true;
268
+ }
269
+ return refresh?.has(name) === true;
270
+ }
271
+ async function resolveSingleProxy(group) {
272
+ const internal = getInternalGroupConfig(group);
273
+ const config = internal.mcp;
274
+ if (config === undefined) {
275
+ return;
276
+ }
277
+ const name = group.name;
278
+ try {
279
+ const cachePath = resolveCachePath(name);
280
+ const refresh = parseRefreshEnv(process.env.TOOLCRAFT_MCP_REFRESH);
281
+ let cache;
282
+ if (isRefreshRequested(name, refresh)) {
283
+ await deleteCacheIfPresent(cachePath);
284
+ cache = await fetchCache(name, config, cachePath);
285
+ }
286
+ else {
287
+ cache = (await readCache(cachePath)) ?? (await fetchCache(name, config, cachePath));
288
+ }
289
+ const tools = filterAllowlistedTools(cache.tools, internal.tools);
290
+ validateRenameMap(name, tools, internal.rename);
291
+ const previousConnection = getProxyConnection(group);
292
+ const nextConnection = createConnection(name, config);
293
+ try {
294
+ populateGroupFromTools(group, tools, internal.rename, nextConnection);
295
+ setProxyConnection(group, nextConnection);
296
+ }
297
+ catch (error) {
298
+ await nextConnection.dispose();
299
+ throw error;
300
+ }
301
+ if (previousConnection !== undefined && previousConnection !== nextConnection) {
302
+ await previousConnection.dispose();
303
+ }
304
+ }
305
+ catch (error) {
306
+ if (error instanceof Error && error.message.startsWith(`couldn't discover MCP ${name}:`)) {
307
+ throw error;
308
+ }
309
+ throw new Error(`couldn't discover MCP ${name}: ${error instanceof Error ? error.message : String(error)}`);
310
+ }
311
+ }
312
+ function collectProxyGroups(root) {
313
+ const groups = [];
314
+ function visit(group) {
315
+ if (getInternalGroupConfig(group).mcp !== undefined) {
316
+ groups.push(group);
317
+ }
318
+ for (const child of group.children) {
319
+ if (child.kind === "group") {
320
+ visit(child);
321
+ }
322
+ }
323
+ }
324
+ visit(root);
325
+ return groups;
326
+ }
327
+ export function hasMcpProxyGroups(root) {
328
+ return collectProxyGroups(root).length > 0;
329
+ }
330
+ export function resolveCachePath(name, projectRoot) {
331
+ if (projectRoot !== undefined) {
332
+ return path.join(projectRoot, ".toolcraft", "mcp", `${name}.json`);
333
+ }
334
+ let current = process.cwd();
335
+ while (true) {
336
+ if (existsSync(path.join(current, "package.json"))) {
337
+ return path.join(current, ".toolcraft", "mcp", `${name}.json`);
338
+ }
339
+ const parent = path.dirname(current);
340
+ if (parent === current) {
341
+ throw new Error(`Could not find package.json above "${process.cwd()}" while resolving MCP cache path.`);
342
+ }
343
+ current = parent;
344
+ }
345
+ }
346
+ export function parseRefreshEnv(value) {
347
+ const trimmed = value?.trim();
348
+ if (trimmed === undefined || trimmed.length === 0) {
349
+ return undefined;
350
+ }
351
+ if (trimmed === "1" || trimmed === "true") {
352
+ return "all";
353
+ }
354
+ const names = trimmed
355
+ .split(",")
356
+ .map((entry) => entry.trim())
357
+ .filter((entry) => entry.length > 0);
358
+ return names.length === 0 ? undefined : new Set(names);
359
+ }
360
+ export async function dialUpstream(name, config) {
361
+ const client = new McpClient({
362
+ clientInfo: {
363
+ name: `${DEFAULT_CLIENT_INFO.name}-${name}`,
364
+ version: DEFAULT_CLIENT_INFO.version,
365
+ },
366
+ });
367
+ const transport = config.transport === "stdio"
368
+ ? new StdioTransport({
369
+ command: config.command,
370
+ ...(config.args === undefined ? {} : { args: config.args }),
371
+ ...(config.env === undefined ? {} : { env: config.env }),
372
+ })
373
+ : new HttpTransport({
374
+ url: config.url,
375
+ ...(config.headers === undefined ? {} : { headers: config.headers }),
376
+ });
377
+ await client.connect(transport);
378
+ return client;
379
+ }
380
+ export async function resolveMcpProxies(root) {
381
+ const groups = collectProxyGroups(root);
382
+ await Promise.all(groups.map((group) => resolveSingleProxy(group)));
383
+ }
@@ -19,6 +19,7 @@ const ignoredOptions = {
19
19
  version: "1.0.0",
20
20
  tools: ["usage"],
21
21
  casing: "snake",
22
+ humanInLoop: {},
22
23
  };
23
24
  const ignoredServer = createMCPServer(ignoredRoot, ignoredOptions);
24
25
  const ignoredServerArray = createMCPServer([ignoredRoot], ignoredOptions);
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type SDKTransport, type Server as TinyServer } from "tiny-stdio-mcp-server";
2
2
  import type { Group } from "./index.js";
3
+ import { type HumanInLoopRuntimeOptions } from "./human-in-loop/index.js";
3
4
  type Casing = "snake" | "camel";
4
5
  type CmdkitServer = Omit<TinyServer, "connect"> & {
5
6
  connect(transport: SDKTransport): Promise<void>;
@@ -7,6 +8,7 @@ type CmdkitServer = Omit<TinyServer, "connect"> & {
7
8
  export interface RunMCPOptions<TServices extends object = Record<string, unknown>> {
8
9
  name: string;
9
10
  version: string;
11
+ humanInLoop?: HumanInLoopRuntimeOptions;
10
12
  /**
11
13
  * Optional allowlist of MCP tool names or group prefixes.
12
14
  *
package/dist/mcp.js CHANGED
@@ -2,6 +2,9 @@ import { access, readFile, writeFile } from "node:fs/promises";
2
2
  import { createServer, JSON_RPC_ERROR_CODES, ToolError, } from "tiny-stdio-mcp-server";
3
3
  import { toJsonSchema } from "toolcraft-schema";
4
4
  import { UserError, assertCommandRequirements, resolveCommandSecrets } from "./index.js";
5
+ import { mergeApprovalsGroup } from "./human-in-loop/approvals-commands.js";
6
+ import { ApprovalDeclinedError, invokeWithHumanInLoop, } from "./human-in-loop/index.js";
7
+ import { hasMcpProxyGroups, resolveMcpProxies } from "./mcp-proxy.js";
5
8
  import { getExpectedNumberDescription, isValidNumberSchemaValue } from "./number-schema.js";
6
9
  import { filterSchemaForScope } from "./schema-scope.js";
7
10
  const RESERVED_SERVICE_NAMES = new Set(["params", "secrets", "fetch", "fs", "env", "progress"]);
@@ -160,12 +163,12 @@ function formatToolName(path) {
160
163
  }
161
164
  function enumerateTools(root, casing, allowlist) {
162
165
  const tools = [];
163
- function visit(node, path) {
166
+ function visit(node, toolPath, commandPath) {
164
167
  if (node.kind === "command") {
165
168
  if (!node.scope.includes("mcp")) {
166
169
  return;
167
170
  }
168
- const name = formatToolName([...path, node.name]);
171
+ const name = formatToolName([...toolPath, node.name]);
169
172
  const params = filterSchemaForScope(node.params, "mcp");
170
173
  if (!matchesAllowlist(name, allowlist)) {
171
174
  return;
@@ -175,23 +178,67 @@ function enumerateTools(root, casing, allowlist) {
175
178
  }
176
179
  tools.push({
177
180
  command: node,
181
+ commandPath: [...commandPath, node.name].join("."),
178
182
  name,
179
183
  description: buildToolDescription(node.description, params, casing),
180
184
  inputSchema: applySchemaCasing(toJsonSchema(params), casing),
181
185
  });
182
186
  return;
183
187
  }
184
- const nextPath = [...path, node.name];
188
+ const nextToolPath = [...toolPath, node.name];
189
+ const nextCommandPath = [...commandPath, node.name];
185
190
  for (const child of node.children) {
186
- visit(child, nextPath);
191
+ visit(child, nextToolPath, nextCommandPath);
187
192
  }
188
193
  }
189
194
  const rootPath = root.name.length === 0 ? [] : [root.name];
190
195
  for (const child of root.children) {
191
- visit(child, rootPath);
196
+ visit(child, rootPath, []);
192
197
  }
193
198
  return tools;
194
199
  }
200
+ function isHumanInLoopPending(result) {
201
+ return (typeof result === "object" &&
202
+ result !== null &&
203
+ result.status === "pending-approval" &&
204
+ typeof result.approvalId === "string" &&
205
+ typeof result.message === "string" &&
206
+ typeof result.enqueuedAt === "string");
207
+ }
208
+ function renderPendingApproval(pending) {
209
+ return {
210
+ isError: false,
211
+ content: [
212
+ {
213
+ type: "text",
214
+ text: `Queued for human approval (id: ${pending.approvalId}). Track with \`toolcraft approvals show ${pending.approvalId}\`.`,
215
+ },
216
+ {
217
+ type: "text",
218
+ text: JSON.stringify(pending),
219
+ },
220
+ ],
221
+ };
222
+ }
223
+ function renderDeclinedApproval(error) {
224
+ return {
225
+ isError: true,
226
+ content: [
227
+ {
228
+ type: "text",
229
+ text: error.reason === undefined ? "Declined." : `Declined: ${error.reason}`,
230
+ },
231
+ {
232
+ type: "text",
233
+ text: JSON.stringify({
234
+ outcome: "declined",
235
+ reason: error.reason,
236
+ commandPath: error.commandPath,
237
+ }),
238
+ },
239
+ ],
240
+ };
241
+ }
195
242
  function validateEnum(value, schema, label) {
196
243
  if (!schema.values.includes(value)) {
197
244
  throw new UserError(`Invalid value for "${label}". Expected one of: ${schema.values.map((candidate) => String(candidate)).join(", ")}.`);
@@ -307,10 +354,15 @@ function toToolError(error) {
307
354
  }
308
355
  return new ToolError(JSON_RPC_ERROR_CODES.INTERNAL_ERROR, String(error));
309
356
  }
310
- export function createMCPServer(roots, options) {
311
- const root = normalizeRoots(roots);
357
+ function createResolvedMCPServer(root, options) {
312
358
  const casing = options.casing ?? "snake";
313
359
  const services = (options.services ?? {});
360
+ const runtimeOptions = options.humanInLoop ?? {};
361
+ const servicesWithBuiltIns = {
362
+ ...services,
363
+ runtimeOptions,
364
+ root,
365
+ };
314
366
  validateServices(services);
315
367
  const tools = enumerateTools(root, casing, options.tools);
316
368
  const server = createServer({ name: options.name, version: options.version });
@@ -319,7 +371,7 @@ export function createMCPServer(roots, options) {
319
371
  try {
320
372
  const secrets = resolveCommandSecrets(tool.command);
321
373
  const baseContext = {
322
- ...services,
374
+ ...servicesWithBuiltIns,
323
375
  secrets,
324
376
  fetch: globalThis.fetch,
325
377
  fs: createFs(),
@@ -330,13 +382,19 @@ export function createMCPServer(roots, options) {
330
382
  };
331
383
  await assertCommandRequirements(tool.command, { ...baseContext, params: undefined });
332
384
  const params = validateToolArguments(tool.command.params, argumentsValue, casing);
333
- const result = await tool.command.handler({
385
+ const result = await invokeWithHumanInLoop(tool.command, {
334
386
  ...baseContext,
335
387
  params,
336
- });
388
+ }, runtimeOptions, tool.commandPath);
389
+ if (isHumanInLoopPending(result)) {
390
+ return renderPendingApproval(result);
391
+ }
337
392
  return toToolContent(result);
338
393
  }
339
394
  catch (error) {
395
+ if (error instanceof ApprovalDeclinedError) {
396
+ return renderDeclinedApproval(error);
397
+ }
340
398
  throw toToolError(error);
341
399
  }
342
400
  });
@@ -348,7 +406,41 @@ export function createMCPServer(roots, options) {
348
406
  },
349
407
  };
350
408
  }
409
+ function createDeferredMCPServer(root, options) {
410
+ let serverPromise;
411
+ const resolveServer = () => {
412
+ serverPromise ??= (async () => {
413
+ await resolveMcpProxies(root);
414
+ return createResolvedMCPServer(root, options);
415
+ })();
416
+ return serverPromise;
417
+ };
418
+ return new Proxy({
419
+ listen() {
420
+ return resolveServer().then((server) => server.listen());
421
+ },
422
+ connect(transport) {
423
+ return resolveServer().then((server) => server.connect(transport));
424
+ },
425
+ }, {
426
+ get(target, property, receiver) {
427
+ if (property === "then") {
428
+ return resolveServer().then.bind(resolveServer());
429
+ }
430
+ return Reflect.get(target, property, receiver);
431
+ },
432
+ });
433
+ }
434
+ export function createMCPServer(roots, options) {
435
+ const root = mergeApprovalsGroup(normalizeRoots(roots));
436
+ if (!hasMcpProxyGroups(root)) {
437
+ return createResolvedMCPServer(root, options);
438
+ }
439
+ return createDeferredMCPServer(root, options);
440
+ }
351
441
  export async function runMCP(roots, options) {
352
- const server = createMCPServer(roots, options);
442
+ const root = mergeApprovalsGroup(normalizeRoots(roots));
443
+ await resolveMcpProxies(root);
444
+ const server = createResolvedMCPServer(root, options);
353
445
  await server.listen();
354
446
  }
@@ -39,6 +39,49 @@ const ignoredRoot = defineGroup({
39
39
  params: S.Object({}),
40
40
  handler: async () => "hidden",
41
41
  }),
42
+ defineCommand({
43
+ name: "queued",
44
+ scope: ["sdk"],
45
+ params: S.Object({
46
+ prompt_text: S.String(),
47
+ }),
48
+ humanInLoop: {
49
+ mode: "async",
50
+ message: () => "queue it",
51
+ },
52
+ handler: async ({ params }) => ({
53
+ content: params.prompt_text,
54
+ }),
55
+ }),
56
+ ],
57
+ }),
58
+ defineGroup({
59
+ name: "review",
60
+ scope: ["sdk"],
61
+ humanInLoop: {
62
+ mode: "async",
63
+ message: () => "needs review",
64
+ },
65
+ children: [
66
+ defineCommand({
67
+ name: "submit",
68
+ params: S.Object({
69
+ target_name: S.String(),
70
+ }),
71
+ handler: async ({ params }) => ({
72
+ target: params.target_name,
73
+ }),
74
+ }),
75
+ defineCommand({
76
+ name: "skip",
77
+ params: S.Object({
78
+ target_name: S.String(),
79
+ }),
80
+ humanInLoop: null,
81
+ handler: async ({ params }) => ({
82
+ target: params.target_name,
83
+ }),
84
+ }),
42
85
  ],
43
86
  }),
44
87
  ],
@@ -50,6 +93,7 @@ const ignoredOptions = {
50
93
  services: {
51
94
  logger: console,
52
95
  },
96
+ humanInLoop: {},
53
97
  };
54
98
  const ignoredSdk = createSDK(ignoredRoot, ignoredOptions);
55
99
  const ignoredResult = ignoredSdk.poeCode.generate.text({
@@ -67,6 +111,27 @@ const ignoredHttpServerResult = ignoredSdk.poeCode.generate.httpServer({
67
111
  void ignoredHttpServerResult.then((value) => {
68
112
  void value.apiKey;
69
113
  });
114
+ const ignoredQueuedResult = ignoredSdk.poeCode.generate.queued({
115
+ promptText: "hello",
116
+ });
117
+ void ignoredQueuedResult.then((value) => {
118
+ const pending = value;
119
+ void pending.approvalId;
120
+ void pending.message;
121
+ });
122
+ const ignoredInheritedAsyncResult = ignoredSdk.poeCode.review.submit({
123
+ targetName: "prod",
124
+ });
125
+ void ignoredInheritedAsyncResult.then((value) => {
126
+ void value.status;
127
+ void value.enqueuedAt;
128
+ });
129
+ const ignoredOptedOutResult = ignoredSdk.poeCode.review.skip({
130
+ targetName: "prod",
131
+ });
132
+ void ignoredOptedOutResult.then((value) => {
133
+ void value.target;
134
+ });
70
135
  // @ts-expect-error cli-only commands are not exposed in the SDK surface
71
136
  void ignoredSdk.poeCode.generate.cliOnly;
72
137
  // @ts-expect-error wrong parameter name
@@ -77,3 +142,15 @@ ignoredSdk.poeCode.generate.text({ promptText: 123 });
77
142
  void ignoredSdk.poeCode.generate.hTTPServer;
78
143
  // @ts-expect-error acronym parameter names should camel-case cleanly
79
144
  ignoredSdk.poeCode.generate.httpServer({ aPIKey: "secret" });
145
+ void ignoredQueuedResult.then((value) => {
146
+ // @ts-expect-error async human-in-loop commands return the pending marker, not the handler result
147
+ void value.content;
148
+ });
149
+ void ignoredInheritedAsyncResult.then((value) => {
150
+ // @ts-expect-error inherited async human-in-loop mode also returns the pending marker
151
+ void value.target;
152
+ });
153
+ void ignoredOptedOutResult.then((value) => {
154
+ // @ts-expect-error opting out of inherited human-in-loop keeps the handler result type
155
+ void value.approvalId;
156
+ });