constructor-research-mcp 0.4.0-beta.1 → 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/README.md +12 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +6 -2
- package/dist/desk.d.ts +23 -0
- package/dist/desk.js +52 -0
- package/dist/files.d.ts +6 -0
- package/dist/files.js +45 -0
- package/dist/location.d.ts +3 -1
- package/dist/location.js +11 -2
- package/dist/login.d.ts +2 -0
- package/dist/login.js +25 -0
- package/dist/server.js +56 -3
- package/dist/session.d.ts +21 -0
- package/dist/session.js +108 -0
- package/dist/set-token.d.ts +2 -0
- package/dist/set-token.js +48 -0
- package/dist/signin.d.ts +19 -0
- package/dist/signin.js +209 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,12 +57,24 @@ Deployment must be switched on for the project. If it is off and the key may not
|
|
|
57
57
|
| `CONSTRUCTOR_STORAGE_PATH` | `/api/platform-storage` |
|
|
58
58
|
| `CONSTRUCTOR_ACCESS_KEY` | unset: the key file is used |
|
|
59
59
|
| `CONSTRUCTOR_KEY_FILE` | `~/.config/constructor-research/access-key.json` |
|
|
60
|
+
| `CONSTRUCTOR_DESK_ROOT` | `~/project` (inside a desk only) |
|
|
60
61
|
| `CONSTRUCTOR_STATE_DIR` | `~/.local/state/constructor-research-mcp` |
|
|
61
62
|
| `CONSTRUCTOR_UPLOAD_SETTLE_SECONDS` | `30` |
|
|
62
63
|
| `CONSTRUCTOR_READINESS_SECONDS` | `60` |
|
|
63
64
|
|
|
64
65
|
HTTPS is required except on localhost.
|
|
65
66
|
|
|
67
|
+
## Inside a Constructor desk (VS Code)
|
|
68
|
+
|
|
69
|
+
When the MCP runs in VS Code inside a Constructor Research desk, it finds that desk from the environment Constructor sets there (`ROLOS_PROJECT_ID`/`RESEARCH_PROJECT_ID`, `ROLOS_DESK_ID`/`RESEARCH_DESK_ID`, `ROLOS_API_SERVER`/`RESEARCH_API_SERVER`) and works in it:
|
|
70
|
+
|
|
71
|
+
- the desk's project folder (`~/project`, or `CONSTRUCTOR_DESK_ROOT`) is its disk, so the agent creates and changes files there directly; nothing is uploaded, and `sync_files`, `list_files`, `create_folder`, `move_path` and `delete_path` answer `local_desk` for this desk;
|
|
72
|
+
- `build_image`, `deploy_app`, `update_app`, `start_app`, `stop_app` and `list_images` act on this desk and project without a desk link; a `desk_url` or other IDs still name another desk;
|
|
73
|
+
- `build.dockerfile` is the path inside the project folder (`/backend/Dockerfile`), or `build.local_path` names a folder inside it; Dockerfiles are checked from the local files;
|
|
74
|
+
- the MCP works with the desk's Constructor (`ROLOS_API_SERVER`) unless `CONSTRUCTOR_BASE_URL` is set.
|
|
75
|
+
|
|
76
|
+
`CONSTRUCTOR_LOCAL_DESK=0` turns this off.
|
|
77
|
+
|
|
66
78
|
## Thirteen tools
|
|
67
79
|
|
|
68
80
|
| Tool | Agent's task |
|
package/dist/config.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { type LocalDesk } from "./desk.js";
|
|
1
2
|
export interface Config {
|
|
2
3
|
/** The Constructor the MCP works with: CONSTRUCTOR_BASE_URL, else the one the saved key belongs to. */
|
|
3
4
|
baseUrl: string;
|
|
4
5
|
baseUrlFixed?: boolean;
|
|
6
|
+
/** The Constructor desk this MCP runs inside (VS Code in Constructor Research), if any. */
|
|
7
|
+
localDesk?: LocalDesk;
|
|
5
8
|
apiPath: string;
|
|
6
9
|
storagePath?: string;
|
|
7
10
|
/** Build-log service root; build logs stream only while a build runs. */
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { localDeskFromEnv } from "./desk.js";
|
|
5
6
|
/** Where the MCP works unless CONSTRUCTOR_BASE_URL or the saved key names another Constructor. */
|
|
6
7
|
export const DEFAULT_BASE_URL = "https://constructor.app";
|
|
7
8
|
/** The origin of a Constructor link; HTTPS except on localhost. */
|
|
@@ -28,7 +29,9 @@ export function configFromEnv(env = process.env) {
|
|
|
28
29
|
if (url.search || url.hash || url.pathname !== "/")
|
|
29
30
|
throw new Error("CONSTRUCTOR_BASE_URL must be an origin without a path or credentials.");
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
+
// Inside a Constructor desk, its Constructor is the one to work with.
|
|
33
|
+
const localDesk = localDeskFromEnv(env, originOf);
|
|
34
|
+
const baseUrl = originOf(fixed ?? localDesk?.server ?? DEFAULT_BASE_URL);
|
|
32
35
|
const apiPath = env.CONSTRUCTOR_API_PATH ?? "/api/platform-account/v1";
|
|
33
36
|
if (!/^\/[a-zA-Z0-9/_-]+$/.test(apiPath) || apiPath.includes("//"))
|
|
34
37
|
throw new Error("Invalid CONSTRUCTOR_API_PATH.");
|
|
@@ -41,7 +44,8 @@ export function configFromEnv(env = process.env) {
|
|
|
41
44
|
const expand = (s) => resolve(s.startsWith("~/") ? homedir() + s.slice(1) : s);
|
|
42
45
|
return {
|
|
43
46
|
baseUrl,
|
|
44
|
-
baseUrlFixed: !!fixed,
|
|
47
|
+
baseUrlFixed: !!fixed || !!localDesk?.server,
|
|
48
|
+
localDesk,
|
|
45
49
|
apiPath: apiPath.replace(/\/$/, ""),
|
|
46
50
|
storagePath: storagePath.replace(/\/$/, ""),
|
|
47
51
|
keeperPath: keeperPath.replace(/\/$/, ""),
|
package/dist/desk.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Constructor desk this MCP runs inside (VS Code in Constructor Research), from the
|
|
3
|
+
* environment Constructor sets there. Its project folder is the desk's disk, so the agent edits
|
|
4
|
+
* files in place and the MCP never uploads to it.
|
|
5
|
+
*/
|
|
6
|
+
export interface LocalDesk {
|
|
7
|
+
projectId: string;
|
|
8
|
+
deskId: string;
|
|
9
|
+
/** The desk's project folder on this machine; the disk root for builds. */
|
|
10
|
+
root: string;
|
|
11
|
+
/** The Constructor the desk belongs to. */
|
|
12
|
+
server?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function localDeskFromEnv(env: NodeJS.ProcessEnv, origin: (link: string) => string): LocalDesk | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Disk path ("/a/Dockerfile") of a local path inside the desk's project folder, or undefined
|
|
17
|
+
* when it lies outside. Symlinks are resolved on both sides when the path exists.
|
|
18
|
+
*/
|
|
19
|
+
export declare function diskPath(desk: LocalDesk, local: string): string | undefined;
|
|
20
|
+
/** Absolute local path of a disk path; refuses anything that escapes the project folder. */
|
|
21
|
+
export declare function localPath(desk: LocalDesk, disk: string): string;
|
|
22
|
+
/** What the agent is told about working inside the desk. */
|
|
23
|
+
export declare function deskInstructions(desk: LocalDesk): string;
|
package/dist/desk.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
const ID = /^[a-zA-Z0-9_-]{4,128}$/;
|
|
5
|
+
export function localDeskFromEnv(env, origin) {
|
|
6
|
+
if (["0", "false", "off"].includes((env.CONSTRUCTOR_LOCAL_DESK ?? "").toLowerCase()))
|
|
7
|
+
return undefined;
|
|
8
|
+
const projectId = env.ROLOS_PROJECT_ID || env.RESEARCH_PROJECT_ID;
|
|
9
|
+
const deskId = env.ROLOS_DESK_ID || env.RESEARCH_DESK_ID;
|
|
10
|
+
if (!projectId || !deskId || !ID.test(projectId) || !ID.test(deskId))
|
|
11
|
+
return undefined;
|
|
12
|
+
let server;
|
|
13
|
+
try {
|
|
14
|
+
const s = env.ROLOS_API_SERVER || env.RESEARCH_API_SERVER;
|
|
15
|
+
server = s ? origin(s) : undefined;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
server = undefined;
|
|
19
|
+
}
|
|
20
|
+
const home = env.HOME || homedir();
|
|
21
|
+
const root = resolve(env.CONSTRUCTOR_DESK_ROOT ?? join(home, "project"));
|
|
22
|
+
return { projectId, deskId, root, server };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Disk path ("/a/Dockerfile") of a local path inside the desk's project folder, or undefined
|
|
26
|
+
* when it lies outside. Symlinks are resolved on both sides when the path exists.
|
|
27
|
+
*/
|
|
28
|
+
export function diskPath(desk, local) {
|
|
29
|
+
const real = (p) => {
|
|
30
|
+
try {
|
|
31
|
+
return realpathSync(p);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return resolve(p);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const rel = relative(real(desk.root), real(local));
|
|
38
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
39
|
+
return undefined;
|
|
40
|
+
return "/" + rel.split(sep).filter(Boolean).join("/");
|
|
41
|
+
}
|
|
42
|
+
/** Absolute local path of a disk path; refuses anything that escapes the project folder. */
|
|
43
|
+
export function localPath(desk, disk) {
|
|
44
|
+
const parts = disk.split("/").filter(Boolean);
|
|
45
|
+
if (parts.some((p) => p === ".." || p === "."))
|
|
46
|
+
throw new Error("Disk paths must not contain . or ..");
|
|
47
|
+
return join(desk.root, ...parts);
|
|
48
|
+
}
|
|
49
|
+
/** What the agent is told about working inside the desk. */
|
|
50
|
+
export function deskInstructions(desk) {
|
|
51
|
+
return `This MCP runs inside Constructor desk ${desk.deskId} of project ${desk.projectId} (VS Code in Constructor Research). That desk's disk is the local folder ${desk.root}: create and change its files there directly with your own file and shell tools, and do not use sync_files, list_files, create_folder, move_path or delete_path for this desk. build_image, deploy_app, update_app, start_app, stop_app and list_images use this desk and project by default, so no desk link is needed; pass desk_url only when the user names another desk. When the Dockerfile and sources are ready, call deploy_app (or build_image) with build.dockerfile as the path inside ${desk.root} (for example /backend/Dockerfile), or build.local_path set to a folder inside ${desk.root}; nothing is uploaded.`;
|
|
52
|
+
}
|
package/dist/files.d.ts
CHANGED
|
@@ -32,6 +32,10 @@ export declare class FileManager {
|
|
|
32
32
|
/** Type of a path, or null when missing. */
|
|
33
33
|
private stat;
|
|
34
34
|
private mkcol;
|
|
35
|
+
/** The desk this MCP runs inside, when `desk` is it. */
|
|
36
|
+
private here;
|
|
37
|
+
/** File tools never touch the desk the MCP runs in: its files are local. */
|
|
38
|
+
private notHere;
|
|
35
39
|
list(raw: unknown): Promise<{
|
|
36
40
|
desk_id: string;
|
|
37
41
|
path: string;
|
|
@@ -108,6 +112,8 @@ export declare class FileManager {
|
|
|
108
112
|
missing: string[];
|
|
109
113
|
warnings: string[];
|
|
110
114
|
}>;
|
|
115
|
+
/** The same checks as on a disk, for the desk this MCP runs in: its files are local. */
|
|
116
|
+
private checkLocal;
|
|
111
117
|
/** Upload to a fresh temp file, move it over the target, then verify the server checksum. */
|
|
112
118
|
private upload;
|
|
113
119
|
}
|
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;
|
|
@@ -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/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
|
@@ -2,7 +2,16 @@ import { ToolError } from "./api.js";
|
|
|
2
2
|
import { VERSION } from "./config.js";
|
|
3
3
|
import { id, deskUrl } from "./schemas.js";
|
|
4
4
|
/** Tool-facing schema: the engine schema plus desk_url, with the IDs it supplies made optional. */
|
|
5
|
-
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;
|
|
6
15
|
return schema
|
|
7
16
|
.extend({
|
|
8
17
|
project_id: id.optional(),
|
|
@@ -35,7 +44,7 @@ export function parseDeskUrl(link, baseUrl, fixed = false) {
|
|
|
35
44
|
const base = new URL(baseUrl).origin;
|
|
36
45
|
if (url.origin !== base)
|
|
37
46
|
bad("wrong_environment", fixed
|
|
38
|
-
? `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}.`
|
|
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}.`
|
|
39
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.`);
|
|
40
49
|
return { project_id: m[1], desk_id: m[2] };
|
|
41
50
|
}
|
package/dist/login.d.ts
ADDED
package/dist/login.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sign in from a terminal; the MCP's sign_in tool does the same from an agent session.
|
|
3
|
+
// Usage: npx -y -p constructor-research-mcp constructor-research-login [--url <any link to your Constructor>] --for claude-code|codex|opencode (or --to <session file>)
|
|
4
|
+
import { configFromEnv, HARNESSES, originOf, sessionArgs } from "./config.js";
|
|
5
|
+
import { signIn } from "./signin.js";
|
|
6
|
+
const config = configFromEnv();
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const at = args.indexOf("--url");
|
|
9
|
+
const url = at >= 0 ? args.splice(at, 2)[1] : undefined;
|
|
10
|
+
const { file } = sessionArgs(args, config);
|
|
11
|
+
if (!file || (at >= 0 && !url)) {
|
|
12
|
+
console.error(`Usage: npx -y -p constructor-research-mcp constructor-research-login [--url <any link to your Constructor>] --for ${HARNESSES.join("|")} (or --to <session file>)`);
|
|
13
|
+
process.exit(2);
|
|
14
|
+
}
|
|
15
|
+
if (url) {
|
|
16
|
+
const host = originOf(url);
|
|
17
|
+
if (config.baseUrlFixed && host !== config.baseUrl) {
|
|
18
|
+
console.error(`CONSTRUCTOR_BASE_URL fixes the MCP to ${config.baseUrl}.`);
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
config.baseUrl = host;
|
|
22
|
+
}
|
|
23
|
+
const result = await signIn(config, file, () => console.log(`A Chrome window opened at ${config.baseUrl}. Sign in there; it closes by itself afterwards.`));
|
|
24
|
+
(result.status === "signed_in" ? console.log : console.error)(result.message);
|
|
25
|
+
process.exit(result.status === "signed_in" ? 0 : 1);
|
package/dist/server.js
CHANGED
|
@@ -11,6 +11,21 @@ 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
30
|
import { buildInput, listInput, deployInput, updateInput, lifecycleInput, getInput, logsInput, listFilesInput, createFolderInput, syncFilesInput, movePathInput, deletePathInput, } from "./schemas.js";
|
|
16
31
|
async function main() {
|
|
@@ -35,6 +50,28 @@ async function main() {
|
|
|
35
50
|
services.push(x);
|
|
36
51
|
continue;
|
|
37
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
|
+
}
|
|
38
75
|
if (!b.desk_id || !a.project_id)
|
|
39
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.`);
|
|
40
77
|
const remote = (b.remote_path ?? basename(b.local_path))
|
|
@@ -114,12 +151,17 @@ async function main() {
|
|
|
114
151
|
}
|
|
115
152
|
: result;
|
|
116
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;
|
|
117
158
|
const server = new McpServer({ name: "constructor-research", version: VERSION }, {
|
|
118
|
-
instructions: `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
|
|
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.`),
|
|
119
160
|
});
|
|
120
161
|
const register = (name, description, schema, fn, readOnly = false, budget = 20000, scope) => {
|
|
162
|
+
const here = config.localDesk && DESK_DEFAULT.has(name) ? config.localDesk : undefined;
|
|
121
163
|
if (scope && scope !== "deploy")
|
|
122
|
-
schema = located(schema, scope);
|
|
164
|
+
schema = located(schema, scope, !!here);
|
|
123
165
|
server.registerTool(name, {
|
|
124
166
|
description,
|
|
125
167
|
inputSchema: schema,
|
|
@@ -133,7 +175,18 @@ async function main() {
|
|
|
133
175
|
try {
|
|
134
176
|
await api.refresh();
|
|
135
177
|
const result = await api.withBudget(budget, () => fn(scope
|
|
136
|
-
? resolveDeskUrl(
|
|
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)
|
|
137
190
|
: args));
|
|
138
191
|
return {
|
|
139
192
|
content: [
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The cookies the MCP keeps: the access token for requests, the refresh token to renew it. */
|
|
2
|
+
export declare const SESSION_COOKIES: readonly ["ap_access_token", "ap_id_token", "ap_refresh_token"];
|
|
3
|
+
/** Claims of a JWT, or undefined when it is not one. */
|
|
4
|
+
export declare function claims(jwt?: string): Record<string, any> | undefined;
|
|
5
|
+
/** Session cookies found in a Cookie header, a cURL, a Set-Cookie list or a bare JWT. */
|
|
6
|
+
export declare function sessionCookies(text: string): Map<string, string>;
|
|
7
|
+
/** The session saved in a cookie file; only its cookie line is read. */
|
|
8
|
+
export declare function readSession(file: string): Promise<Map<string, string>>;
|
|
9
|
+
/**
|
|
10
|
+
* The Constructor (an origin) a saved session belongs to: its host line, written at sign-in,
|
|
11
|
+
* or else the issuer of its access token. Undefined when nothing is saved.
|
|
12
|
+
*/
|
|
13
|
+
export declare function sessionHost(file: string): Promise<string | undefined>;
|
|
14
|
+
/**
|
|
15
|
+
* Replaces the cookie line of a file with these cookies, keeping its other lines, and with
|
|
16
|
+
* `host` its host line. Written to a temporary file and renamed, mode 600, so a reader never
|
|
17
|
+
* sees half a token and a crash never loses the session.
|
|
18
|
+
*/
|
|
19
|
+
export declare function saveSession(file: string, cookies: Map<string, string>, host?: string): Promise<void>;
|
|
20
|
+
/** New session cookies from a response's Set-Cookie headers. */
|
|
21
|
+
export declare function setCookies(response: Response): Map<string, string>;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
/** The cookies the MCP keeps: the access token for requests, the refresh token to renew it. */
|
|
5
|
+
export const SESSION_COOKIES = [
|
|
6
|
+
"ap_access_token",
|
|
7
|
+
"ap_id_token",
|
|
8
|
+
"ap_refresh_token",
|
|
9
|
+
];
|
|
10
|
+
/** Claims of a JWT, or undefined when it is not one. */
|
|
11
|
+
export function claims(jwt) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString());
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Session cookies found in a Cookie header, a cURL, a Set-Cookie list or a bare JWT. */
|
|
20
|
+
export function sessionCookies(text) {
|
|
21
|
+
const found = new Map();
|
|
22
|
+
for (const name of SESSION_COOKIES) {
|
|
23
|
+
const value = [
|
|
24
|
+
...text.matchAll(new RegExp(`(?:^|[\\s;'"])${name}=([\\w.~+/=-]+)`, "g")),
|
|
25
|
+
].at(-1)?.[1];
|
|
26
|
+
if (value)
|
|
27
|
+
found.set(name, value);
|
|
28
|
+
}
|
|
29
|
+
const bare = /^\s*(ey[\w-]+\.[\w-]+\.[\w-]+)\s*$/.exec(text)?.[1];
|
|
30
|
+
if (bare && !found.size)
|
|
31
|
+
found.set("ap_access_token", bare);
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
/** Index of the line holding the tokens in a cookie file, or -1. */
|
|
35
|
+
const cookieLine = (lines) => lines.findIndex((l) => /^\s*(Cookie:\s*)?(ey[\w-]+\.|.*\bap_(access|refresh)_token=)/.test(l));
|
|
36
|
+
/** The session saved in a cookie file; only its cookie line is read. */
|
|
37
|
+
export async function readSession(file) {
|
|
38
|
+
const lines = (await readFile(file, "utf8")).split(/\r?\n/);
|
|
39
|
+
const line = lines[cookieLine(lines)] ?? "";
|
|
40
|
+
if (/[\x00-\x1f]/.test(line))
|
|
41
|
+
return new Map();
|
|
42
|
+
return sessionCookies(line.trim().replace(/^Cookie:\s*/i, ""));
|
|
43
|
+
}
|
|
44
|
+
/** The line naming the Constructor the session belongs to. */
|
|
45
|
+
const HOST = /^host=(\S+)\s*$/;
|
|
46
|
+
/**
|
|
47
|
+
* The Constructor (an origin) a saved session belongs to: its host line, written at sign-in,
|
|
48
|
+
* or else the issuer of its access token. Undefined when nothing is saved.
|
|
49
|
+
*/
|
|
50
|
+
export async function sessionHost(file) {
|
|
51
|
+
let lines;
|
|
52
|
+
try {
|
|
53
|
+
lines = (await readFile(file, "utf8")).split(/\r?\n/);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const named = lines.map((l) => HOST.exec(l)?.[1]).find(Boolean);
|
|
59
|
+
const line = lines[cookieLine(lines)] ?? "";
|
|
60
|
+
const issuer = claims(sessionCookies(line.replace(/^\s*Cookie:\s*/i, "")).get("ap_access_token"))?.iss;
|
|
61
|
+
try {
|
|
62
|
+
return new URL(named ?? issuer).origin;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Replaces the cookie line of a file with these cookies, keeping its other lines, and with
|
|
70
|
+
* `host` its host line. Written to a temporary file and renamed, mode 600, so a reader never
|
|
71
|
+
* sees half a token and a crash never loses the session.
|
|
72
|
+
*/
|
|
73
|
+
export async function saveSession(file, cookies, host) {
|
|
74
|
+
let lines = [];
|
|
75
|
+
try {
|
|
76
|
+
lines = (await readFile(file, "utf8")).split(/\r?\n/);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
if (e.code !== "ENOENT")
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
const line = SESSION_COOKIES.filter((n) => cookies.get(n))
|
|
83
|
+
.map((n) => `${n}=${cookies.get(n)}`)
|
|
84
|
+
.join("; ");
|
|
85
|
+
const i = cookieLine(lines);
|
|
86
|
+
if (i >= 0)
|
|
87
|
+
lines[i] = line;
|
|
88
|
+
else
|
|
89
|
+
lines.unshift(line);
|
|
90
|
+
if (host) {
|
|
91
|
+
lines = lines.filter((l) => !HOST.test(l));
|
|
92
|
+
lines.splice(lines.indexOf(line) + 1, 0, `host=${host}`);
|
|
93
|
+
}
|
|
94
|
+
await mkdir(dirname(file), { recursive: true });
|
|
95
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
96
|
+
await writeFile(tmp, lines.join("\n").replace(/\n*$/, "\n"), {
|
|
97
|
+
mode: 0o600,
|
|
98
|
+
flag: "wx",
|
|
99
|
+
});
|
|
100
|
+
await rename(tmp, file);
|
|
101
|
+
}
|
|
102
|
+
/** New session cookies from a response's Set-Cookie headers. */
|
|
103
|
+
export function setCookies(response) {
|
|
104
|
+
return sessionCookies(response.headers
|
|
105
|
+
.getSetCookie()
|
|
106
|
+
.map((c) => c.split(";")[0])
|
|
107
|
+
.join("; "));
|
|
108
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Save the session from a copied cURL (or a bare token) for the MCP without printing it.
|
|
3
|
+
// Usage: npx -y -p constructor-research-mcp constructor-research-set-token <file with a copied cURL, or a token> --for claude-code|codex|opencode
|
|
4
|
+
// (or --to <session file>); the session belongs to the Constructor the cURL was sent to.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { configFromEnv, HARNESSES, originOf, sessionArgs } from "./config.js";
|
|
7
|
+
import { claims, saveSession, sessionCookies } from "./session.js";
|
|
8
|
+
const { file: target, rest } = sessionArgs(process.argv.slice(2), configFromEnv());
|
|
9
|
+
const source = rest[0];
|
|
10
|
+
if (!source || !target) {
|
|
11
|
+
console.error(`Usage: npx -y -p constructor-research-mcp constructor-research-set-token <file with a copied cURL> --for ${HARNESSES.join("|")} (or --to <session file>)`);
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
const text = readFileSync(source, "utf8");
|
|
15
|
+
const found = sessionCookies(text);
|
|
16
|
+
const token = found.get("ap_access_token");
|
|
17
|
+
if (!token) {
|
|
18
|
+
console.error("No ap_access_token found in " + source + ".");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const { exp, max_slt } = claims(token) ?? {};
|
|
22
|
+
if (exp && exp * 1000 <= Date.now() && !found.has("ap_refresh_token")) {
|
|
23
|
+
console.error("That token already expired at " +
|
|
24
|
+
new Date(exp * 1000).toISOString() +
|
|
25
|
+
"; copy a fresh one.");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
// The Constructor the cURL was sent to; a bare token names its own issuer.
|
|
29
|
+
const sent = /\bcurl\s+(?:-\S+\s+)*['"]?(https?:\/\/[^\s'"]+)/.exec(text)?.[1];
|
|
30
|
+
let host;
|
|
31
|
+
try {
|
|
32
|
+
host = sent ? originOf(sent) : undefined;
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
await saveSession(target, found, host);
|
|
36
|
+
const time = (s) => new Date(s * 1000).toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
37
|
+
console.log("Saved to " +
|
|
38
|
+
target +
|
|
39
|
+
(host ? ` (for ${host})` : "") +
|
|
40
|
+
(found.has("ap_refresh_token")
|
|
41
|
+
? `, with the refresh token: the MCP renews the session itself${max_slt ? " until " + time(max_slt) : ""}. The browser this cURL came from holds the same session, and a refresh token works once: whichever renews first logs the other out. The sign_in tool (or constructor-research-login) gives the MCP a session of its own.`
|
|
42
|
+
: exp
|
|
43
|
+
? "; expires " +
|
|
44
|
+
time(exp) +
|
|
45
|
+
" (in " +
|
|
46
|
+
Math.round((exp * 1000 - Date.now()) / 60000) +
|
|
47
|
+
" min). Copy the cURL with the ap_refresh_token cookie, or use the sign_in tool, and the MCP renews it itself."
|
|
48
|
+
: "."));
|
package/dist/signin.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Config } from "./config.js";
|
|
2
|
+
export type SignInResult = {
|
|
3
|
+
status: "signed_in";
|
|
4
|
+
message: string;
|
|
5
|
+
session_file: string;
|
|
6
|
+
host: string;
|
|
7
|
+
until?: string;
|
|
8
|
+
} | {
|
|
9
|
+
status: "sign_in_failed";
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
/** A Chromium-based browser on macOS, Windows or Linux; CONSTRUCTOR_BROWSER overrides. */
|
|
13
|
+
export declare function findBrowser(): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Opens the sign-in window, waits for the user to sign in (up to 15 min), saves the
|
|
16
|
+
* session cookies to `file`, and closes the window at once. Never returns a token.
|
|
17
|
+
* `opened` runs once the window is up.
|
|
18
|
+
*/
|
|
19
|
+
export declare function signIn(config: Pick<Config, "baseUrl" | "apiPath" | "requestTimeoutMs">, file: string, opened?: () => void): Promise<SignInResult>;
|
package/dist/signin.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Sign in to Constructor in a separate Chrome window and save the session.
|
|
2
|
+
// The window uses a fresh temporary profile: a session nothing else holds, so the MCP can
|
|
3
|
+
// renew it without logging out the user's own browser (a refresh token works only once).
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
|
+
import { delimiter, join } from "node:path";
|
|
9
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
+
import { claims, saveSession, setCookies, SESSION_COOKIES } from "./session.js";
|
|
11
|
+
const SIGN_IN_MINUTES = 15;
|
|
12
|
+
/** A Chromium-based browser on macOS, Windows or Linux; CONSTRUCTOR_BROWSER overrides. */
|
|
13
|
+
export function findBrowser() {
|
|
14
|
+
if (process.env.CONSTRUCTOR_BROWSER)
|
|
15
|
+
return existsSync(process.env.CONSTRUCTOR_BROWSER)
|
|
16
|
+
? process.env.CONSTRUCTOR_BROWSER
|
|
17
|
+
: undefined;
|
|
18
|
+
const env = process.env;
|
|
19
|
+
const installed = process.platform === "win32"
|
|
20
|
+
? [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA]
|
|
21
|
+
.filter((d) => !!d)
|
|
22
|
+
.flatMap((d) => [
|
|
23
|
+
join(d, "Google", "Chrome", "Application", "chrome.exe"),
|
|
24
|
+
join(d, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
25
|
+
join(d, "Chromium", "Application", "chrome.exe"),
|
|
26
|
+
join(d, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
|
27
|
+
])
|
|
28
|
+
: process.platform === "darwin"
|
|
29
|
+
? [
|
|
30
|
+
"Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
31
|
+
"Chromium.app/Contents/MacOS/Chromium",
|
|
32
|
+
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
33
|
+
"Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
34
|
+
].flatMap((app) => [
|
|
35
|
+
join("/Applications", app),
|
|
36
|
+
join(homedir(), "Applications", app),
|
|
37
|
+
])
|
|
38
|
+
: [];
|
|
39
|
+
// Then the PATH, as Linux installs them (and any OS where the user put one there).
|
|
40
|
+
const names = [
|
|
41
|
+
"google-chrome",
|
|
42
|
+
"google-chrome-stable",
|
|
43
|
+
"chromium",
|
|
44
|
+
"chromium-browser",
|
|
45
|
+
"microsoft-edge",
|
|
46
|
+
"brave-browser",
|
|
47
|
+
"chrome",
|
|
48
|
+
"msedge",
|
|
49
|
+
];
|
|
50
|
+
const exts = process.platform === "win32"
|
|
51
|
+
? (env.PATHEXT ?? ".EXE").split(";").filter(Boolean)
|
|
52
|
+
: [""];
|
|
53
|
+
const onPath = (env.PATH ?? "")
|
|
54
|
+
.split(delimiter)
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.flatMap((dir) => names.flatMap((n) => exts.map((e) => join(dir, n + e.toLowerCase()))));
|
|
57
|
+
return [...installed, ...onPath].find((c) => existsSync(c));
|
|
58
|
+
}
|
|
59
|
+
/** Minimal Chrome DevTools Protocol client over the browser's WebSocket. */
|
|
60
|
+
async function devtools(url) {
|
|
61
|
+
const ws = new WebSocket(url);
|
|
62
|
+
await new Promise((ok, fail) => {
|
|
63
|
+
ws.onopen = ok;
|
|
64
|
+
ws.onerror = () => fail(new Error("Could not connect to the browser."));
|
|
65
|
+
});
|
|
66
|
+
let id = 0;
|
|
67
|
+
const waiting = new Map();
|
|
68
|
+
ws.onmessage = (e) => {
|
|
69
|
+
const m = JSON.parse(String(e.data));
|
|
70
|
+
waiting.get(m.id)?.(m);
|
|
71
|
+
waiting.delete(m.id);
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
send(method, params = {}) {
|
|
75
|
+
return new Promise((ok, fail) => {
|
|
76
|
+
const n = ++id;
|
|
77
|
+
waiting.set(n, (m) => m.error ? fail(new Error(m.error.message)) : ok(m.result));
|
|
78
|
+
ws.send(JSON.stringify({ id: n, method, params }));
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
close: () => ws.close(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Opens the sign-in window, waits for the user to sign in (up to 15 min), saves the
|
|
86
|
+
* session cookies to `file`, and closes the window at once. Never returns a token.
|
|
87
|
+
* `opened` runs once the window is up.
|
|
88
|
+
*/
|
|
89
|
+
export async function signIn(config, file, opened = () => { }) {
|
|
90
|
+
const fallback = `save a session from a copied cURL instead: npx -y -p constructor-research-mcp constructor-research-set-token <file with the cURL> --to ${file}`;
|
|
91
|
+
const headless = !!process.env.CONSTRUCTOR_LOGIN_HEADLESS;
|
|
92
|
+
if (process.platform === "linux" &&
|
|
93
|
+
!headless &&
|
|
94
|
+
!process.env.DISPLAY &&
|
|
95
|
+
!process.env.WAYLAND_DISPLAY)
|
|
96
|
+
return {
|
|
97
|
+
status: "sign_in_failed",
|
|
98
|
+
message: `This machine has no screen to open a sign-in window on (no DISPLAY or WAYLAND_DISPLAY). Sign in where the MCP has a desktop, or ${fallback}.`,
|
|
99
|
+
};
|
|
100
|
+
const exe = findBrowser();
|
|
101
|
+
if (!exe)
|
|
102
|
+
return {
|
|
103
|
+
status: "sign_in_failed",
|
|
104
|
+
message: `No Chrome, Edge, Chromium or Brave found. Set CONSTRUCTOR_BROWSER in the MCP settings to the browser's executable, or ${fallback}.`,
|
|
105
|
+
};
|
|
106
|
+
const host = new URL(config.baseUrl).hostname;
|
|
107
|
+
const profile = await mkdtemp(join(tmpdir(), "constructor-login-"));
|
|
108
|
+
const chrome = spawn(exe, [
|
|
109
|
+
`--user-data-dir=${profile}`,
|
|
110
|
+
"--remote-debugging-port=0",
|
|
111
|
+
"--remote-allow-origins=http://127.0.0.1",
|
|
112
|
+
"--no-first-run",
|
|
113
|
+
"--no-default-browser-check",
|
|
114
|
+
"--disable-sync",
|
|
115
|
+
// Tests run the browser without a window.
|
|
116
|
+
...(headless ? ["--headless=new"] : []),
|
|
117
|
+
"--new-window",
|
|
118
|
+
`${config.baseUrl}/research/`,
|
|
119
|
+
], { stdio: "ignore" });
|
|
120
|
+
let exited = false;
|
|
121
|
+
chrome.on("exit", () => (exited = true));
|
|
122
|
+
chrome.on("error", () => (exited = true));
|
|
123
|
+
const failed = (message) => ({
|
|
124
|
+
status: "sign_in_failed",
|
|
125
|
+
message,
|
|
126
|
+
});
|
|
127
|
+
try {
|
|
128
|
+
let port, path;
|
|
129
|
+
for (let i = 0; i < 300 && !port && !exited; i++) {
|
|
130
|
+
try {
|
|
131
|
+
[port, path] = (await readFile(join(profile, "DevToolsActivePort"), "utf8")).split("\n");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
await delay(100);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!port)
|
|
138
|
+
return failed(`The browser did not start (${exe}); ${fallback}.`);
|
|
139
|
+
const cdp = await devtools(`ws://127.0.0.1:${port}${path}`);
|
|
140
|
+
opened();
|
|
141
|
+
const session = async () => {
|
|
142
|
+
const { cookies } = await cdp.send("Storage.getCookies");
|
|
143
|
+
return new Map(cookies
|
|
144
|
+
.filter((c) => SESSION_COOKIES.includes(c.name) &&
|
|
145
|
+
(c.domain.replace(/^\./, "") === host ||
|
|
146
|
+
host.endsWith(c.domain.startsWith(".") ? c.domain : "." + c.domain)))
|
|
147
|
+
.map((c) => [c.name, c.value]));
|
|
148
|
+
};
|
|
149
|
+
const deadline = Date.now() + SIGN_IN_MINUTES * 60000;
|
|
150
|
+
let found;
|
|
151
|
+
while (!exited && Date.now() < deadline) {
|
|
152
|
+
const s = await session().catch(() => undefined);
|
|
153
|
+
const exp = claims(s?.get("ap_access_token"))?.exp;
|
|
154
|
+
if (s?.has("ap_refresh_token") && exp && exp * 1000 > Date.now()) {
|
|
155
|
+
// The page may still be loading; read again once it settles, then close at once so
|
|
156
|
+
// the window never renews this session itself.
|
|
157
|
+
await delay(3000);
|
|
158
|
+
found = (await session().catch(() => undefined)) ?? s;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
await delay(1000);
|
|
162
|
+
}
|
|
163
|
+
await cdp.send("Browser.close").catch(() => { });
|
|
164
|
+
cdp.close();
|
|
165
|
+
if (!found)
|
|
166
|
+
return failed(exited
|
|
167
|
+
? "The window was closed before sign-in finished; nothing was saved."
|
|
168
|
+
: `No sign-in within ${SIGN_IN_MINUTES} min; nothing was saved.`);
|
|
169
|
+
// Check the access token without sending the refresh token, so nothing is renewed.
|
|
170
|
+
const response = await fetch(config.baseUrl + config.apiPath + "/auth/session", {
|
|
171
|
+
headers: {
|
|
172
|
+
Cookie: `ap_access_token=${found.get("ap_access_token")}`,
|
|
173
|
+
Accept: "application/json",
|
|
174
|
+
},
|
|
175
|
+
redirect: "manual",
|
|
176
|
+
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
|
177
|
+
}).catch(() => undefined);
|
|
178
|
+
await response?.body?.cancel();
|
|
179
|
+
if (response?.status === 401)
|
|
180
|
+
return failed("Constructor rejected the new session; nothing was saved.");
|
|
181
|
+
await saveSession(file, new Map([...found, ...(response ? setCookies(response) : [])]), config.baseUrl);
|
|
182
|
+
const end = claims(found.get("ap_access_token"))?.max_slt;
|
|
183
|
+
const until = end
|
|
184
|
+
? new Date(end * 1000).toISOString().slice(0, 16).replace("T", " ") +
|
|
185
|
+
" UTC"
|
|
186
|
+
: undefined;
|
|
187
|
+
return {
|
|
188
|
+
status: "signed_in",
|
|
189
|
+
message: `Signed in to ${config.baseUrl}. Session saved to ${file}` +
|
|
190
|
+
(until ? `; the MCP renews it automatically until ${until}.` : "."),
|
|
191
|
+
session_file: file,
|
|
192
|
+
host: config.baseUrl,
|
|
193
|
+
until,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
return failed(`Sign-in failed: ${e?.message ?? e}. Nothing was saved.`);
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
if (!exited) {
|
|
201
|
+
chrome.kill();
|
|
202
|
+
for (let i = 0; i < 50 && !exited; i++)
|
|
203
|
+
await delay(100);
|
|
204
|
+
if (!exited)
|
|
205
|
+
chrome.kill("SIGKILL"); // TerminateProcess on Windows
|
|
206
|
+
}
|
|
207
|
+
await rm(profile, { recursive: true, force: true }).catch(() => { });
|
|
208
|
+
}
|
|
209
|
+
}
|