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/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
- ["needs_auth", "connection_error"].includes(err.code)) {
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
+ }
@@ -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">): z.ZodObject<{
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 user is signed in to ${base}. If they work on ${url.origin}, tell them a sign-in window is about to open, then call sign_in with url set to this link; that replaces the sign-in to ${base}.`);
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 === "needs_auth") {
191
+ if (err instanceof ToolError && err.code === "needs_key") {
192
192
  log.error =
193
- "The token expired while following this build log; the lines below may be incomplete.";
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(["needs_auth", "needs_upload"]);
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 === "needs_auth" &&
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 === "needs_auth"
214
- ? `${this.engine.api.authHelp("The token expired or is invalid.")} Then check this deployment again.`
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, originOf, VERSION } from "./config.js";
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, terminal } from "./deployment.js";
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, signInInput, } from "./schemas.js";
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: `Sign-in: the MCP works with one Constructor, https://constructor.app unless the user signed in to another. When the user asks to sign in, or names their Constructor or gives a link from it (e.g. https://acme.constructor.app), call sign_in with url set to that link. 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.`,
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.followSession();
138
- // Nobody signed in yet: a desk link names the Constructor to sign in to.
139
- if (args?.desk_url &&
140
- !config.baseUrlFixed &&
141
- !(await sessionHost(config.cookieFile)))
142
- try {
143
- api.aimed = config.baseUrl = originOf(args.desk_url);
144
- }
145
- catch { }
146
- // Do not start a change on a token about to expire; checking on a deployment is still fine.
147
- const before = await api.authLeft().catch(() => undefined);
148
- const what = (renewable) => renewable ? "The login session ends" : "The token expires";
149
- if (!readOnly &&
150
- name !== "get_deployment" &&
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 cookie replacement 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");
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.followSession().finally(() => runtime.recover());
301
+ void api.refresh().finally(() => runtime.recover());
344
302
  }
345
303
  };
346
304
  await server.connect(new StdioServerTransport());
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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 sign-in to another host. */
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>;