constructor-research-mcp 0.3.2 → 0.4.0-beta.2
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/INSTALL.md +24 -25
- package/README.md +44 -48
- package/dist/api.d.ts +11 -30
- package/dist/api.js +53 -130
- package/dist/config.d.ts +11 -22
- package/dist/config.js +15 -47
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.js +26 -13
- package/dist/desk.d.ts +23 -0
- package/dist/desk.js +52 -0
- package/dist/files.d.ts +6 -0
- package/dist/files.js +46 -1
- package/dist/key.d.ts +9 -0
- package/dist/key.js +35 -0
- package/dist/location.d.ts +3 -1
- package/dist/location.js +13 -3
- package/dist/logs.js +2 -2
- package/dist/runtime.js +4 -4
- package/dist/schemas.d.ts +0 -5
- package/dist/schemas.js +0 -18
- package/dist/server.js +64 -106
- package/dist/set-key.d.ts +2 -0
- package/dist/set-key.js +107 -0
- package/dist/store.d.ts +1 -1
- package/dist/store.js +1 -1
- package/dist/wait.js +3 -6
- package/examples/claude-hooks.json +1 -1
- package/examples/claude.mcp.json +1 -1
- package/examples/codex-hooks.json +1 -1
- package/examples/codex.toml +1 -1
- package/examples/opencode-plugin.js +1 -1
- package/examples/opencode-v2.json +1 -1
- package/examples/opencode.json +1 -1
- package/package.json +7 -6
package/dist/files.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
3
|
import { lstat, open, readdir, readFile } from "node:fs/promises";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
|
+
import { localPath } from "./desk.js";
|
|
5
6
|
import { ToolError } from "./api.js";
|
|
6
7
|
import { listFilesInput, createFolderInput, syncFilesInput, movePathInput, deletePathInput, } from "./schemas.js";
|
|
7
8
|
/** Single PUTs above ~9 MB are rejected with 413, so larger files go in ranged chunks. */
|
|
@@ -201,8 +202,20 @@ export class FileManager {
|
|
|
201
202
|
? `Cannot create ${parts.join("/")}: a parent path is missing or is a file.`
|
|
202
203
|
: `Folder creation returned HTTP ${r.status}.`, r.status);
|
|
203
204
|
}
|
|
205
|
+
/** The desk this MCP runs inside, when `desk` is it. */
|
|
206
|
+
here(desk) {
|
|
207
|
+
const d = this.api.config.localDesk;
|
|
208
|
+
return d && d.deskId === desk ? d : undefined;
|
|
209
|
+
}
|
|
210
|
+
/** File tools never touch the desk the MCP runs in: its files are local. */
|
|
211
|
+
notHere(desk) {
|
|
212
|
+
const d = this.here(desk);
|
|
213
|
+
if (d)
|
|
214
|
+
throw new ToolError("local_desk", `This MCP runs inside desk ${desk}: its disk is the local folder ${d.root}. Read and change files there directly with your own file and shell tools; nothing needs uploading. When the Dockerfile is ready, call deploy_app or build_image with the Dockerfile path inside ${d.root} (for example /Dockerfile).`);
|
|
215
|
+
}
|
|
204
216
|
async list(raw) {
|
|
205
217
|
const i = listFilesInput.parse(raw);
|
|
218
|
+
this.notHere(i.desk_id);
|
|
206
219
|
await this.engine.filesDesk(i.project_id, i.desk_id);
|
|
207
220
|
const start = segs(i.path);
|
|
208
221
|
const entries = [];
|
|
@@ -238,6 +251,7 @@ export class FileManager {
|
|
|
238
251
|
}
|
|
239
252
|
async createFolder(raw) {
|
|
240
253
|
const i = createFolderInput.parse(raw);
|
|
254
|
+
this.notHere(i.desk_id);
|
|
241
255
|
await this.engine.filesDesk(i.project_id, i.desk_id);
|
|
242
256
|
const parts = segs(i.path);
|
|
243
257
|
const created = [];
|
|
@@ -255,6 +269,7 @@ export class FileManager {
|
|
|
255
269
|
}
|
|
256
270
|
async move(raw) {
|
|
257
271
|
const i = movePathInput.parse(raw);
|
|
272
|
+
this.notHere(i.desk_id);
|
|
258
273
|
await this.engine.filesDesk(i.project_id, i.desk_id);
|
|
259
274
|
const desk = i.desk_id;
|
|
260
275
|
const plan = [];
|
|
@@ -318,6 +333,7 @@ export class FileManager {
|
|
|
318
333
|
}
|
|
319
334
|
async remove(raw) {
|
|
320
335
|
const i = deletePathInput.parse(raw);
|
|
336
|
+
this.notHere(i.desk_id);
|
|
321
337
|
await this.engine.filesDesk(i.project_id, i.desk_id);
|
|
322
338
|
const desk = i.desk_id;
|
|
323
339
|
const targets = [];
|
|
@@ -374,6 +390,7 @@ export class FileManager {
|
|
|
374
390
|
}
|
|
375
391
|
async sync(raw) {
|
|
376
392
|
const i = syncFilesInput.parse(raw);
|
|
393
|
+
this.notHere(i.desk_id);
|
|
377
394
|
const begun = Date.now();
|
|
378
395
|
await this.engine.filesDesk(i.project_id, i.desk_id);
|
|
379
396
|
const desk = i.desk_id;
|
|
@@ -597,7 +614,7 @@ export class FileManager {
|
|
|
597
614
|
}
|
|
598
615
|
catch (err) {
|
|
599
616
|
if (err instanceof ToolError &&
|
|
600
|
-
["
|
|
617
|
+
["needs_key", "connection_error"].includes(err.code)) {
|
|
601
618
|
stop ??= err;
|
|
602
619
|
remaining++;
|
|
603
620
|
return;
|
|
@@ -632,6 +649,11 @@ export class FileManager {
|
|
|
632
649
|
const missing = [];
|
|
633
650
|
const warnings = [];
|
|
634
651
|
for (const b of sources) {
|
|
652
|
+
const local = this.here(b.desk_id);
|
|
653
|
+
if (local) {
|
|
654
|
+
await this.checkLocal(local, b, missing, warnings);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
635
657
|
await this.engine.filesDesk(project, b.desk_id);
|
|
636
658
|
const parts = segs(b.dockerfile);
|
|
637
659
|
const r = await this.api.storage("GET", b.desk_id, href(parts));
|
|
@@ -661,6 +683,29 @@ export class FileManager {
|
|
|
661
683
|
}
|
|
662
684
|
return { missing, warnings: [...new Set(warnings)] };
|
|
663
685
|
}
|
|
686
|
+
/** The same checks as on a disk, for the desk this MCP runs in: its files are local. */
|
|
687
|
+
async checkLocal(desk, b, missing, warnings) {
|
|
688
|
+
const parts = segs(b.dockerfile);
|
|
689
|
+
let text;
|
|
690
|
+
try {
|
|
691
|
+
text = (await readFile(localPath(desk, b.dockerfile), "utf8")).slice(0, 256 * 1024);
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
missing.push(`${b.service}: ${parts.join("/")} (in ${desk.root})`);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
warnings.push(...lintDockerfile(parts.join("/"), text, b.ports).map((w) => `${b.service}: ${w}`));
|
|
698
|
+
const dir = parts.slice(0, -1);
|
|
699
|
+
for (const conf of configsCopied(text)) {
|
|
700
|
+
const body = await readFile(localPath(desk, [...dir, ...segs(conf)].join("/")), "utf8").then((t) => t.slice(0, 64 * 1024), () => "");
|
|
701
|
+
if (/^\s*listen\s+\[::\]/m.test(body))
|
|
702
|
+
warnings.push(`${b.service}: ${conf} has "listen [::]". nginx exits on start when the cluster has no IPv6; keep only the IPv4 listen line (listen <port>;).`);
|
|
703
|
+
}
|
|
704
|
+
const names = await readdir(localPath(desk, dir.join("/"))).catch(() => []);
|
|
705
|
+
for (const name of names)
|
|
706
|
+
if (/^(docker-)?compose\.ya?ml$/.test(name))
|
|
707
|
+
warnings.push(`${b.service}: ${[...dir, name].join("/")} is ignored; docker-compose is not supported, so each service is configured in deploy_app.`);
|
|
708
|
+
}
|
|
664
709
|
/** Upload to a fresh temp file, move it over the target, then verify the server checksum. */
|
|
665
710
|
async upload(desk, f, target, replace) {
|
|
666
711
|
const tmp = [TEMP, TEMP_PREFIX + randomUUID()];
|
package/dist/key.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A saved key and the Constructor it belongs to. */
|
|
2
|
+
export interface SavedKey {
|
|
3
|
+
url: string;
|
|
4
|
+
key: string;
|
|
5
|
+
}
|
|
6
|
+
/** The saved key, or undefined when none was saved. A broken file throws. */
|
|
7
|
+
export declare function readKeyFile(file: string): Promise<SavedKey | undefined>;
|
|
8
|
+
/** Saves the key for one Constructor, mode 600, replacing the file in one step. */
|
|
9
|
+
export declare function saveKeyFile(file: string, url: string, key: string): Promise<void>;
|
package/dist/key.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { originOf } from "./config.js";
|
|
4
|
+
/** The saved key, or undefined when none was saved. A broken file throws. */
|
|
5
|
+
export async function readKeyFile(file) {
|
|
6
|
+
let text;
|
|
7
|
+
try {
|
|
8
|
+
text = await readFile(file, "utf8");
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
if (err?.code === "ENOENT")
|
|
12
|
+
return undefined;
|
|
13
|
+
throw new Error("unreadable");
|
|
14
|
+
}
|
|
15
|
+
let data;
|
|
16
|
+
try {
|
|
17
|
+
data = JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Never echo the contents: they hold the key.
|
|
21
|
+
throw new Error("not valid JSON");
|
|
22
|
+
}
|
|
23
|
+
if (typeof data?.key !== "string" || typeof data?.url !== "string")
|
|
24
|
+
throw new Error("expected url and key");
|
|
25
|
+
return { url: originOf(data.url), key: data.key };
|
|
26
|
+
}
|
|
27
|
+
/** Saves the key for one Constructor, mode 600, replacing the file in one step. */
|
|
28
|
+
export async function saveKeyFile(file, url, key) {
|
|
29
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
30
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
31
|
+
await writeFile(tmp, JSON.stringify({ url: originOf(url), key }) + "\n", {
|
|
32
|
+
mode: 0o600,
|
|
33
|
+
});
|
|
34
|
+
await rename(tmp, file);
|
|
35
|
+
}
|
package/dist/location.d.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { z } from "zod";
|
|
|
2
2
|
/** Where a tool acts: a desk (project + desk), a project only, or a deployment-shaped input whose schema already allows a missing project_id. */
|
|
3
3
|
export type Scope = "desk" | "project" | "deploy";
|
|
4
4
|
/** Tool-facing schema: the engine schema plus desk_url, with the IDs it supplies made optional. */
|
|
5
|
-
export declare function located(schema: z.ZodObject<any>, scope: Exclude<Scope, "deploy"
|
|
5
|
+
export declare function located(schema: z.ZodObject<any>, scope: Exclude<Scope, "deploy">,
|
|
6
|
+
/** Inside a desk the IDs default to it, so neither a link nor IDs are required. */
|
|
7
|
+
implied?: boolean): z.ZodObject<{
|
|
6
8
|
[x: string]: any;
|
|
7
9
|
project_id: z.ZodOptional<z.ZodString>;
|
|
8
10
|
desk_id?: z.ZodOptional<z.ZodString> | undefined;
|
package/dist/location.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { ToolError } from "./api.js";
|
|
2
|
+
import { VERSION } from "./config.js";
|
|
2
3
|
import { id, deskUrl } from "./schemas.js";
|
|
3
4
|
/** Tool-facing schema: the engine schema plus desk_url, with the IDs it supplies made optional. */
|
|
4
|
-
export function located(schema, scope
|
|
5
|
+
export function located(schema, scope,
|
|
6
|
+
/** Inside a desk the IDs default to it, so neither a link nor IDs are required. */
|
|
7
|
+
implied = false) {
|
|
8
|
+
const extended = schema.extend({
|
|
9
|
+
project_id: id.optional(),
|
|
10
|
+
...("desk_id" in schema.shape ? { desk_id: id.optional() } : {}),
|
|
11
|
+
desk_url: deskUrl.optional(),
|
|
12
|
+
});
|
|
13
|
+
if (implied)
|
|
14
|
+
return extended;
|
|
5
15
|
return schema
|
|
6
16
|
.extend({
|
|
7
17
|
project_id: id.optional(),
|
|
@@ -34,8 +44,8 @@ export function parseDeskUrl(link, baseUrl, fixed = false) {
|
|
|
34
44
|
const base = new URL(baseUrl).origin;
|
|
35
45
|
if (url.origin !== base)
|
|
36
46
|
bad("wrong_environment", fixed
|
|
37
|
-
? `The link is for ${url.origin}, but this MCP is fixed to ${base} by CONSTRUCTOR_BASE_URL. Ask the user for a link from ${base}.`
|
|
38
|
-
: `The link is for ${url.origin}, but the
|
|
47
|
+
? `The link is for ${url.origin}, but this MCP is fixed to ${base} by CONSTRUCTOR_BASE_URL or the desk it runs in. Ask the user for a link from ${base}.`
|
|
48
|
+
: `The link is for ${url.origin}, but the saved access key is for ${base}. If they work on ${url.origin}, the user saves a key for it in a terminal (it replaces the key for ${base}): npx -y -p constructor-research-mcp@${VERSION} constructor-research-set-key ${url.origin}. Never ask for the key in the chat.`);
|
|
39
49
|
return { project_id: m[1], desk_id: m[2] };
|
|
40
50
|
}
|
|
41
51
|
/** Replace desk_url with the IDs it carries; explicit IDs must agree with it. */
|
package/dist/logs.js
CHANGED
|
@@ -188,9 +188,9 @@ export class LogFollower {
|
|
|
188
188
|
catch (err) {
|
|
189
189
|
if (signal.aborted)
|
|
190
190
|
return;
|
|
191
|
-
if (err instanceof ToolError && err.code === "
|
|
191
|
+
if (err instanceof ToolError && err.code === "needs_key") {
|
|
192
192
|
log.error =
|
|
193
|
-
"
|
|
193
|
+
"Constructor rejected the access key while following this build log; the lines below may be incomplete.";
|
|
194
194
|
return;
|
|
195
195
|
}
|
|
196
196
|
if (++failures > 8) {
|
package/dist/runtime.js
CHANGED
|
@@ -3,7 +3,7 @@ import { terminal } from "./deployment.js";
|
|
|
3
3
|
import { ToolError } from "./api.js";
|
|
4
4
|
import { waitCommand } from "./config.js";
|
|
5
5
|
import { buildInput, deployInput, updateInput, lifecycleInput, getInput, } from "./schemas.js";
|
|
6
|
-
export const paused = new Set(["
|
|
6
|
+
export const paused = new Set(["needs_key", "needs_upload"]);
|
|
7
7
|
/** Tells the agent which unfinished deployments a new one ended, and what they changed that it does not. */
|
|
8
8
|
function replacedNote(replaced) {
|
|
9
9
|
if (!replaced?.length)
|
|
@@ -73,7 +73,7 @@ export class DeploymentRuntime {
|
|
|
73
73
|
await this.engine.api.withBudget(15000, () => this.engine.get({ ...i, resume: true }));
|
|
74
74
|
}
|
|
75
75
|
let op = await this.engine.store.load(i.deployment_id);
|
|
76
|
-
if (op?.status === "
|
|
76
|
+
if (op?.status === "needs_key" &&
|
|
77
77
|
i.resume &&
|
|
78
78
|
i.files_uploaded === undefined) {
|
|
79
79
|
await this.pause(i.deployment_id);
|
|
@@ -210,8 +210,8 @@ export class DeploymentRuntime {
|
|
|
210
210
|
: "background",
|
|
211
211
|
next_action: op.status === "failed"
|
|
212
212
|
? "Tell the user which stage/service failed and pass on the message. failure.log_tail has the last log lines when the MCP captured them; get_logs shows more. Do not investigate using browser or other tools."
|
|
213
|
-
: op.status === "
|
|
214
|
-
? `${this.engine.api.
|
|
213
|
+
: op.status === "needs_key"
|
|
214
|
+
? `${this.engine.api.keyHelp("Constructor rejected the access key, or none is set.")} Then check this deployment again.`
|
|
215
215
|
: op.status === "needs_upload"
|
|
216
216
|
? "Upload the listed files with sync_files, then call get_deployment with files_uploaded=true."
|
|
217
217
|
: op.status === "confirm_required"
|
package/dist/schemas.d.ts
CHANGED
|
@@ -225,8 +225,3 @@ export declare const syncFilesInput: z.ZodObject<{
|
|
|
225
225
|
dry_run: z.ZodDefault<z.ZodBoolean>;
|
|
226
226
|
confirm: z.ZodDefault<z.ZodBoolean>;
|
|
227
227
|
}, z.core.$strict>;
|
|
228
|
-
export declare const signInInput: z.ZodObject<{
|
|
229
|
-
url: z.ZodOptional<z.ZodString>;
|
|
230
|
-
wait_seconds: z.ZodDefault<z.ZodNumber>;
|
|
231
|
-
force: z.ZodDefault<z.ZodBoolean>;
|
|
232
|
-
}, z.core.$strict>;
|
package/dist/schemas.js
CHANGED
|
@@ -257,21 +257,3 @@ export const syncFilesInput = z.strictObject({
|
|
|
257
257
|
.default(false)
|
|
258
258
|
.describe("Needed only when existing files would be replaced: the first call returns the list for the user to approve once; repeat with confirm=true."),
|
|
259
259
|
});
|
|
260
|
-
export const signInInput = z.strictObject({
|
|
261
|
-
url: z
|
|
262
|
-
.string()
|
|
263
|
-
.max(2000)
|
|
264
|
-
.optional()
|
|
265
|
-
.describe("Any link to the user's Constructor: a desk link, or just its address, e.g. https://acme.constructor.app. The user signs in there and the MCP works there afterwards, replacing any earlier sign-in. Pass it when the user names their Constructor or gives a link; omit it to sign in where the MCP works now (https://constructor.app unless the user signed in elsewhere)."),
|
|
266
|
-
wait_seconds: z
|
|
267
|
-
.number()
|
|
268
|
-
.int()
|
|
269
|
-
.min(0)
|
|
270
|
-
.max(25)
|
|
271
|
-
.default(20)
|
|
272
|
-
.describe("How long to wait for the user to finish signing in."),
|
|
273
|
-
force: z
|
|
274
|
-
.boolean()
|
|
275
|
-
.default(false)
|
|
276
|
-
.describe("Open the window even though a renewable session is saved (to switch account)."),
|
|
277
|
-
});
|
package/dist/server.js
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { configFromEnv,
|
|
4
|
+
import { configFromEnv, VERSION } from "./config.js";
|
|
5
5
|
import { PlatformApi, ToolError } from "./api.js";
|
|
6
6
|
import { StateStore } from "./store.js";
|
|
7
|
-
import { DeploymentEngine
|
|
7
|
+
import { DeploymentEngine } from "./deployment.js";
|
|
8
8
|
import { DeploymentRuntime } from "./runtime.js";
|
|
9
9
|
import { FileManager, dockerignore } from "./files.js";
|
|
10
10
|
import { basename } from "node:path";
|
|
11
11
|
import { LogFollower } from "./logs.js";
|
|
12
12
|
const RULES = "Dockerfile rules: one Dockerfile per service named Dockerfile or <name>.dockerfile; docker-compose is not supported. The Dockerfile must build everything itself; no separate setup script the user runs before the build. The app listens on 0.0.0.0 on a fixed port; only one port per service can be public. Services reach each other through internal references (bare host:port by default, http:// only when scheme is set), never localhost; do not bake compose-style fallback hosts (http://db:5432) into ENV. Files uploaded to a desk lose the executable bit: RUN chmod 0755 any entrypoint script you COPY. In nginx configs use only the IPv4 listen line. A volume belongs to one service; services cannot share a volume. Volume data survives restarts and image changes but is wiped if the service is deleted or the volume is removed or changed. The build context is the folder containing the Dockerfile, exactly like a local docker build in that folder. Test the image locally (docker build/run) before uploading.";
|
|
13
13
|
import { located, resolveDeskUrl } from "./location.js";
|
|
14
|
+
import { deskInstructions, diskPath } from "./desk.js";
|
|
15
|
+
/** Tools whose project and desk default to the desk the MCP runs inside. */
|
|
16
|
+
const DESK_DEFAULT = new Set([
|
|
17
|
+
"build_image",
|
|
18
|
+
"list_images",
|
|
19
|
+
"list_files",
|
|
20
|
+
"create_folder",
|
|
21
|
+
"sync_files",
|
|
22
|
+
"move_path",
|
|
23
|
+
"delete_path",
|
|
24
|
+
"deploy_app",
|
|
25
|
+
"update_app",
|
|
26
|
+
"start_app",
|
|
27
|
+
"stop_app",
|
|
28
|
+
]);
|
|
14
29
|
import { ZodError } from "zod";
|
|
15
|
-
import { buildInput, listInput, deployInput, updateInput, lifecycleInput, getInput, logsInput, listFilesInput, createFolderInput, syncFilesInput, movePathInput, deletePathInput,
|
|
16
|
-
import { harnessOf, sessionFile } from "./config.js";
|
|
17
|
-
import { sessionHost } from "./session.js";
|
|
18
|
-
import { signIn } from "./signin.js";
|
|
30
|
+
import { buildInput, listInput, deployInput, updateInput, lifecycleInput, getInput, logsInput, listFilesInput, createFolderInput, syncFilesInput, movePathInput, deletePathInput, } from "./schemas.js";
|
|
19
31
|
async function main() {
|
|
20
32
|
const config = configFromEnv();
|
|
21
33
|
const api = new PlatformApi(config);
|
|
@@ -38,6 +50,28 @@ async function main() {
|
|
|
38
50
|
services.push(x);
|
|
39
51
|
continue;
|
|
40
52
|
}
|
|
53
|
+
// Inside the desk the MCP runs in, its project folder is the disk: nothing to upload.
|
|
54
|
+
const here = config.localDesk;
|
|
55
|
+
if (here && b.desk_id === here.deskId) {
|
|
56
|
+
const at = diskPath(here, b.local_path);
|
|
57
|
+
if (!at)
|
|
58
|
+
throw new ToolError("invalid_input", `Service ${x.name}: local_path ${b.local_path} is outside this desk's project folder ${here.root}, which is the desk's disk. Move the sources into ${here.root}, then call again.`);
|
|
59
|
+
const { local_path: _l, remote_path: _r, ...rest } = b;
|
|
60
|
+
services.push({
|
|
61
|
+
...x,
|
|
62
|
+
build: {
|
|
63
|
+
...rest,
|
|
64
|
+
dockerfile: "/" +
|
|
65
|
+
[
|
|
66
|
+
...at.split("/"),
|
|
67
|
+
...(b.dockerfile ?? "Dockerfile").split("/"),
|
|
68
|
+
]
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join("/"),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
41
75
|
if (!b.desk_id || !a.project_id)
|
|
42
76
|
throw new ToolError("invalid_input", `Service ${x.name}: local_path needs a disk. Pass desk_url (the desk link from the user) or build.desk_id with project_id.`);
|
|
43
77
|
const remote = (b.remote_path ?? basename(b.local_path))
|
|
@@ -117,12 +151,17 @@ async function main() {
|
|
|
117
151
|
}
|
|
118
152
|
: result;
|
|
119
153
|
};
|
|
154
|
+
/** Inside a desk, the agent edits its files in place instead of asking for a desk link. */
|
|
155
|
+
const withDesk = (text) => config.localDesk
|
|
156
|
+
? text.replace(/Where to act: ask the user for the Constructor desk link.*?if the user has not said where to deploy or where files live, ask\. /, deskInstructions(config.localDesk) + " ")
|
|
157
|
+
: text;
|
|
120
158
|
const server = new McpServer({ name: "constructor-research", version: VERSION }, {
|
|
121
|
-
instructions: `
|
|
159
|
+
instructions: withDesk(`Access: the MCP works with one Constructor and the user's personal access key for it, set up outside the chat. When a tool returns needs_key, pass its instructions on to the user; never ask for the key in the chat. Where to act: ask the user for the Constructor desk link (they open the desk in the browser and copy the address; it contains /projects/<project>/desks/<desk>) and pass it as desk_url. Do not pick a project or desk yourself; if the user has not said where to deploy or where files live, ask. Ask the user only before replacing, renaming or deleting files on a disk, once per request for the whole list (the tools return confirm_required); creating folders and uploading new files need no question. ${RULES} For a local app, pass build.local_path (the folder with the Dockerfile) and deploy_app uploads it first. Hardware: leave it out; the default is used. Set hardware minimums only when the app cannot run without a GPU or a lot of RAM. Use deploy_app/update_app once for the WHOLE application. The MCP builds, resolves service URLs, configures and starts services in the background. IDs are automatic. To wait, call get_deployment(wait_seconds=20) again as often as needed (a loop of such calls is fine); never perform the deployment steps yourself. When the user asks to deploy or build, wait until the deployment is finished before answering, then give the public URL or what failed. On failure, tell the user the failed stage/service; failure.log_tail and get_logs show the last build or service log lines. Do not use browser or debugging tools. For source files: sync_files to the desk before building (the MCP checks each Dockerfile on the disk and reports warnings); after a needs_upload pause, sync and call get_deployment with files_uploaded=true. Started is not readiness.`),
|
|
122
160
|
});
|
|
123
161
|
const register = (name, description, schema, fn, readOnly = false, budget = 20000, scope) => {
|
|
162
|
+
const here = config.localDesk && DESK_DEFAULT.has(name) ? config.localDesk : undefined;
|
|
124
163
|
if (scope && scope !== "deploy")
|
|
125
|
-
schema = located(schema, scope);
|
|
164
|
+
schema = located(schema, scope, !!here);
|
|
126
165
|
server.registerTool(name, {
|
|
127
166
|
description,
|
|
128
167
|
inputSchema: schema,
|
|
@@ -134,41 +173,21 @@ async function main() {
|
|
|
134
173
|
},
|
|
135
174
|
}, async (args) => {
|
|
136
175
|
try {
|
|
137
|
-
await api.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
name !== "sign_in" &&
|
|
152
|
-
before !== undefined &&
|
|
153
|
-
before.minutes < 3)
|
|
154
|
-
throw new ToolError("needs_auth", api.authHelp(`${what(before.renewable)} in ${Math.max(0, Math.floor(before.minutes))} min, too soon to start this operation.`));
|
|
155
|
-
let result = await api.withBudget(budget, () => fn(scope
|
|
156
|
-
? resolveDeskUrl(args, config.baseUrl, scope, config.baseUrlFixed)
|
|
176
|
+
await api.refresh();
|
|
177
|
+
const result = await api.withBudget(budget, () => fn(scope
|
|
178
|
+
? resolveDeskUrl(
|
|
179
|
+
// Inside a desk, a call that names no other desk acts on it.
|
|
180
|
+
here &&
|
|
181
|
+
!args?.desk_url &&
|
|
182
|
+
(!args?.project_id ||
|
|
183
|
+
args.project_id === here.projectId) &&
|
|
184
|
+
(!args?.desk_id || args.desk_id === here.deskId)
|
|
185
|
+
? {
|
|
186
|
+
...args,
|
|
187
|
+
desk_url: `${config.baseUrl}/research/projects/${here.projectId}/desks/${here.deskId}`,
|
|
188
|
+
}
|
|
189
|
+
: args, config.baseUrl, scope, config.baseUrlFixed)
|
|
157
190
|
: args));
|
|
158
|
-
const left = await api.authLeft().catch(() => undefined);
|
|
159
|
-
// A build can take half an hour; warn while there is still time to sign in again.
|
|
160
|
-
// A renewable session warns a day ahead.
|
|
161
|
-
const builds = ["deploy_app", "update_app", "build_image"].includes(name);
|
|
162
|
-
const limit = left?.renewable ? 24 * 60 : builds ? 40 : 10;
|
|
163
|
-
if (left !== undefined && left.minutes > 0 && left.minutes < limit)
|
|
164
|
-
result = {
|
|
165
|
-
...result,
|
|
166
|
-
auth_warning: `${what(left.renewable)} in ${left.minutes >= 120
|
|
167
|
-
? `${Math.floor(left.minutes / 60)} h`
|
|
168
|
-
: `${Math.floor(left.minutes)} min`}${builds && !terminal.has(result.status)
|
|
169
|
-
? "; builds often take 10–30 min, and when it ends the deployment pauses as needs_auth until the user signs in again"
|
|
170
|
-
: ""}. Ask the user to sign in again now: ${api.authHelp("")}`,
|
|
171
|
-
};
|
|
172
191
|
return {
|
|
173
192
|
content: [
|
|
174
193
|
{
|
|
@@ -176,10 +195,7 @@ async function main() {
|
|
|
176
195
|
text: result.text ??
|
|
177
196
|
(result.status
|
|
178
197
|
? `${result.status}${result.deployment_id ? ` (${result.deployment_id})` : ""}${result.message ? `: ${result.message}` : ""}`
|
|
179
|
-
: `Discovery result: ${result.total !== undefined ? result.total + " images" : Object.keys(result).join(", ")}. See structuredContent for details.`)
|
|
180
|
-
(result.auth_warning
|
|
181
|
-
? ` Warning: ${result.auth_warning}`
|
|
182
|
-
: ""),
|
|
198
|
+
: `Discovery result: ${result.total !== undefined ? result.total + " images" : Object.keys(result).join(", ")}. See structuredContent for details.`),
|
|
183
199
|
},
|
|
184
200
|
],
|
|
185
201
|
// A preformatted text (build logs) is not repeated in structuredContent.
|
|
@@ -226,62 +242,7 @@ async function main() {
|
|
|
226
242
|
register("update_app", "Update/add selected services in one request; preserve omitted settings. MCP handles builds, links and restarts in the background. Environment variables merge; supplied port/volume lists replace their lists. Resume a failed update using deployment_id and service overrides. The Dockerfile rules in deploy_app apply.", updateInput, checked((a) => runtime.update(a)), false, 60000, "deploy");
|
|
227
243
|
register("start_app", "Start all or selected services as one operation. MCP handles all requests. Waits up to wait_seconds (default 20); remaining execution continues in the background. Started does not mean ready.", lifecycleInput, (a) => runtime.lifecycle("start", a), false, 20000, "project");
|
|
228
244
|
register("stop_app", "Stop all or selected services as one operation. MCP handles all requests. Waits up to wait_seconds (default 20). Stopped means stop requests accepted, not verified teardown.", lifecycleInput, (a) => runtime.lifecycle("stop", a), false, 20000, "project");
|
|
229
|
-
register("get_deployment", "With deployment_id, observe/wait for background execution; the MCP owns execution. Each call waits up to wait_seconds (max 20); to wait longer, call again, as often as needed (a loop of get_deployment calls is fine), until execution is finished; do not answer the user while it is still in the background. Services carry status_changes and restarts_seen when the MCP saw the status change or a new container start. After sync_files (or manual upload) set files_uploaded=true; after
|
|
230
|
-
let signing;
|
|
231
|
-
register("sign_in", "Signs the user in to their Constructor: https://constructor.app unless url names another one (any link from it, e.g. https://acme.constructor.app). Call it when the user asks to sign in, or when a tool returns needs_auth, after telling the user a sign-in window is about to open. It opens a separate Chrome window at the sign-in page; the user signs in there and the window closes by itself. There is one sign-in at a time: signing in to another Constructor replaces it, and the MCP works there afterwards. The session is kept for this harness only, and the MCP renews it automatically for up to 14 days. Waits up to wait_seconds; while the user is still signing in it returns sign_in_pending: call it again (no second window opens), then repeat the call that needed auth.", signInInput, async (a) => {
|
|
232
|
-
const i = signInInput.parse(a);
|
|
233
|
-
if (!signing) {
|
|
234
|
-
let host = config.baseUrl;
|
|
235
|
-
if (i.url) {
|
|
236
|
-
try {
|
|
237
|
-
host = originOf(i.url);
|
|
238
|
-
}
|
|
239
|
-
catch (e) {
|
|
240
|
-
throw new ToolError("invalid_input", `url: ${e.message}`);
|
|
241
|
-
}
|
|
242
|
-
if (config.baseUrlFixed && host !== config.baseUrl)
|
|
243
|
-
throw new ToolError("wrong_environment", `This MCP is fixed to ${config.baseUrl} by CONSTRUCTOR_BASE_URL; it cannot sign in to ${host}.`);
|
|
244
|
-
}
|
|
245
|
-
const signedIn = (await sessionHost(config.cookieFile)) === host;
|
|
246
|
-
const left = signedIn
|
|
247
|
-
? await api.authLeft().catch(() => undefined)
|
|
248
|
-
: undefined;
|
|
249
|
-
if (!i.force && left?.renewable && left.minutes > 60)
|
|
250
|
-
return {
|
|
251
|
-
status: "signed_in",
|
|
252
|
-
message: `Already signed in to ${host}; the session renews automatically for another ${Math.floor(left.minutes / 1440)} days. Pass force=true to sign in again.`,
|
|
253
|
-
session_file: config.cookieFile,
|
|
254
|
-
host,
|
|
255
|
-
};
|
|
256
|
-
const state = {
|
|
257
|
-
done: false,
|
|
258
|
-
host,
|
|
259
|
-
promise: Promise.resolve(),
|
|
260
|
-
};
|
|
261
|
-
state.promise = signIn({ ...config, baseUrl: host }, config.cookieFile).then((r) => {
|
|
262
|
-
state.result = r;
|
|
263
|
-
state.done = true;
|
|
264
|
-
if (r.status === "signed_in")
|
|
265
|
-
api.aimed = undefined;
|
|
266
|
-
});
|
|
267
|
-
signing = state;
|
|
268
|
-
api.signingIn = true;
|
|
269
|
-
void state.promise.finally(() => (api.signingIn = false));
|
|
270
|
-
}
|
|
271
|
-
const current = signing;
|
|
272
|
-
await Promise.race([
|
|
273
|
-
current.promise,
|
|
274
|
-
new Promise((r) => setTimeout(r, i.wait_seconds * 1000)),
|
|
275
|
-
]);
|
|
276
|
-
if (!current.done)
|
|
277
|
-
return {
|
|
278
|
-
status: "sign_in_pending",
|
|
279
|
-
message: `A Chrome window is open at ${current.host}. Ask the user to sign in there; it closes by itself afterwards. Then call sign_in again to confirm, or repeat the call that needed auth.`,
|
|
280
|
-
};
|
|
281
|
-
signing = undefined;
|
|
282
|
-
await api.followSession();
|
|
283
|
-
return current.result;
|
|
284
|
-
}, true, 30000);
|
|
245
|
+
register("get_deployment", "With deployment_id, observe/wait for background execution; the MCP owns execution. Each call waits up to wait_seconds (max 20); to wait longer, call again, as often as needed (a loop of get_deployment calls is fine), until execution is finished; do not answer the user while it is still in the background. Services carry status_changes and restarts_seen when the MCP saw the status change or a new container start. After sync_files (or manual upload) set files_uploaded=true; after a new key is saved, checking the job resumes it. With no arguments, returns the map of all deployments: every deployment-enabled project with its applications and services (names, native statuses, public URLs, ports). With desk_url or project_id, returns that project's desks, images, hardware and applications; add application_id for one application. resume=false observes only. Never returns environment-variable values.", getInput, (a) => runtime.get(a), false, 20000, "deploy");
|
|
285
246
|
register("get_logs", "Last lines (default 20, up to 50) of a build or service log: why a build or start failed, how far a long build got, what a starting service prints. Constructor serves logs only live: a build log while the build runs, a service log for its current container while it runs (both replayed from the start, then streamed). The MCP follows the builds and services it starts or shows and keeps their last 50 lines, so they stay readable after the build ends or the container restarts; anything else can be read only while it runs. When a new container starts, the lines of the one before stay, above a '--- new container' marker, and restarts_seen counts them: that answers whether a service keeps restarting and why. An empty service log on a running service usually means a crash loop.", logsInput, async (a) => {
|
|
286
247
|
const i = logsInput.parse(a);
|
|
287
248
|
let targets = [];
|
|
@@ -333,14 +294,11 @@ async function main() {
|
|
|
333
294
|
process.once(signal, () => {
|
|
334
295
|
void Promise.all([runtime.close(), logs.close()]).then(() => server.close());
|
|
335
296
|
});
|
|
336
|
-
// Each harness keeps its own session, named after the client in the handshake.
|
|
337
297
|
let recovered = false;
|
|
338
298
|
server.server.oninitialized = () => {
|
|
339
|
-
if (!config.cookieFileFixed)
|
|
340
|
-
config.cookieFile = sessionFile(harnessOf(server.server.getClientVersion()?.name));
|
|
341
299
|
if (!recovered) {
|
|
342
300
|
recovered = true;
|
|
343
|
-
void api.
|
|
301
|
+
void api.refresh().finally(() => runtime.recover());
|
|
344
302
|
}
|
|
345
303
|
};
|
|
346
304
|
await server.connect(new StdioServerTransport());
|
package/dist/set-key.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Save a personal access key for the MCP without it appearing in the chat, a command line or the
|
|
3
|
+
// shell history: the key is read from the terminal (hidden) or from stdin.
|
|
4
|
+
// Usage: npx -y -p constructor-research-mcp constructor-research-set-key <Constructor link>
|
|
5
|
+
import { configFromEnv, originOf } from "./config.js";
|
|
6
|
+
import { cleanKey } from "./api.js";
|
|
7
|
+
import { saveKeyFile } from "./key.js";
|
|
8
|
+
const USAGE = "Usage: npx -y -p constructor-research-mcp constructor-research-set-key <Constructor link, e.g. https://acme.constructor.app>\nThen paste the access key (rk-…) when asked, or pipe it in on stdin.";
|
|
9
|
+
/** One line from the terminal without echoing it, or all of stdin when piped. */
|
|
10
|
+
async function readSecret(prompt) {
|
|
11
|
+
const input = process.stdin;
|
|
12
|
+
if (!input.isTTY) {
|
|
13
|
+
let text = "";
|
|
14
|
+
for await (const chunk of input)
|
|
15
|
+
text += chunk;
|
|
16
|
+
return text;
|
|
17
|
+
}
|
|
18
|
+
process.stderr.write(prompt);
|
|
19
|
+
input.setRawMode(true);
|
|
20
|
+
input.setEncoding("utf8");
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
let text = "";
|
|
23
|
+
const done = (value) => {
|
|
24
|
+
input.setRawMode(false);
|
|
25
|
+
input.pause();
|
|
26
|
+
input.off("data", onData);
|
|
27
|
+
process.stderr.write("\n");
|
|
28
|
+
value === undefined ? reject(new Error("cancelled")) : resolve(value);
|
|
29
|
+
};
|
|
30
|
+
const onData = (chunk) => {
|
|
31
|
+
for (const ch of chunk) {
|
|
32
|
+
if (ch === "\r" || ch === "\n")
|
|
33
|
+
return done(text);
|
|
34
|
+
if (ch === "\u0003" || ch === "\u0004")
|
|
35
|
+
return done();
|
|
36
|
+
if (ch === "\u007f" || ch === "\b")
|
|
37
|
+
text = text.slice(0, -1);
|
|
38
|
+
else if (ch >= " ")
|
|
39
|
+
text += ch;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
input.on("data", onData);
|
|
43
|
+
input.resume();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function main() {
|
|
47
|
+
const args = process.argv.slice(2);
|
|
48
|
+
if (args.length !== 1 || args[0].startsWith("-")) {
|
|
49
|
+
console.error(USAGE);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = originOf(args[0]);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
console.error(e.message);
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
const config = configFromEnv({ ...process.env, CONSTRUCTOR_BASE_URL: url });
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
raw = await readSecret(`Access key for ${url} (hidden): `);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
console.error("Cancelled; nothing saved.");
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
const key = cleanKey(raw);
|
|
70
|
+
if (!key) {
|
|
71
|
+
console.error("That is not an access key; nothing saved.");
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
// Check the key with Constructor before saving it.
|
|
75
|
+
let status;
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(`${url}${config.apiPath}/auth/session`, {
|
|
78
|
+
headers: {
|
|
79
|
+
"X-Research-AccessKey": `Bearer ${key}`,
|
|
80
|
+
Accept: "application/json",
|
|
81
|
+
},
|
|
82
|
+
redirect: "manual",
|
|
83
|
+
signal: AbortSignal.timeout(15000),
|
|
84
|
+
});
|
|
85
|
+
status = response.status;
|
|
86
|
+
await response.body?.cancel();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
console.error(`Could not reach ${url} (VPN?); nothing saved.`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
if (status === 401) {
|
|
93
|
+
console.error(`${url} rejected this key; nothing saved.`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
if (status !== 200) {
|
|
97
|
+
console.error(`${url} answered HTTP ${status}: it may not have the public API yet; nothing saved.`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
await saveKeyFile(config.keyFile, url, key);
|
|
101
|
+
console.log(`Key for ${url} saved to ${config.keyFile}. The MCP uses it from the next call; no restart needed.`);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
main().then((code) => (process.exitCode = code), () => {
|
|
105
|
+
console.error("Saving the key failed.");
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
});
|
package/dist/store.d.ts
CHANGED
|
@@ -70,7 +70,7 @@ export declare class StateStore {
|
|
|
70
70
|
* harnesses' calls "busy" about half the time while anything was deploying.
|
|
71
71
|
*/
|
|
72
72
|
lockWaitMs: number;
|
|
73
|
-
/** The Constructor API saved operations belong to; follows a
|
|
73
|
+
/** The Constructor API saved operations belong to; follows a key saved for another host. */
|
|
74
74
|
get endpoint(): string;
|
|
75
75
|
private init;
|
|
76
76
|
load(id: string): Promise<Operation | undefined>;
|