@allocator-one/harness-cli 1.15.1 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +256 -38
- package/dist/{chunk-FQWV5MIC.js → chunk-5THYIKLW.js} +26 -4
- package/dist/chunk-A4WZAC7L.js +563 -0
- package/dist/chunk-BHBEIT5I.js +1348 -0
- package/dist/{chunk-L2OWOZ4N.js → chunk-CEEQW6ZV.js} +411 -1556
- package/dist/chunk-W7BVOQQG.js +1448 -0
- package/dist/chunk-WSDFLMO6.js +717 -0
- package/dist/cli.js +1886 -1155
- package/dist/infraone-A3KDADRM.js +249 -0
- package/dist/infraone-V3IKKVBG.js +593 -0
- package/dist/infraone.js +372 -0
- package/dist/{login-page-KGEJFFXR.js → login-page-MFHNVTUV.js} +1 -1
- package/dist/{server-KVRQBVXI.js → server-TRXBTJSA.js} +37 -19
- package/package.json +4 -3
- package/skills/harness-code/SKILL.md +37 -0
- package/spec/openapi.json +4803 -25
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
InfraoneClient,
|
|
4
|
+
collectFiles,
|
|
5
|
+
commandSpecs,
|
|
6
|
+
describeSession,
|
|
7
|
+
loadSpec,
|
|
8
|
+
refreshSpec,
|
|
9
|
+
resolveConfig,
|
|
10
|
+
transferFor
|
|
11
|
+
} from "./chunk-W7BVOQQG.js";
|
|
12
|
+
import "./chunk-WSDFLMO6.js";
|
|
13
|
+
import {
|
|
14
|
+
CommandError,
|
|
15
|
+
HttpError,
|
|
16
|
+
LocalError,
|
|
17
|
+
TransportError,
|
|
18
|
+
UsageError
|
|
19
|
+
} from "./chunk-BHBEIT5I.js";
|
|
20
|
+
import {
|
|
21
|
+
CLI_VERSION
|
|
22
|
+
} from "./chunk-5THYIKLW.js";
|
|
23
|
+
|
|
24
|
+
// src/mcp/infraone.ts
|
|
25
|
+
import { randomUUID } from "crypto";
|
|
26
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
27
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
28
|
+
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
29
|
+
import { isAbsolute } from "path";
|
|
30
|
+
|
|
31
|
+
// src/mcp/infraone-tools.ts
|
|
32
|
+
var PATHS = "paths";
|
|
33
|
+
var ON_CONFLICT = "on_conflict";
|
|
34
|
+
var OUTPUT_PATH = "output_path";
|
|
35
|
+
var OVERWRITE = "overwrite";
|
|
36
|
+
var PATHS_PROPERTY = {
|
|
37
|
+
type: "array",
|
|
38
|
+
minItems: 1,
|
|
39
|
+
items: { type: "string", minLength: 1 },
|
|
40
|
+
description: "Absolute paths of files and folders on this machine to upload. A folder keeps its tree; hidden and empty files are left out."
|
|
41
|
+
};
|
|
42
|
+
var ON_CONFLICT_PROPERTY = {
|
|
43
|
+
type: "string",
|
|
44
|
+
enum: ["keep", "replace", "skip"],
|
|
45
|
+
default: "keep",
|
|
46
|
+
description: "For a name that already exists: keep both, skip the new file, or replace the existing one, which a person approves first."
|
|
47
|
+
};
|
|
48
|
+
var OUTPUT_PATH_PROPERTY = {
|
|
49
|
+
type: "string",
|
|
50
|
+
minLength: 1,
|
|
51
|
+
description: "Absolute path on this machine to save the file to, or an existing folder to save it in under its own name."
|
|
52
|
+
};
|
|
53
|
+
var OVERWRITE_PROPERTY = { type: "boolean", default: false, description: "Replace a file already at output_path." };
|
|
54
|
+
var IDEMPOTENCY_KEY = "idempotency_key";
|
|
55
|
+
var METHODS = ["get", "post", "put", "patch", "delete"];
|
|
56
|
+
var RISKS = ["read", "write", "destructive", "outward"];
|
|
57
|
+
var TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
58
|
+
var PATH_PARAM = /\{([a-z][a-z0-9_]*)\}/g;
|
|
59
|
+
var MAX_REF_DEPTH = 16;
|
|
60
|
+
var SUBSCHEMA_KEYS = /* @__PURE__ */ new Set(["items", "additionalProperties", "not"]);
|
|
61
|
+
var SUBSCHEMA_LIST_KEYS = /* @__PURE__ */ new Set(["allOf", "anyOf", "oneOf"]);
|
|
62
|
+
var IDEMPOTENCY_PROPERTY = {
|
|
63
|
+
type: "string",
|
|
64
|
+
minLength: 1,
|
|
65
|
+
maxLength: 255,
|
|
66
|
+
description: "Optional. A fresh value for each write; send the same value again when retrying it, so the retry returns the first result instead of writing twice."
|
|
67
|
+
};
|
|
68
|
+
function toolOperations(spec, options = {}) {
|
|
69
|
+
const byName = /* @__PURE__ */ new Map();
|
|
70
|
+
for (const [path, item] of Object.entries(spec.paths)) {
|
|
71
|
+
if (!isRecord(item)) continue;
|
|
72
|
+
for (const method of METHODS) {
|
|
73
|
+
const operation = item[method];
|
|
74
|
+
if (!isRecord(operation)) continue;
|
|
75
|
+
const built = toolOperation(spec, path, method.toUpperCase(), operation, options.local === true);
|
|
76
|
+
if (built !== null && !byName.has(built.name)) byName.set(built.name, built);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...byName.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
80
|
+
}
|
|
81
|
+
function toolDefinitions(spec, scopes, options = {}) {
|
|
82
|
+
return toolOperations(spec, options).filter((operation) => scopes.includes(operation.scope)).map((operation) => operation.definition);
|
|
83
|
+
}
|
|
84
|
+
function toolRequest(operation, args) {
|
|
85
|
+
const { [IDEMPOTENCY_KEY]: idempotencyKey, ...rest } = args;
|
|
86
|
+
const invalid = [];
|
|
87
|
+
for (const name of [...operation.pathNames, ...operation.queryNames]) {
|
|
88
|
+
const value = rest[name];
|
|
89
|
+
if (value !== void 0 && !isScalar(value)) invalid.push(name);
|
|
90
|
+
}
|
|
91
|
+
for (const name of operation.pathNames) {
|
|
92
|
+
const value = rest[name];
|
|
93
|
+
if (value === void 0 || value === "" || value === "." || value === "..") {
|
|
94
|
+
if (!invalid.includes(name)) invalid.push(name);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (idempotencyKey !== void 0 && (operation.risk === "read" || typeof idempotencyKey !== "string" || idempotencyKey === "")) {
|
|
98
|
+
invalid.push(IDEMPOTENCY_KEY);
|
|
99
|
+
}
|
|
100
|
+
if (invalid.length > 0) return { invalid };
|
|
101
|
+
const path = operation.path.replace(PATH_PARAM, (_, name) => encodeURIComponent(String(rest[name])));
|
|
102
|
+
const query = {};
|
|
103
|
+
const body = {};
|
|
104
|
+
for (const [name, value] of Object.entries(rest)) {
|
|
105
|
+
if (operation.pathNames.includes(name)) continue;
|
|
106
|
+
if (operation.queryNames.includes(name)) query[name] = value;
|
|
107
|
+
else body[name] = value;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
method: operation.method,
|
|
111
|
+
path,
|
|
112
|
+
query,
|
|
113
|
+
body: operation.method === "GET" || Object.keys(body).length === 0 ? void 0 : body,
|
|
114
|
+
idempotencyKey
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function toolOperation(spec, path, method, operation, local) {
|
|
118
|
+
const name = operation["x-infraone-tool"];
|
|
119
|
+
const scope = operation["x-infraone-scope"];
|
|
120
|
+
if (typeof name !== "string" || !TOOL_NAME.test(name) || typeof scope !== "string" || scope === "") return null;
|
|
121
|
+
const rawRisk = operation["x-infraone-risk"];
|
|
122
|
+
const risk = typeof rawRisk === "string" && RISKS.includes(rawRisk) ? rawRisk : "destructive";
|
|
123
|
+
const parameters = (operation.parameters ?? []).map((parameter) => resolveParameter(spec, parameter)).filter((parameter) => parameter !== null && (parameter.in === "path" || parameter.in === "query"));
|
|
124
|
+
const pathNames = [...path.matchAll(PATH_PARAM)].map((match) => match[1]);
|
|
125
|
+
const queryNames = parameters.filter((parameter) => parameter.in === "query").map((parameter) => parameter.name);
|
|
126
|
+
const properties = {};
|
|
127
|
+
const required = [];
|
|
128
|
+
for (const parameter of parameters) {
|
|
129
|
+
const schema = inline(spec, parameter.schema ?? { type: "string" }, 0);
|
|
130
|
+
properties[parameter.name] = typeof parameter.description === "string" ? { ...schema, description: parameter.description } : schema;
|
|
131
|
+
if (parameter.required === true || parameter.in === "path") required.push(parameter.name);
|
|
132
|
+
}
|
|
133
|
+
for (const name2 of pathNames) {
|
|
134
|
+
if (!(name2 in properties)) properties[name2] = { type: "string" };
|
|
135
|
+
if (!required.includes(name2)) required.push(name2);
|
|
136
|
+
}
|
|
137
|
+
const bodySchema = inline(spec, operation.requestBody?.content?.["application/json"]?.schema, 0);
|
|
138
|
+
const hasBody = isRecord(operation.requestBody?.content?.["application/json"]?.schema);
|
|
139
|
+
const kind = operation["x-infraone-transfer"];
|
|
140
|
+
if (local && kind === "upload" && !hasBody) return null;
|
|
141
|
+
const transfer = local && kind === "upload" && method === "POST" && isRecord(bodySchema.properties?.["files"]) ? "upload" : local && kind === "download" && method === "GET" ? "download" : null;
|
|
142
|
+
for (const [property, schema] of Object.entries(bodySchema.properties ?? {})) {
|
|
143
|
+
if (transfer === "upload" && property === "files") continue;
|
|
144
|
+
properties[property] = schema;
|
|
145
|
+
}
|
|
146
|
+
for (const property of bodySchema.required ?? []) {
|
|
147
|
+
if (transfer === "upload" && property === "files") continue;
|
|
148
|
+
if (!required.includes(property)) required.push(property);
|
|
149
|
+
}
|
|
150
|
+
if (transfer === "upload") {
|
|
151
|
+
properties[PATHS] = PATHS_PROPERTY;
|
|
152
|
+
properties[ON_CONFLICT] = ON_CONFLICT_PROPERTY;
|
|
153
|
+
required.push(PATHS);
|
|
154
|
+
} else if (transfer === "download") {
|
|
155
|
+
properties[OUTPUT_PATH] = OUTPUT_PATH_PROPERTY;
|
|
156
|
+
properties[OVERWRITE] = OVERWRITE_PROPERTY;
|
|
157
|
+
required.push(OUTPUT_PATH);
|
|
158
|
+
} else if (risk !== "read") {
|
|
159
|
+
properties[IDEMPOTENCY_KEY] = IDEMPOTENCY_PROPERTY;
|
|
160
|
+
}
|
|
161
|
+
const description = operation.description ?? operation.summary ?? "";
|
|
162
|
+
const localNote = transfer === "upload" ? " Here, on this machine: `paths` names local files and folders, and this tool sends their bytes and finalizes the upload itself." : transfer === "download" ? " Here, on this machine: the file is saved to `output_path` instead of returning its URL." : "";
|
|
163
|
+
return {
|
|
164
|
+
name,
|
|
165
|
+
method,
|
|
166
|
+
path,
|
|
167
|
+
risk,
|
|
168
|
+
scope,
|
|
169
|
+
pathNames,
|
|
170
|
+
queryNames,
|
|
171
|
+
hasBody,
|
|
172
|
+
transfer,
|
|
173
|
+
definition: {
|
|
174
|
+
name,
|
|
175
|
+
title: operation.summary ?? name,
|
|
176
|
+
description: description + localNote,
|
|
177
|
+
inputSchema: { type: "object", properties, required, additionalProperties: false },
|
|
178
|
+
annotations: {
|
|
179
|
+
// A download saved to disk changes this machine.
|
|
180
|
+
readOnlyHint: risk === "read" && transfer === null,
|
|
181
|
+
// A local download writes a file, and with `overwrite` replaces one.
|
|
182
|
+
destructiveHint: risk === "destructive" || transfer === "download",
|
|
183
|
+
idempotentHint: risk === "read" && transfer === null,
|
|
184
|
+
openWorldHint: risk === "outward"
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function inline(spec, schema, depth) {
|
|
190
|
+
if (!isRecord(schema)) return {};
|
|
191
|
+
if (typeof schema["$ref"] === "string") {
|
|
192
|
+
if (depth >= MAX_REF_DEPTH) return {};
|
|
193
|
+
const name = schema["$ref"].replace(/^#\/components\/schemas\//, "");
|
|
194
|
+
return inline(spec, spec.components?.schemas?.[name], depth + 1);
|
|
195
|
+
}
|
|
196
|
+
const copy = {};
|
|
197
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
198
|
+
if (key === "properties" && isRecord(value)) {
|
|
199
|
+
copy[key] = Object.fromEntries(Object.entries(value).map(([name, property]) => [name, inline(spec, property, depth + 1)]));
|
|
200
|
+
} else if (SUBSCHEMA_KEYS.has(key) && isRecord(value)) {
|
|
201
|
+
copy[key] = inline(spec, value, depth + 1);
|
|
202
|
+
} else if (SUBSCHEMA_LIST_KEYS.has(key) && Array.isArray(value)) {
|
|
203
|
+
copy[key] = value.map((item) => inline(spec, item, depth + 1));
|
|
204
|
+
} else {
|
|
205
|
+
copy[key] = value;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return copy;
|
|
209
|
+
}
|
|
210
|
+
function resolveParameter(spec, parameter) {
|
|
211
|
+
if ("$ref" in parameter) {
|
|
212
|
+
const name = parameter.$ref.replace(/^#\/components\/parameters\//, "");
|
|
213
|
+
return spec.components?.parameters?.[name] ?? null;
|
|
214
|
+
}
|
|
215
|
+
return typeof parameter.name === "string" && typeof parameter.in === "string" ? parameter : null;
|
|
216
|
+
}
|
|
217
|
+
function isScalar(value) {
|
|
218
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
219
|
+
}
|
|
220
|
+
function isRecord(value) {
|
|
221
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/mcp/infraone.ts
|
|
225
|
+
var MCP_CLIENT = "mcp-local";
|
|
226
|
+
var POLL_INTERVAL_MS = 2e3;
|
|
227
|
+
var POLL_ATTEMPTS = 60;
|
|
228
|
+
var MAX_ELICITATION_MS = 10 * 6e4;
|
|
229
|
+
var FILES_PER_SESSION = 20;
|
|
230
|
+
var FOLDER_FILES_PER_SESSION = 100;
|
|
231
|
+
var NEVER_SENT = /* @__PURE__ */ new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "CERT_HAS_EXPIRED", "DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "ERR_TLS_CERT_ALTNAME_INVALID"]);
|
|
232
|
+
var NotSignedIn = class extends Error {
|
|
233
|
+
};
|
|
234
|
+
function buildInfraoneServer(options = {}) {
|
|
235
|
+
const env = options.env ?? process.env;
|
|
236
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
237
|
+
const server = new Server({ name: "infraone", version: CLI_VERSION }, { capabilities: { tools: { listChanged: true } } });
|
|
238
|
+
let loaded = null;
|
|
239
|
+
const pending = /* @__PURE__ */ new Map();
|
|
240
|
+
const config = () => {
|
|
241
|
+
const resolved = resolveConfig(env);
|
|
242
|
+
if (!resolved.ok) throw new NotSignedIn(resolved.message);
|
|
243
|
+
return resolved.config;
|
|
244
|
+
};
|
|
245
|
+
const client = (current, headers = {}, seen) => new InfraoneClient(current.apiUrl, current.token, localFetch(options.fetchImpl ?? globalThis.fetch, headers, seen));
|
|
246
|
+
const spec = async (current, fresh = false) => {
|
|
247
|
+
if (fresh || loaded === null || loaded.source !== "server") {
|
|
248
|
+
const refreshed = await refreshSpec(current, client(current));
|
|
249
|
+
if (refreshed !== null) loaded = refreshed;
|
|
250
|
+
else loaded ??= await loadSpec(current);
|
|
251
|
+
}
|
|
252
|
+
return loaded;
|
|
253
|
+
};
|
|
254
|
+
const observe = async (current, answer) => {
|
|
255
|
+
if (answer.fingerprint === null || loaded === null || answer.fingerprint === loaded.fingerprint) return;
|
|
256
|
+
const before = JSON.stringify(toolOperations(loaded.spec, LOCAL).map((operation) => operation.definition));
|
|
257
|
+
const after = await spec(current, true);
|
|
258
|
+
if (JSON.stringify(toolOperations(after.spec, LOCAL).map((operation) => operation.definition)) !== before) await server.sendToolListChanged().catch(() => void 0);
|
|
259
|
+
};
|
|
260
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
261
|
+
const current = config();
|
|
262
|
+
if (current.token === null) throw new McpError(ErrorCode.InvalidRequest, notSignedIn(current));
|
|
263
|
+
const { spec: served } = await spec(current);
|
|
264
|
+
let scopes;
|
|
265
|
+
try {
|
|
266
|
+
scopes = (await describeSession(client(current)))?.scopes ?? null;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
269
|
+
throw new McpError(ErrorCode.InternalError, `Cannot read the credential's scopes from ${current.apiUrl} (${reason}), so no tools are listed.`);
|
|
270
|
+
}
|
|
271
|
+
if (scopes === null) throw new McpError(ErrorCode.InternalError, `${current.apiUrl} did not describe the credential's scopes, so no tools are listed.`);
|
|
272
|
+
return { tools: toolDefinitions(served, scopes, LOCAL) };
|
|
273
|
+
});
|
|
274
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
275
|
+
const name = request.params.name;
|
|
276
|
+
const args = request.params.arguments ?? {};
|
|
277
|
+
let current;
|
|
278
|
+
try {
|
|
279
|
+
current = config();
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
282
|
+
}
|
|
283
|
+
let operation = find((await spec(current)).spec, name);
|
|
284
|
+
if (operation === null) operation = find((await spec(current, true)).spec, name);
|
|
285
|
+
if (operation === null) return failure(`Unknown tool: ${name}`);
|
|
286
|
+
const tool = operation;
|
|
287
|
+
if (current.token === null) {
|
|
288
|
+
return failure(notSignedIn(current));
|
|
289
|
+
}
|
|
290
|
+
if (operation.transfer !== null) return transfer(operation, (await spec(current)).spec);
|
|
291
|
+
const built = toolRequest(operation, args);
|
|
292
|
+
if ("invalid" in built) return failure(JSON.stringify({ error: "invalid_arguments", message: `Invalid arguments: ${built.invalid.join(", ")}` }));
|
|
293
|
+
const key = pendingKey(name, args);
|
|
294
|
+
const remembered = live(pending, key);
|
|
295
|
+
const call = {
|
|
296
|
+
...built,
|
|
297
|
+
idempotencyKey: built.idempotencyKey ?? remembered?.idempotencyKey ?? (operation.risk === "read" ? void 0 : randomUUID())
|
|
298
|
+
};
|
|
299
|
+
const send = async (confirmation) => {
|
|
300
|
+
const answer = await client(current, confirmation === null ? {} : { "infraone-confirmation": confirmation }).request(call.method, call.path, {
|
|
301
|
+
query: call.query,
|
|
302
|
+
body: call.body,
|
|
303
|
+
...call.idempotencyKey === void 0 ? {} : { idempotencyKey: call.idempotencyKey }
|
|
304
|
+
});
|
|
305
|
+
await observe(current, answer);
|
|
306
|
+
return answer;
|
|
307
|
+
};
|
|
308
|
+
try {
|
|
309
|
+
const answer = await send(remembered?.id ?? null);
|
|
310
|
+
pending.delete(key);
|
|
311
|
+
return success(answer);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
const confirmation = pendingConfirmation(error);
|
|
314
|
+
if (confirmation !== null) {
|
|
315
|
+
pending.set(key, { ...confirmation, idempotencyKey: call.idempotencyKey });
|
|
316
|
+
return confirm(confirmation);
|
|
317
|
+
}
|
|
318
|
+
if (remembered !== void 0 && isInvalidConfirmation(error)) return awaitingApproval(remembered, error);
|
|
319
|
+
return failed(error, tool, call.idempotencyKey);
|
|
320
|
+
}
|
|
321
|
+
function confirm(confirmation) {
|
|
322
|
+
return confirmThen(confirmation, key, send, tool, call.idempotencyKey);
|
|
323
|
+
}
|
|
324
|
+
async function transfer(transferTool, served) {
|
|
325
|
+
const { [PATHS]: paths, [ON_CONFLICT]: onConflict, [OUTPUT_PATH]: outputPath, [OVERWRITE]: overwrite, ...apiArgs } = args;
|
|
326
|
+
const invalid = [];
|
|
327
|
+
if (transferTool.transfer === "upload") {
|
|
328
|
+
if (!Array.isArray(paths) || paths.length === 0 || !paths.every((path) => typeof path === "string" && isAbsolute(path))) invalid.push(PATHS);
|
|
329
|
+
if (onConflict !== void 0 && !["keep", "replace", "skip"].includes(onConflict)) invalid.push(ON_CONFLICT);
|
|
330
|
+
} else {
|
|
331
|
+
if (typeof outputPath !== "string" || !isAbsolute(outputPath)) invalid.push(OUTPUT_PATH);
|
|
332
|
+
if (overwrite !== void 0 && typeof overwrite !== "boolean") invalid.push(OVERWRITE);
|
|
333
|
+
}
|
|
334
|
+
const built2 = toolRequest(transferTool, apiArgs);
|
|
335
|
+
if ("invalid" in built2) invalid.push(...built2.invalid);
|
|
336
|
+
if (invalid.length > 0 || "invalid" in built2) {
|
|
337
|
+
return failure(JSON.stringify({ error: "invalid_arguments", message: `Invalid arguments: ${invalid.join(", ")}` }));
|
|
338
|
+
}
|
|
339
|
+
const operations = commandSpecs(served);
|
|
340
|
+
const command = operations.find((candidate) => candidate.tool === transferTool.name);
|
|
341
|
+
const kind = command === void 0 ? null : transferFor(command);
|
|
342
|
+
if (command === void 0 || kind === null) return failure(`${transferTool.name} can't move files on this machine.`);
|
|
343
|
+
const replace = onConflict === "replace";
|
|
344
|
+
if (replace) {
|
|
345
|
+
const { files } = await collectFiles(paths).catch((error) => ({ files: null, error }));
|
|
346
|
+
if (files !== null) {
|
|
347
|
+
const loose = files.filter((file) => file.relativePath === null).length;
|
|
348
|
+
const folder = files.length - loose;
|
|
349
|
+
if (loose > 0 && folder > 0 || loose > FILES_PER_SESSION || folder > FOLDER_FILES_PER_SESSION) {
|
|
350
|
+
return failure(
|
|
351
|
+
`Replacing needs a person's approval for each upload, so on_conflict "replace" takes at most ${FILES_PER_SESSION} files, or ${FOLDER_FILES_PER_SESSION} from folders, and not both at once. Split the upload; nothing was sent.`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
let session = null;
|
|
357
|
+
const sessionUrl = apiUrlOf(current, built2.path);
|
|
358
|
+
const watch = async (url, answer) => {
|
|
359
|
+
if (url.origin + url.pathname !== sessionUrl || !answer.ok) return;
|
|
360
|
+
const body = await answer.clone().json().catch(() => null);
|
|
361
|
+
const id = body?.data?.id;
|
|
362
|
+
if (typeof id === "string") session = id;
|
|
363
|
+
};
|
|
364
|
+
const invocation = {
|
|
365
|
+
pathValues: {},
|
|
366
|
+
query: built2.query,
|
|
367
|
+
body: built2.body ?? {},
|
|
368
|
+
json: true,
|
|
369
|
+
all: false,
|
|
370
|
+
// The API asks a person for a replace (428): this server never confirms by itself.
|
|
371
|
+
yes: replace,
|
|
372
|
+
extra: Array.isArray(paths) ? paths : [],
|
|
373
|
+
options: { onConflict: onConflict ?? "keep", output: outputPath, force: overwrite === true }
|
|
374
|
+
};
|
|
375
|
+
const context = {
|
|
376
|
+
client: client(current, {}, watch),
|
|
377
|
+
command,
|
|
378
|
+
invocation,
|
|
379
|
+
path: built2.path,
|
|
380
|
+
operations,
|
|
381
|
+
confirmed: false,
|
|
382
|
+
interactive: false,
|
|
383
|
+
prompt: async () => "",
|
|
384
|
+
target: async () => new URL(current.apiUrl).host,
|
|
385
|
+
stderr: () => void 0
|
|
386
|
+
};
|
|
387
|
+
try {
|
|
388
|
+
return { content: [{ type: "text", text: JSON.stringify({ data: await kind.run(context) }) }] };
|
|
389
|
+
} catch (error) {
|
|
390
|
+
const confirmation = pendingConfirmation(error);
|
|
391
|
+
if (confirmation !== null && replace && session !== null) return finalizeLater(confirmation, session, served, built2.path, apiArgs);
|
|
392
|
+
if (error instanceof UsageError || error instanceof LocalError || error instanceof CommandError) return failure(error.message);
|
|
393
|
+
return failed(error, transferTool, void 0);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async function finalizeLater(confirmation, session, served, createPath, createArgs) {
|
|
397
|
+
const finalize = toolOperations(served, LOCAL).find((candidate) => {
|
|
398
|
+
const last = candidate.pathNames.at(-1);
|
|
399
|
+
return candidate.method === "POST" && candidate.pathNames.length === tool.pathNames.length + 1 && candidate.path === `${tool.path}/{${last}}/finalize`;
|
|
400
|
+
});
|
|
401
|
+
if (finalize === void 0) return failure(`The files were uploaded (session ${session}), but this API has no finalize tool to approve.`);
|
|
402
|
+
const finalizeArgs = { [ON_CONFLICT]: "replace" };
|
|
403
|
+
tool.pathNames.forEach((name2, index) => {
|
|
404
|
+
finalizeArgs[finalize.pathNames[index]] = createArgs[name2];
|
|
405
|
+
});
|
|
406
|
+
finalizeArgs[finalize.pathNames.at(-1)] = session;
|
|
407
|
+
const finalizeKey = pendingKey(finalize.name, finalizeArgs);
|
|
408
|
+
const idempotencyKey = randomUUID();
|
|
409
|
+
pending.set(finalizeKey, { ...confirmation, idempotencyKey });
|
|
410
|
+
const path = `${createPath}/${encodeURIComponent(session)}/finalize`;
|
|
411
|
+
const sendFinalize = async (id) => {
|
|
412
|
+
const answer = await client(current, id === null ? {} : { "infraone-confirmation": id }).request("POST", path, {
|
|
413
|
+
body: { [ON_CONFLICT]: "replace" },
|
|
414
|
+
idempotencyKey
|
|
415
|
+
});
|
|
416
|
+
await observe(current, answer);
|
|
417
|
+
const status = await filed(`${createPath}/${encodeURIComponent(session)}`);
|
|
418
|
+
const data = answer.body["data"];
|
|
419
|
+
return status === null ? answer : { ...answer, body: { ...answer.body, data: { ...isRecord2(data) ? data : {}, ...status } } };
|
|
420
|
+
};
|
|
421
|
+
async function filed(sessionPath) {
|
|
422
|
+
const deadline = Date.now() + MAX_ELICITATION_MS;
|
|
423
|
+
let wait = 500;
|
|
424
|
+
for (; ; ) {
|
|
425
|
+
let data;
|
|
426
|
+
try {
|
|
427
|
+
data = (await client(current).request("GET", sessionPath)).body["data"];
|
|
428
|
+
} catch {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
const status = isRecord2(data) ? data["status"] : void 0;
|
|
432
|
+
if (status === "completed") return { status, result: isRecord2(data) ? data["result"] ?? null : null };
|
|
433
|
+
if (status === "failed") throw new CommandError(`The files were uploaded but could not be filed (session ${session}).`);
|
|
434
|
+
if (Date.now() > deadline) return { status };
|
|
435
|
+
await sleep(wait);
|
|
436
|
+
wait = Math.min(5e3, wait * 2);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return confirmThen(confirmation, finalizeKey, sendFinalize, finalize, idempotencyKey, { tool: finalize.name, arguments: finalizeArgs });
|
|
440
|
+
}
|
|
441
|
+
async function confirmThen(confirmation, key2, send2, tool2, idempotencyKey, next) {
|
|
442
|
+
const elicitation = server.getClientCapabilities()?.elicitation;
|
|
443
|
+
if (elicitation === void 0) return approvalLink(confirmation, next);
|
|
444
|
+
const url = elicitation.url !== void 0;
|
|
445
|
+
const message = `Approve or deny this in Infra One${describe(confirmation.summary)}. ` + (url ? "" : `Open ${confirmation.approvalUrl}, decide there, then continue here. `) + "It runs only once you approve it there.";
|
|
446
|
+
const wait = Math.min(Math.max(confirmation.expiresAt - Date.now(), 1e3), MAX_ELICITATION_MS);
|
|
447
|
+
let action;
|
|
448
|
+
try {
|
|
449
|
+
const result = await server.elicitInput(
|
|
450
|
+
url ? { mode: "url", message, url: confirmation.approvalUrl, elicitationId: confirmation.id } : { message, requestedSchema: { type: "object", properties: {} } },
|
|
451
|
+
{ timeout: wait, signal: extra.signal }
|
|
452
|
+
);
|
|
453
|
+
action = result.action;
|
|
454
|
+
} catch {
|
|
455
|
+
return approvalLink(confirmation, next);
|
|
456
|
+
}
|
|
457
|
+
if (action !== "accept") {
|
|
458
|
+
pending.delete(key2);
|
|
459
|
+
return failure(next === void 0 ? "Not confirmed, so nothing was done." : "Not confirmed: the files were uploaded but not filed, and nothing was replaced.");
|
|
460
|
+
}
|
|
461
|
+
for (let attempt = 0; attempt < POLL_ATTEMPTS && !extra.signal.aborted; attempt++) {
|
|
462
|
+
try {
|
|
463
|
+
const answer = await send2(confirmation.id);
|
|
464
|
+
pending.delete(key2);
|
|
465
|
+
if (url) await server.createElicitationCompletionNotifier(confirmation.id)().catch(() => void 0);
|
|
466
|
+
return success(answer);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (!isInvalidConfirmation(error)) return failed(error, tool2, idempotencyKey);
|
|
469
|
+
if (Date.now() >= confirmation.expiresAt) break;
|
|
470
|
+
await sleep(POLL_INTERVAL_MS);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return approvalLink(confirmation, next);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
return server;
|
|
477
|
+
}
|
|
478
|
+
async function startInfraoneMcp(options = {}) {
|
|
479
|
+
const server = buildInfraoneServer(options);
|
|
480
|
+
const closed = new Promise((resolve) => {
|
|
481
|
+
server.onclose = () => resolve();
|
|
482
|
+
process.stdin.once("end", () => resolve());
|
|
483
|
+
});
|
|
484
|
+
await server.connect(new StdioServerTransport());
|
|
485
|
+
await closed;
|
|
486
|
+
}
|
|
487
|
+
function notSignedIn(current) {
|
|
488
|
+
return `Not signed in to ${current.apiUrl}. Run \`infraone login\` in a terminal (or set INFRAONE_TOKEN for headless use) \u2014 the MCP server never opens a browser itself.`;
|
|
489
|
+
}
|
|
490
|
+
var LOCAL = { local: true };
|
|
491
|
+
function isRecord2(value) {
|
|
492
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
493
|
+
}
|
|
494
|
+
function find(spec, name) {
|
|
495
|
+
return toolOperations(spec, LOCAL).find((operation) => operation.name === name) ?? null;
|
|
496
|
+
}
|
|
497
|
+
function apiUrlOf(current, path) {
|
|
498
|
+
const base = current.apiUrl.replace(/\/+$/, "");
|
|
499
|
+
return new URL(/\/v\d+$/.test(base) ? base + path.replace(/^\/v\d+(?=\/|$)/, "") : base + path).href;
|
|
500
|
+
}
|
|
501
|
+
function localFetch(fetchImpl, headers, seen) {
|
|
502
|
+
return (async (input, init) => {
|
|
503
|
+
const merged = new Headers(init?.headers);
|
|
504
|
+
merged.set("infraone-client", MCP_CLIENT);
|
|
505
|
+
merged.delete("infraone-confirm");
|
|
506
|
+
for (const [name, value] of Object.entries(headers)) merged.set(name, value);
|
|
507
|
+
const answer = await fetchImpl(input, { ...init, headers: Object.fromEntries(merged) });
|
|
508
|
+
await seen?.(new URL(String(input instanceof Request ? input.url : input)), answer);
|
|
509
|
+
return answer;
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
function pendingKey(name, args) {
|
|
513
|
+
const { idempotency_key: _key, ...rest } = args;
|
|
514
|
+
return `${name}\0${canonical(rest)}`;
|
|
515
|
+
}
|
|
516
|
+
function canonical(value) {
|
|
517
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
518
|
+
if (typeof value === "object" && value !== null) {
|
|
519
|
+
const entries = Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
|
520
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
|
|
521
|
+
}
|
|
522
|
+
return JSON.stringify(value) ?? "null";
|
|
523
|
+
}
|
|
524
|
+
function live(pending, key) {
|
|
525
|
+
const found = pending.get(key);
|
|
526
|
+
if (found !== void 0 && found.expiresAt <= Date.now()) {
|
|
527
|
+
pending.delete(key);
|
|
528
|
+
return void 0;
|
|
529
|
+
}
|
|
530
|
+
return found;
|
|
531
|
+
}
|
|
532
|
+
function pendingConfirmation(error) {
|
|
533
|
+
if (!(error instanceof HttpError) || error.status !== 428) return null;
|
|
534
|
+
const confirmation = error.body["confirmation"];
|
|
535
|
+
if (typeof confirmation !== "object" || confirmation === null) return null;
|
|
536
|
+
const { id, approval_url: approvalUrl, expires_at: expiresAt, summary } = confirmation;
|
|
537
|
+
if (typeof id !== "string" || id === "" || typeof approvalUrl !== "string" || approvalUrl === "") return null;
|
|
538
|
+
const expiry = typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN;
|
|
539
|
+
return { id, approvalUrl, expiresAt: Number.isNaN(expiry) ? Date.now() + 10 * 6e4 : expiry, summary };
|
|
540
|
+
}
|
|
541
|
+
function isInvalidConfirmation(error) {
|
|
542
|
+
return error instanceof HttpError && error.status === 409 && error.body["error"] === "confirmation_invalid";
|
|
543
|
+
}
|
|
544
|
+
function approvalLink(confirmation, next) {
|
|
545
|
+
return failure(
|
|
546
|
+
JSON.stringify({
|
|
547
|
+
error: "confirmation_required",
|
|
548
|
+
message: next === void 0 ? "A person must approve this in Infra One first; nothing was done yet. Give them approval_url to open, and once they approved it, call this tool again with the same arguments." : `The files were uploaded, but replacing existing files needs a person's approval, so nothing is filed yet. Give them approval_url to open, and once they approved it, call ${next.tool} with then_call.arguments (not this tool again).`,
|
|
549
|
+
approval_url: confirmation.approvalUrl,
|
|
550
|
+
expires_at: new Date(confirmation.expiresAt).toISOString(),
|
|
551
|
+
summary: confirmation.summary ?? null,
|
|
552
|
+
...next === void 0 ? {} : { then_call: next }
|
|
553
|
+
})
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
function awaitingApproval(confirmation, error) {
|
|
557
|
+
return failure(
|
|
558
|
+
JSON.stringify({
|
|
559
|
+
error: "confirmation_invalid",
|
|
560
|
+
message: "Not approved yet, so nothing was done. Once the person approved it at approval_url, call this tool again with the same arguments. A denied approval, or one whose target changed, stops matching; after it expires, calling again asks anew.",
|
|
561
|
+
approval_url: confirmation.approvalUrl,
|
|
562
|
+
expires_at: new Date(confirmation.expiresAt).toISOString(),
|
|
563
|
+
request_id: error.body["request_id"] ?? null
|
|
564
|
+
})
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
function describe(summary) {
|
|
568
|
+
if (typeof summary !== "object" || summary === null || Object.keys(summary).length === 0) return "";
|
|
569
|
+
return `: ${JSON.stringify(summary)}`;
|
|
570
|
+
}
|
|
571
|
+
function success(answer) {
|
|
572
|
+
return { content: [{ type: "text", text: JSON.stringify(answer.body) }] };
|
|
573
|
+
}
|
|
574
|
+
function failed(error, operation, idempotencyKey) {
|
|
575
|
+
if (error instanceof HttpError) return failure(JSON.stringify({ status: error.status, ...error.body, message: error.message }));
|
|
576
|
+
if (error instanceof TransportError) {
|
|
577
|
+
if (operation.risk !== "read" && !NEVER_SENT.has(error.code ?? "")) {
|
|
578
|
+
return failure(
|
|
579
|
+
`Outcome unknown: the write may have reached the API (${error.message}). Check before calling again, or call again ` + (idempotencyKey === void 0 ? "with the same idempotency_key." : `with idempotency_key: ${idempotencyKey}`)
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
return failure(`Cannot reach the API: ${error.message}`);
|
|
583
|
+
}
|
|
584
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
585
|
+
}
|
|
586
|
+
function failure(text) {
|
|
587
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
588
|
+
}
|
|
589
|
+
export {
|
|
590
|
+
MCP_CLIENT,
|
|
591
|
+
buildInfraoneServer,
|
|
592
|
+
startInfraoneMcp
|
|
593
|
+
};
|