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/api.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
-
import { DEFAULT_BASE_URL } from "./config.js";
|
|
4
|
-
import {
|
|
5
|
-
import { withLock } from "./store.js";
|
|
3
|
+
import { DEFAULT_BASE_URL, VERSION } from "./config.js";
|
|
4
|
+
import { readKeyFile } from "./key.js";
|
|
6
5
|
export class ToolError extends Error {
|
|
7
6
|
code;
|
|
8
7
|
status;
|
|
@@ -15,14 +14,12 @@ export class ToolError extends Error {
|
|
|
15
14
|
}
|
|
16
15
|
}
|
|
17
16
|
export const segment = (s) => encodeURIComponent(s);
|
|
18
|
-
/**
|
|
19
|
-
export function
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
return
|
|
17
|
+
/** A key as Constructor shows it, with or without the "Bearer " the header adds. */
|
|
18
|
+
export function cleanKey(raw) {
|
|
19
|
+
const key = raw?.trim().replace(/^Bearer\s+/i, "");
|
|
20
|
+
// Printable ASCII only: anything else could not travel in a header.
|
|
21
|
+
return key && /^[\x21-\x7e]{8,512}$/.test(key) ? key : undefined;
|
|
23
22
|
}
|
|
24
|
-
/** Renew the access token when it has less than this left (it lasts an hour). */
|
|
25
|
-
const RENEW_MS = 10 * 60000;
|
|
26
23
|
export class PlatformApi {
|
|
27
24
|
config;
|
|
28
25
|
deadline = new AsyncLocalStorage();
|
|
@@ -36,123 +33,45 @@ export class PlatformApi {
|
|
|
36
33
|
withBudget(milliseconds, fn) {
|
|
37
34
|
return this.deadline.run(Date.now() + milliseconds, fn);
|
|
38
35
|
}
|
|
39
|
-
/** How
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return `${what} ${this.signingIn
|
|
43
|
-
? "A sign-in window is already open: ask the user to finish signing in there, then call sign_in to confirm."
|
|
44
|
-
: "Tell the user a sign-in window is about to open, then call the sign_in tool: the user signs in in a separate Chrome window that closes by itself, and the MCP renews the session automatically for up to 14 days."} Without a desktop browser, the user runs: npx -y -p constructor-research-mcp constructor-research-set-token <file with a copied cURL> --to ${to}. Then repeat the same call.`;
|
|
36
|
+
/** How the user supplies a key; the key itself never passes through the chat. */
|
|
37
|
+
keyHelp(what = "No Constructor access key is set.") {
|
|
38
|
+
return `${what} The MCP needs the user's personal access key (rk-…) for ${this.config.baseUrl}. Ask the user to run this in a terminal and paste the key when asked: npx -y -p constructor-research-mcp@${VERSION} constructor-research-set-key ${this.config.baseUrl} (or to set CONSTRUCTOR_ACCESS_KEY and CONSTRUCTOR_BASE_URL in the MCP settings). Never ask the user to paste the key into the chat. Then repeat the same call.`;
|
|
45
39
|
}
|
|
46
40
|
/**
|
|
47
|
-
* Works with the Constructor the saved
|
|
48
|
-
*
|
|
41
|
+
* Works with the Constructor the saved key belongs to, unless CONSTRUCTOR_BASE_URL fixes it.
|
|
42
|
+
* Read before every tool call, so a key saved meanwhile is used without a restart.
|
|
49
43
|
*/
|
|
50
|
-
async
|
|
51
|
-
if (this.config.baseUrlFixed)
|
|
44
|
+
async refresh() {
|
|
45
|
+
if (this.config.baseUrlFixed || this.config.accessKey)
|
|
52
46
|
return;
|
|
53
|
-
const
|
|
54
|
-
this.config.baseUrl =
|
|
47
|
+
const saved = await readKeyFile(this.config.keyFile).catch(() => undefined);
|
|
48
|
+
this.config.baseUrl = saved?.url ?? DEFAULT_BASE_URL;
|
|
55
49
|
}
|
|
56
|
-
/** The
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* Time left on the saved credentials: the login session when it can be renewed
|
|
62
|
-
* (`max_slt`, the latest renewal the server allows), else the access token.
|
|
63
|
-
*/
|
|
64
|
-
async authLeft() {
|
|
65
|
-
const saved = await readSession(this.config.cookieFile);
|
|
66
|
-
const access = claims(saved.get("ap_access_token"));
|
|
67
|
-
const renewable = saved.has("ap_refresh_token");
|
|
68
|
-
const end = renewable ? access?.max_slt : access?.exp;
|
|
69
|
-
if (renewable && end === undefined)
|
|
70
|
-
return undefined;
|
|
71
|
-
if (typeof end !== "number")
|
|
72
|
-
return undefined;
|
|
73
|
-
return { minutes: (end * 1000 - Date.now()) / 60000, renewable };
|
|
74
|
-
}
|
|
75
|
-
renewing;
|
|
76
|
-
/** The Cookie header for a request: only the access token, renewed first when it runs low. */
|
|
77
|
-
async cookie() {
|
|
78
|
-
let saved;
|
|
79
|
-
try {
|
|
80
|
-
saved = await readSession(this.config.cookieFile);
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
saved = new Map();
|
|
84
|
-
}
|
|
85
|
-
let token = saved.get("ap_access_token");
|
|
86
|
-
const exp = expiry(`ap_access_token=${token}`);
|
|
87
|
-
// Never send one Constructor's session to another.
|
|
88
|
-
const owner = await sessionHost(this.config.cookieFile);
|
|
89
|
-
if (owner && owner !== this.config.baseUrl)
|
|
90
|
-
throw new ToolError("needs_auth", this.authHelp(`${this.config.cookieFile} holds a session for ${owner}, but this MCP works with ${this.config.baseUrl}.`));
|
|
91
|
-
if (saved.has("ap_refresh_token") &&
|
|
92
|
-
(!token || (exp !== undefined && exp * 1000 - Date.now() < RENEW_MS)))
|
|
93
|
-
token = await (this.renewing ??= this.renew().finally(() => (this.renewing = undefined)));
|
|
94
|
-
else if (!token)
|
|
95
|
-
throw new ToolError("needs_auth", this.authHelp());
|
|
96
|
-
else if (exp !== undefined && exp * 1000 <= Date.now())
|
|
97
|
-
throw new ToolError("needs_auth", this.authHelp(`Token expired at ${new Date(exp * 1000).toISOString().slice(11, 16)} UTC.`));
|
|
98
|
-
return `ap_access_token=${token}`;
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Trades the refresh token for new tokens and saves them before anything else. A refresh
|
|
102
|
-
* token works once, so several MCP processes sharing the file renew under one lock, and
|
|
103
|
-
* a process that waited uses the tokens another one just saved.
|
|
104
|
-
*/
|
|
105
|
-
renew() {
|
|
106
|
-
const file = this.config.cookieFile;
|
|
107
|
-
return withLock(file + ".lock", async () => {
|
|
108
|
-
const saved = await readSession(file);
|
|
109
|
-
const token = saved.get("ap_access_token");
|
|
110
|
-
const exp = expiry(`ap_access_token=${token}`);
|
|
111
|
-
const valid = !!token && (exp === undefined || exp * 1000 > Date.now());
|
|
112
|
-
const refresh = saved.get("ap_refresh_token");
|
|
113
|
-
if (!refresh ||
|
|
114
|
-
(exp !== undefined && exp * 1000 - Date.now() >= RENEW_MS))
|
|
115
|
-
return this.usable(token, valid);
|
|
116
|
-
let response;
|
|
50
|
+
/** The access-key header for a request. */
|
|
51
|
+
async auth() {
|
|
52
|
+
let key = this.config.accessKey;
|
|
53
|
+
if (key === undefined) {
|
|
54
|
+
let saved;
|
|
117
55
|
try {
|
|
118
|
-
|
|
119
|
-
// Without an access token the server must renew; the browser does the same.
|
|
120
|
-
headers: {
|
|
121
|
-
Cookie: `ap_refresh_token=${refresh}`,
|
|
122
|
-
Accept: "application/json",
|
|
123
|
-
},
|
|
124
|
-
redirect: "manual",
|
|
125
|
-
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
if (valid)
|
|
130
|
-
return token;
|
|
131
|
-
throw new ToolError("connection_error", "Could not reach Constructor to renew the session; repeat the same call.");
|
|
56
|
+
saved = await readKeyFile(this.config.keyFile);
|
|
132
57
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (fresh.has("ap_access_token") && fresh.has("ap_refresh_token")) {
|
|
136
|
-
await saveSession(file, new Map([...saved, ...fresh]));
|
|
137
|
-
return fresh.get("ap_access_token");
|
|
58
|
+
catch (err) {
|
|
59
|
+
throw new ToolError("needs_key", this.keyHelp(`${this.config.keyFile} could not be read (${err?.message ?? "invalid"}).`));
|
|
138
60
|
}
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}, 15000, () => new ToolError("busy", `Another process is renewing the session (${file}.lock); repeat the same call.`));
|
|
61
|
+
if (!saved)
|
|
62
|
+
throw new ToolError("needs_key", this.keyHelp());
|
|
63
|
+
// Never send one Constructor's key to another.
|
|
64
|
+
if (saved.url !== this.config.baseUrl)
|
|
65
|
+
throw new ToolError("needs_key", this.keyHelp(`The saved key belongs to ${saved.url}, but this MCP works with ${this.config.baseUrl}.`));
|
|
66
|
+
key = saved.key;
|
|
67
|
+
}
|
|
68
|
+
const clean = cleanKey(key);
|
|
69
|
+
if (!clean)
|
|
70
|
+
throw new ToolError("needs_key", this.keyHelp("The access key is not in a valid format."));
|
|
71
|
+
return { "X-Research-AccessKey": `Bearer ${clean}` };
|
|
151
72
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return token;
|
|
155
|
-
throw new ToolError("needs_auth", this.authHelp(token ? "Token expired." : undefined));
|
|
73
|
+
rejected() {
|
|
74
|
+
return new ToolError("needs_key", this.keyHelp("Constructor rejected the access key (revoked, expired or for another Constructor)."), 401);
|
|
156
75
|
}
|
|
157
76
|
/**
|
|
158
77
|
* WebDAV request to a disk's project folder; callers interpret statuses other than 401.
|
|
@@ -160,7 +79,7 @@ export class PlatformApi {
|
|
|
160
79
|
* safe to repeat (all but MOVE and DELETE) are retried a few times with backoff.
|
|
161
80
|
*/
|
|
162
81
|
async storage(method, desk, path, init = {}) {
|
|
163
|
-
const
|
|
82
|
+
const auth = await this.auth();
|
|
164
83
|
const retries = ["MOVE", "DELETE"].includes(method)
|
|
165
84
|
? []
|
|
166
85
|
: this.storageRetryMs;
|
|
@@ -174,7 +93,7 @@ export class PlatformApi {
|
|
|
174
93
|
try {
|
|
175
94
|
response = await fetch(this.config.baseUrl + this.davRoot(desk) + path, {
|
|
176
95
|
method,
|
|
177
|
-
headers: {
|
|
96
|
+
headers: { ...auth, ...init.headers },
|
|
178
97
|
body: init.body,
|
|
179
98
|
redirect: "manual",
|
|
180
99
|
signal: AbortSignal.timeout(Math.max(1, Math.floor(Math.min(60000, remaining)))),
|
|
@@ -192,23 +111,25 @@ export class PlatformApi {
|
|
|
192
111
|
await delay(wait);
|
|
193
112
|
continue;
|
|
194
113
|
}
|
|
195
|
-
if (response.status === 401)
|
|
196
|
-
|
|
114
|
+
if (response.status === 401) {
|
|
115
|
+
await response.body?.cancel();
|
|
116
|
+
throw this.rejected();
|
|
117
|
+
}
|
|
197
118
|
return response;
|
|
198
119
|
}
|
|
199
120
|
}
|
|
200
121
|
/**
|
|
201
122
|
* Opens a build-log stream. It stays open while the build runs; the caller reads the
|
|
202
|
-
* body and aborts through `signal`. 401 becomes
|
|
123
|
+
* body and aborts through `signal`. 401 becomes needs_key; other statuses are returned.
|
|
203
124
|
*/
|
|
204
125
|
async stream(path, signal) {
|
|
205
|
-
const
|
|
126
|
+
const auth = await this.auth();
|
|
206
127
|
let response;
|
|
207
128
|
try {
|
|
208
129
|
response = await fetch(this.config.baseUrl +
|
|
209
130
|
(this.config.keeperPath ?? "/api/platform-keeper") +
|
|
210
131
|
path, {
|
|
211
|
-
headers: {
|
|
132
|
+
headers: { ...auth, Accept: "text/event-stream" },
|
|
212
133
|
redirect: "manual",
|
|
213
134
|
signal,
|
|
214
135
|
});
|
|
@@ -218,7 +139,7 @@ export class PlatformApi {
|
|
|
218
139
|
}
|
|
219
140
|
if (response.status === 401) {
|
|
220
141
|
await response.body?.cancel();
|
|
221
|
-
throw
|
|
142
|
+
throw this.rejected();
|
|
222
143
|
}
|
|
223
144
|
return response;
|
|
224
145
|
}
|
|
@@ -229,7 +150,7 @@ export class PlatformApi {
|
|
|
229
150
|
return `${this.config.storagePath ?? "/api/platform-storage"}/desks/${segment(desk)}/dav/project/`;
|
|
230
151
|
}
|
|
231
152
|
async request(method, path, body) {
|
|
232
|
-
const
|
|
153
|
+
const auth = await this.auth();
|
|
233
154
|
const remaining = (this.deadline.getStore() ?? Infinity) - Date.now();
|
|
234
155
|
if (remaining <= 0)
|
|
235
156
|
throw new ToolError("connection_error", "Operation time budget exhausted before sending the next request; resume with get_deployment.");
|
|
@@ -238,7 +159,7 @@ export class PlatformApi {
|
|
|
238
159
|
response = await fetch(this.config.baseUrl + this.config.apiPath + path, {
|
|
239
160
|
method,
|
|
240
161
|
headers: {
|
|
241
|
-
|
|
162
|
+
...auth,
|
|
242
163
|
Accept: "application/json",
|
|
243
164
|
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
244
165
|
},
|
|
@@ -252,8 +173,10 @@ export class PlatformApi {
|
|
|
252
173
|
? "API connection failed or timed out; retry the read."
|
|
253
174
|
: "The API request may have been applied. Inspect resources before retrying this mutation.");
|
|
254
175
|
}
|
|
255
|
-
if (response.status === 401)
|
|
256
|
-
|
|
176
|
+
if (response.status === 401) {
|
|
177
|
+
await response.body?.cancel();
|
|
178
|
+
throw this.rejected();
|
|
179
|
+
}
|
|
257
180
|
if (!response.ok) {
|
|
258
181
|
if (method !== "GET" && response.status >= 500)
|
|
259
182
|
throw new ToolError("uncertain_outcome", `Constructor API returned HTTP ${response.status}. The mutation may have been applied; inspect resources before retrying.`, response.status);
|
package/dist/config.d.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import { type LocalDesk } from "./desk.js";
|
|
1
2
|
export interface Config {
|
|
2
|
-
/** The Constructor the MCP works with: the
|
|
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. */
|
|
8
11
|
keeperPath?: string;
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
/** Personal access key (rk-…) from CONSTRUCTOR_ACCESS_KEY; without it the key file is read. */
|
|
13
|
+
accessKey?: string;
|
|
14
|
+
/** Saved key and the Constructor it belongs to (JSON, mode 600); CONSTRUCTOR_KEY_FILE moves it. */
|
|
15
|
+
keyFile: string;
|
|
12
16
|
stateDir: string;
|
|
13
17
|
requestTimeoutMs: number;
|
|
14
18
|
/** How long to wait for public URLs to answer after start; 0 disables the check. */
|
|
@@ -16,28 +20,13 @@ export interface Config {
|
|
|
16
20
|
/** Pause between uploading to a disk and building from it; 0 disables. */
|
|
17
21
|
uploadSettleSeconds?: number;
|
|
18
22
|
}
|
|
19
|
-
/** Where the MCP works
|
|
23
|
+
/** Where the MCP works unless CONSTRUCTOR_BASE_URL or the saved key names another Constructor. */
|
|
20
24
|
export declare const DEFAULT_BASE_URL = "https://constructor.app";
|
|
21
25
|
/** The origin of a Constructor link; HTTPS except on localhost. */
|
|
22
26
|
export declare function originOf(link: string): string;
|
|
23
27
|
export declare function configFromEnv(env?: NodeJS.ProcessEnv): Config;
|
|
24
|
-
/**
|
|
25
|
-
export declare const
|
|
26
|
-
/** The harness behind an MCP client name (from the initialize handshake). */
|
|
27
|
-
export declare function harnessOf(client?: string): string;
|
|
28
|
-
/** Where a harness's Constructor session is kept (mode 600). */
|
|
29
|
-
export declare function sessionFile(harness: string): string;
|
|
30
|
-
/**
|
|
31
|
-
* Session file named on a command line: --for <harness> or --to <file>; without either,
|
|
32
|
-
* CONSTRUCTOR_COOKIE_FILE. Returns the remaining arguments too.
|
|
33
|
-
*/
|
|
34
|
-
export declare function sessionArgs(args: string[], config: Config): {
|
|
35
|
-
file: string;
|
|
36
|
-
rest: string[];
|
|
37
|
-
} | {
|
|
38
|
-
file: undefined;
|
|
39
|
-
rest: string[];
|
|
40
|
-
};
|
|
28
|
+
/** Where constructor-research-set-key saves the key; the MCP and the wait command read it. */
|
|
29
|
+
export declare const DEFAULT_KEY_FILE = "~/.config/constructor-research/access-key.json";
|
|
41
30
|
/** Shell command that waits for deployments, carrying the settings that differ from the defaults. */
|
|
42
31
|
export declare function waitCommand(config: Config, ids: string[]): string;
|
|
43
32
|
/** This package's version, as published; the server reports it on connect. */
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,8 @@ 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
|
-
|
|
5
|
+
import { localDeskFromEnv } from "./desk.js";
|
|
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. */
|
|
8
9
|
export function originOf(link) {
|
|
@@ -28,8 +29,10 @@ 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
|
-
const
|
|
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);
|
|
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.");
|
|
35
38
|
const storagePath = env.CONSTRUCTOR_STORAGE_PATH ?? "/api/platform-storage";
|
|
@@ -41,63 +44,28 @@ 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(/\/$/, ""),
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
: sessionFile("default"),
|
|
51
|
-
cookieFileFixed: !!env.CONSTRUCTOR_COOKIE_FILE,
|
|
52
|
+
accessKey: env.CONSTRUCTOR_ACCESS_KEY?.trim() || undefined,
|
|
53
|
+
keyFile: expand(env.CONSTRUCTOR_KEY_FILE ?? DEFAULT_KEY_FILE),
|
|
52
54
|
stateDir: expand(env.CONSTRUCTOR_STATE_DIR ?? "~/.local/state/constructor-research-mcp"),
|
|
53
55
|
requestTimeoutMs: 15000,
|
|
54
56
|
uploadSettleSeconds: Math.max(0, Math.min(300, Number(env.CONSTRUCTOR_UPLOAD_SETTLE_SECONDS ?? 30) || 0)),
|
|
55
57
|
readinessSeconds: Math.max(0, Math.min(300, Number(env.CONSTRUCTOR_READINESS_SECONDS ?? 60) || 0)),
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
|
-
/**
|
|
59
|
-
export const
|
|
60
|
-
/** The harness behind an MCP client name (from the initialize handshake). */
|
|
61
|
-
export function harnessOf(client) {
|
|
62
|
-
const name = (client ?? "").toLowerCase();
|
|
63
|
-
if (name.includes("claude"))
|
|
64
|
-
return "claude-code";
|
|
65
|
-
if (name.includes("codex"))
|
|
66
|
-
return "codex";
|
|
67
|
-
if (name.includes("opencode"))
|
|
68
|
-
return "opencode";
|
|
69
|
-
return name.replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "") || "default";
|
|
70
|
-
}
|
|
71
|
-
/** Where a harness's Constructor session is kept (mode 600). */
|
|
72
|
-
export function sessionFile(harness) {
|
|
73
|
-
return join(homedir(), ".config", "constructor-research", "sessions", `${harness}.txt`);
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Session file named on a command line: --for <harness> or --to <file>; without either,
|
|
77
|
-
* CONSTRUCTOR_COOKIE_FILE. Returns the remaining arguments too.
|
|
78
|
-
*/
|
|
79
|
-
export function sessionArgs(args, config) {
|
|
80
|
-
const value = (flag) => {
|
|
81
|
-
const i = args.indexOf(flag);
|
|
82
|
-
return i >= 0 ? args[i + 1] : undefined;
|
|
83
|
-
};
|
|
84
|
-
const to = value("--to"), harness = value("--for");
|
|
85
|
-
const rest = args.filter((_, n) => !["--to", "--for"].includes(args[n]) &&
|
|
86
|
-
!["--to", "--for"].includes(args[n - 1]));
|
|
87
|
-
if (to)
|
|
88
|
-
return { file: resolve(to), rest };
|
|
89
|
-
if (harness && HARNESSES.includes(harness))
|
|
90
|
-
return { file: sessionFile(harness), rest };
|
|
91
|
-
if (!harness && config.cookieFileFixed)
|
|
92
|
-
return { file: config.cookieFile, rest };
|
|
93
|
-
return { file: undefined, rest };
|
|
94
|
-
}
|
|
60
|
+
/** Where constructor-research-set-key saves the key; the MCP and the wait command read it. */
|
|
61
|
+
export const DEFAULT_KEY_FILE = "~/.config/constructor-research/access-key.json";
|
|
95
62
|
const quote = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
96
63
|
/** Shell command that waits for deployments, carrying the settings that differ from the defaults. */
|
|
97
64
|
export function waitCommand(config, ids) {
|
|
98
65
|
const defaults = configFromEnv({});
|
|
99
66
|
const env = [
|
|
100
|
-
// The wait command reads the host from the
|
|
67
|
+
// The wait command reads the host from the key file, as the MCP does, unless it is fixed.
|
|
68
|
+
// The key itself never goes into a command line: CONSTRUCTOR_ACCESS_KEY stays in the environment.
|
|
101
69
|
[
|
|
102
70
|
"CONSTRUCTOR_BASE_URL",
|
|
103
71
|
config.baseUrlFixed ? config.baseUrl : undefined,
|
|
@@ -106,7 +74,7 @@ export function waitCommand(config, ids) {
|
|
|
106
74
|
["CONSTRUCTOR_API_PATH", config.apiPath, defaults.apiPath],
|
|
107
75
|
["CONSTRUCTOR_STORAGE_PATH", config.storagePath, defaults.storagePath],
|
|
108
76
|
["CONSTRUCTOR_KEEPER_PATH", config.keeperPath, defaults.keeperPath],
|
|
109
|
-
["
|
|
77
|
+
["CONSTRUCTOR_KEY_FILE", config.keyFile, defaults.keyFile],
|
|
110
78
|
["CONSTRUCTOR_STATE_DIR", config.stateDir, defaults.stateDir],
|
|
111
79
|
]
|
|
112
80
|
.filter(([, value, standard]) => value !== standard)
|
package/dist/deployment.d.ts
CHANGED
|
@@ -72,6 +72,8 @@ export declare class DeploymentEngine {
|
|
|
72
72
|
get(raw: unknown): Promise<any>;
|
|
73
73
|
private mutate;
|
|
74
74
|
private advance;
|
|
75
|
+
/** Switches on deployment; when the key may not, the operation stops with what to do instead. */
|
|
76
|
+
private enableDeployment;
|
|
75
77
|
private advanceBuild;
|
|
76
78
|
private advanceLifecycle;
|
|
77
79
|
private advanceApp;
|
package/dist/deployment.js
CHANGED
|
@@ -244,7 +244,7 @@ export class DeploymentEngine {
|
|
|
244
244
|
});
|
|
245
245
|
}
|
|
246
246
|
catch (err) {
|
|
247
|
-
if (err instanceof ToolError && err.code === "
|
|
247
|
+
if (err instanceof ToolError && err.code === "needs_key")
|
|
248
248
|
throw err;
|
|
249
249
|
}
|
|
250
250
|
if (this.files) {
|
|
@@ -605,7 +605,7 @@ export class DeploymentEngine {
|
|
|
605
605
|
}));
|
|
606
606
|
}
|
|
607
607
|
catch (err) {
|
|
608
|
-
if (err instanceof ToolError && err.code === "
|
|
608
|
+
if (err instanceof ToolError && err.code === "needs_key")
|
|
609
609
|
throw err;
|
|
610
610
|
row.error =
|
|
611
611
|
err instanceof ToolError
|
|
@@ -751,8 +751,8 @@ export class DeploymentEngine {
|
|
|
751
751
|
if (err.code === "invalid_input")
|
|
752
752
|
throw err;
|
|
753
753
|
op.status =
|
|
754
|
-
err.code === "
|
|
755
|
-
? "
|
|
754
|
+
err.code === "needs_key"
|
|
755
|
+
? "needs_key"
|
|
756
756
|
: err.code === "uncertain_outcome"
|
|
757
757
|
? "needs_reconciliation"
|
|
758
758
|
: err.code === "connection_error" || err.code === "not_ready"
|
|
@@ -762,7 +762,11 @@ export class DeploymentEngine {
|
|
|
762
762
|
op.status === "failed"
|
|
763
763
|
? `${err.message} Please check the logs in Constructor.`
|
|
764
764
|
: err.message;
|
|
765
|
-
if (
|
|
765
|
+
if (err.code === "deployment_disabled") {
|
|
766
|
+
op.failure = { stage: "prepare" };
|
|
767
|
+
op.message = err.message;
|
|
768
|
+
}
|
|
769
|
+
else if (op.status === "failed") {
|
|
766
770
|
op.failure = op.action ?? {
|
|
767
771
|
stage: op.kind === "build" ? "build" : "deployment",
|
|
768
772
|
};
|
|
@@ -778,6 +782,19 @@ export class DeploymentEngine {
|
|
|
778
782
|
return this.view(op);
|
|
779
783
|
}
|
|
780
784
|
}
|
|
785
|
+
/** Switches on deployment; when the key may not, the operation stops with what to do instead. */
|
|
786
|
+
async enableDeployment(op, project) {
|
|
787
|
+
try {
|
|
788
|
+
await this.mutate(op, "PATCH", this.root(project), { deployment_enabled: true }, () => {
|
|
789
|
+
op.status = "preparing";
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
catch (err) {
|
|
793
|
+
if (err instanceof ToolError && err.status === 403)
|
|
794
|
+
throw new ToolError("deployment_disabled", "Deployment is switched off for this project, and the access key may not switch it on. Ask the user to switch on deployment for the project in Constructor, then repeat the same call.", 403, err.apiCode);
|
|
795
|
+
throw err;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
781
798
|
async advanceBuild(op) {
|
|
782
799
|
const i = op.input;
|
|
783
800
|
await this.project(i.project_id);
|
|
@@ -785,9 +802,7 @@ export class DeploymentEngine {
|
|
|
785
802
|
if (!op.images.image) {
|
|
786
803
|
const p = await this.project(i.project_id);
|
|
787
804
|
if (!p.deployment_enabled) {
|
|
788
|
-
await this.
|
|
789
|
-
op.status = "preparing";
|
|
790
|
-
});
|
|
805
|
+
await this.enableDeployment(op, i.project_id);
|
|
791
806
|
return this.view(op);
|
|
792
807
|
}
|
|
793
808
|
if (await this.settling(op, i.desk_id))
|
|
@@ -957,9 +972,7 @@ export class DeploymentEngine {
|
|
|
957
972
|
return this.view(op);
|
|
958
973
|
}
|
|
959
974
|
if (!project.deployment_enabled) {
|
|
960
|
-
await this.
|
|
961
|
-
op.status = "preparing";
|
|
962
|
-
});
|
|
975
|
+
await this.enableDeployment(op, op.project_id);
|
|
963
976
|
return this.view(op);
|
|
964
977
|
}
|
|
965
978
|
const builds = services.filter((s) => s.build);
|
|
@@ -1218,8 +1231,8 @@ export class DeploymentEngine {
|
|
|
1218
1231
|
? "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 use browser or other tools to investigate."
|
|
1219
1232
|
: terminal.has(op.status)
|
|
1220
1233
|
? undefined
|
|
1221
|
-
: op.status === "
|
|
1222
|
-
? `${this.api.
|
|
1234
|
+
: op.status === "needs_key"
|
|
1235
|
+
? `${this.api.keyHelp("Constructor rejected the access key, or none is set.")} Then call get_deployment with this deployment_id.`
|
|
1223
1236
|
: op.status === "needs_upload"
|
|
1224
1237
|
? "Upload files with sync_files, then call get_deployment with files_uploaded=true."
|
|
1225
1238
|
: "Execution continues automatically in the MCP. Keep calling get_deployment with wait_seconds=20 until it is finished, then tell the user the result; do not orchestrate individual steps.",
|
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
|
}
|