constructor-research-mcp 0.3.1 → 0.4.0-beta.1
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 +34 -50
- package/dist/api.d.ts +11 -30
- package/dist/api.js +53 -130
- package/dist/config.d.ts +8 -22
- package/dist/config.js +9 -45
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.js +27 -14
- package/dist/files.js +1 -1
- package/dist/key.d.ts +9 -0
- package/dist/key.js +35 -0
- package/dist/location.js +2 -1
- package/dist/logs.js +2 -2
- package/dist/runtime.js +5 -5
- package/dist/schemas.d.ts +0 -5
- package/dist/schemas.js +0 -18
- package/dist/server.js +9 -104
- 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/login.js +0 -25
- package/dist/session.d.ts +0 -21
- package/dist/session.js +0 -108
- package/dist/set-token.d.ts +0 -2
- package/dist/set-token.js +0 -48
- package/dist/signin.d.ts +0 -19
- package/dist/signin.js +0 -209
- /package/dist/{login.d.ts → set-key.d.ts} +0 -0
package/dist/runtime.js
CHANGED
|
@@ -3,7 +3,7 @@ import { terminal } from "./deployment.js";
|
|
|
3
3
|
import { ToolError } from "./api.js";
|
|
4
4
|
import { waitCommand } from "./config.js";
|
|
5
5
|
import { buildInput, deployInput, updateInput, lifecycleInput, getInput, } from "./schemas.js";
|
|
6
|
-
export const paused = new Set(["
|
|
6
|
+
export const paused = new Set(["needs_key", "needs_upload"]);
|
|
7
7
|
/** Tells the agent which unfinished deployments a new one ended, and what they changed that it does not. */
|
|
8
8
|
function replacedNote(replaced) {
|
|
9
9
|
if (!replaced?.length)
|
|
@@ -73,7 +73,7 @@ export class DeploymentRuntime {
|
|
|
73
73
|
await this.engine.api.withBudget(15000, () => this.engine.get({ ...i, resume: true }));
|
|
74
74
|
}
|
|
75
75
|
let op = await this.engine.store.load(i.deployment_id);
|
|
76
|
-
if (op?.status === "
|
|
76
|
+
if (op?.status === "needs_key" &&
|
|
77
77
|
i.resume &&
|
|
78
78
|
i.files_uploaded === undefined) {
|
|
79
79
|
await this.pause(i.deployment_id);
|
|
@@ -210,8 +210,8 @@ export class DeploymentRuntime {
|
|
|
210
210
|
: "background",
|
|
211
211
|
next_action: op.status === "failed"
|
|
212
212
|
? "Tell the user which stage/service failed and pass on the message. failure.log_tail has the last log lines when the MCP captured them; get_logs shows more. Do not investigate using browser or other tools."
|
|
213
|
-
: op.status === "
|
|
214
|
-
? `${this.engine.api.
|
|
213
|
+
: op.status === "needs_key"
|
|
214
|
+
? `${this.engine.api.keyHelp("Constructor rejected the access key, or none is set.")} Then check this deployment again.`
|
|
215
215
|
: op.status === "needs_upload"
|
|
216
216
|
? "Upload the listed files with sync_files, then call get_deployment with files_uploaded=true."
|
|
217
217
|
: op.status === "confirm_required"
|
|
@@ -220,7 +220,7 @@ export class DeploymentRuntime {
|
|
|
220
220
|
? "Inspect the uncertain mutation in Constructor before submitting a new operation."
|
|
221
221
|
: terminal.has(op.status)
|
|
222
222
|
? undefined
|
|
223
|
-
: `
|
|
223
|
+
: `Not finished yet; builds often take several minutes. Do not end your turn or answer the user until execution is finished: they asked for the result (the public_url, or what failed). Finish any other work of this request, then wait with the shell command \`${waitCommand(this.engine.api.config, [op.id])}\`; it prints the outcome when the deployment finishes. Run it in the foreground, or in the background only if your client wakes you when a background command ends. Without a shell, keep calling get_deployment with wait_seconds=20 until execution is finished.`,
|
|
224
224
|
};
|
|
225
225
|
}
|
|
226
226
|
async wait(id, milliseconds) {
|
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,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { configFromEnv,
|
|
4
|
+
import { configFromEnv, VERSION } from "./config.js";
|
|
5
5
|
import { PlatformApi, ToolError } from "./api.js";
|
|
6
6
|
import { StateStore } from "./store.js";
|
|
7
|
-
import { DeploymentEngine
|
|
7
|
+
import { DeploymentEngine } from "./deployment.js";
|
|
8
8
|
import { DeploymentRuntime } from "./runtime.js";
|
|
9
9
|
import { FileManager, dockerignore } from "./files.js";
|
|
10
10
|
import { basename } from "node:path";
|
|
@@ -12,10 +12,7 @@ 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
14
|
import { ZodError } from "zod";
|
|
15
|
-
import { buildInput, listInput, deployInput, updateInput, lifecycleInput, getInput, logsInput, listFilesInput, createFolderInput, syncFilesInput, movePathInput, deletePathInput,
|
|
16
|
-
import { harnessOf, sessionFile } from "./config.js";
|
|
17
|
-
import { sessionHost } from "./session.js";
|
|
18
|
-
import { signIn } from "./signin.js";
|
|
15
|
+
import { buildInput, listInput, deployInput, updateInput, lifecycleInput, getInput, logsInput, listFilesInput, createFolderInput, syncFilesInput, movePathInput, deletePathInput, } from "./schemas.js";
|
|
19
16
|
async function main() {
|
|
20
17
|
const config = configFromEnv();
|
|
21
18
|
const api = new PlatformApi(config);
|
|
@@ -118,7 +115,7 @@ async function main() {
|
|
|
118
115
|
: result;
|
|
119
116
|
};
|
|
120
117
|
const server = new McpServer({ name: "constructor-research", version: VERSION }, {
|
|
121
|
-
instructions: `
|
|
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.`,
|
|
122
119
|
});
|
|
123
120
|
const register = (name, description, schema, fn, readOnly = false, budget = 20000, scope) => {
|
|
124
121
|
if (scope && scope !== "deploy")
|
|
@@ -134,41 +131,10 @@ async function main() {
|
|
|
134
131
|
},
|
|
135
132
|
}, async (args) => {
|
|
136
133
|
try {
|
|
137
|
-
await api.
|
|
138
|
-
|
|
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
|
|
134
|
+
await api.refresh();
|
|
135
|
+
const result = await api.withBudget(budget, () => fn(scope
|
|
156
136
|
? resolveDeskUrl(args, config.baseUrl, scope, config.baseUrlFixed)
|
|
157
137
|
: 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
138
|
return {
|
|
173
139
|
content: [
|
|
174
140
|
{
|
|
@@ -176,10 +142,7 @@ async function main() {
|
|
|
176
142
|
text: result.text ??
|
|
177
143
|
(result.status
|
|
178
144
|
? `${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
|
-
: ""),
|
|
145
|
+
: `Discovery result: ${result.total !== undefined ? result.total + " images" : Object.keys(result).join(", ")}. See structuredContent for details.`),
|
|
183
146
|
},
|
|
184
147
|
],
|
|
185
148
|
// A preformatted text (build logs) is not repeated in structuredContent.
|
|
@@ -226,62 +189,7 @@ async function main() {
|
|
|
226
189
|
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
190
|
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
191
|
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). Services carry status_changes and restarts_seen when the MCP saw the status change or a new container start. After sync_files (or manual upload) set files_uploaded=true; after
|
|
230
|
-
let signing;
|
|
231
|
-
register("sign_in", "Signs the user in to their Constructor: https://constructor.app unless url names another one (any link from it, e.g. https://acme.constructor.app). Call it when the user asks to sign in, or when a tool returns needs_auth, after telling the user a sign-in window is about to open. It opens a separate Chrome window at the sign-in page; the user signs in there and the window closes by itself. There is one sign-in at a time: signing in to another Constructor replaces it, and the MCP works there afterwards. The session is kept for this harness only, and the MCP renews it automatically for up to 14 days. Waits up to wait_seconds; while the user is still signing in it returns sign_in_pending: call it again (no second window opens), then repeat the call that needed auth.", signInInput, async (a) => {
|
|
232
|
-
const i = signInInput.parse(a);
|
|
233
|
-
if (!signing) {
|
|
234
|
-
let host = config.baseUrl;
|
|
235
|
-
if (i.url) {
|
|
236
|
-
try {
|
|
237
|
-
host = originOf(i.url);
|
|
238
|
-
}
|
|
239
|
-
catch (e) {
|
|
240
|
-
throw new ToolError("invalid_input", `url: ${e.message}`);
|
|
241
|
-
}
|
|
242
|
-
if (config.baseUrlFixed && host !== config.baseUrl)
|
|
243
|
-
throw new ToolError("wrong_environment", `This MCP is fixed to ${config.baseUrl} by CONSTRUCTOR_BASE_URL; it cannot sign in to ${host}.`);
|
|
244
|
-
}
|
|
245
|
-
const signedIn = (await sessionHost(config.cookieFile)) === host;
|
|
246
|
-
const left = signedIn
|
|
247
|
-
? await api.authLeft().catch(() => undefined)
|
|
248
|
-
: undefined;
|
|
249
|
-
if (!i.force && left?.renewable && left.minutes > 60)
|
|
250
|
-
return {
|
|
251
|
-
status: "signed_in",
|
|
252
|
-
message: `Already signed in to ${host}; the session renews automatically for another ${Math.floor(left.minutes / 1440)} days. Pass force=true to sign in again.`,
|
|
253
|
-
session_file: config.cookieFile,
|
|
254
|
-
host,
|
|
255
|
-
};
|
|
256
|
-
const state = {
|
|
257
|
-
done: false,
|
|
258
|
-
host,
|
|
259
|
-
promise: Promise.resolve(),
|
|
260
|
-
};
|
|
261
|
-
state.promise = signIn({ ...config, baseUrl: host }, config.cookieFile).then((r) => {
|
|
262
|
-
state.result = r;
|
|
263
|
-
state.done = true;
|
|
264
|
-
if (r.status === "signed_in")
|
|
265
|
-
api.aimed = undefined;
|
|
266
|
-
});
|
|
267
|
-
signing = state;
|
|
268
|
-
api.signingIn = true;
|
|
269
|
-
void state.promise.finally(() => (api.signingIn = false));
|
|
270
|
-
}
|
|
271
|
-
const current = signing;
|
|
272
|
-
await Promise.race([
|
|
273
|
-
current.promise,
|
|
274
|
-
new Promise((r) => setTimeout(r, i.wait_seconds * 1000)),
|
|
275
|
-
]);
|
|
276
|
-
if (!current.done)
|
|
277
|
-
return {
|
|
278
|
-
status: "sign_in_pending",
|
|
279
|
-
message: `A Chrome window is open at ${current.host}. Ask the user to sign in there; it closes by itself afterwards. Then call sign_in again to confirm, or repeat the call that needed auth.`,
|
|
280
|
-
};
|
|
281
|
-
signing = undefined;
|
|
282
|
-
await api.followSession();
|
|
283
|
-
return current.result;
|
|
284
|
-
}, true, 30000);
|
|
192
|
+
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
193
|
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
194
|
const i = logsInput.parse(a);
|
|
287
195
|
let targets = [];
|
|
@@ -333,14 +241,11 @@ async function main() {
|
|
|
333
241
|
process.once(signal, () => {
|
|
334
242
|
void Promise.all([runtime.close(), logs.close()]).then(() => server.close());
|
|
335
243
|
});
|
|
336
|
-
// Each harness keeps its own session, named after the client in the handshake.
|
|
337
244
|
let recovered = false;
|
|
338
245
|
server.server.oninitialized = () => {
|
|
339
|
-
if (!config.cookieFileFixed)
|
|
340
|
-
config.cookieFile = sessionFile(harnessOf(server.server.getClientVersion()?.name));
|
|
341
246
|
if (!recovered) {
|
|
342
247
|
recovered = true;
|
|
343
|
-
void api.
|
|
248
|
+
void api.refresh().finally(() => runtime.recover());
|
|
344
249
|
}
|
|
345
250
|
};
|
|
346
251
|
await server.connect(new StdioServerTransport());
|
package/dist/set-key.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Save a personal access key for the MCP without it appearing in the chat, a command line or the
|
|
3
|
+
// shell history: the key is read from the terminal (hidden) or from stdin.
|
|
4
|
+
// Usage: npx -y -p constructor-research-mcp constructor-research-set-key <Constructor link>
|
|
5
|
+
import { configFromEnv, originOf } from "./config.js";
|
|
6
|
+
import { cleanKey } from "./api.js";
|
|
7
|
+
import { saveKeyFile } from "./key.js";
|
|
8
|
+
const USAGE = "Usage: npx -y -p constructor-research-mcp constructor-research-set-key <Constructor link, e.g. https://acme.constructor.app>\nThen paste the access key (rk-…) when asked, or pipe it in on stdin.";
|
|
9
|
+
/** One line from the terminal without echoing it, or all of stdin when piped. */
|
|
10
|
+
async function readSecret(prompt) {
|
|
11
|
+
const input = process.stdin;
|
|
12
|
+
if (!input.isTTY) {
|
|
13
|
+
let text = "";
|
|
14
|
+
for await (const chunk of input)
|
|
15
|
+
text += chunk;
|
|
16
|
+
return text;
|
|
17
|
+
}
|
|
18
|
+
process.stderr.write(prompt);
|
|
19
|
+
input.setRawMode(true);
|
|
20
|
+
input.setEncoding("utf8");
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
let text = "";
|
|
23
|
+
const done = (value) => {
|
|
24
|
+
input.setRawMode(false);
|
|
25
|
+
input.pause();
|
|
26
|
+
input.off("data", onData);
|
|
27
|
+
process.stderr.write("\n");
|
|
28
|
+
value === undefined ? reject(new Error("cancelled")) : resolve(value);
|
|
29
|
+
};
|
|
30
|
+
const onData = (chunk) => {
|
|
31
|
+
for (const ch of chunk) {
|
|
32
|
+
if (ch === "\r" || ch === "\n")
|
|
33
|
+
return done(text);
|
|
34
|
+
if (ch === "\u0003" || ch === "\u0004")
|
|
35
|
+
return done();
|
|
36
|
+
if (ch === "\u007f" || ch === "\b")
|
|
37
|
+
text = text.slice(0, -1);
|
|
38
|
+
else if (ch >= " ")
|
|
39
|
+
text += ch;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
input.on("data", onData);
|
|
43
|
+
input.resume();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function main() {
|
|
47
|
+
const args = process.argv.slice(2);
|
|
48
|
+
if (args.length !== 1 || args[0].startsWith("-")) {
|
|
49
|
+
console.error(USAGE);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = originOf(args[0]);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
console.error(e.message);
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
const config = configFromEnv({ ...process.env, CONSTRUCTOR_BASE_URL: url });
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
raw = await readSecret(`Access key for ${url} (hidden): `);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
console.error("Cancelled; nothing saved.");
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
const key = cleanKey(raw);
|
|
70
|
+
if (!key) {
|
|
71
|
+
console.error("That is not an access key; nothing saved.");
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
// Check the key with Constructor before saving it.
|
|
75
|
+
let status;
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(`${url}${config.apiPath}/auth/session`, {
|
|
78
|
+
headers: {
|
|
79
|
+
"X-Research-AccessKey": `Bearer ${key}`,
|
|
80
|
+
Accept: "application/json",
|
|
81
|
+
},
|
|
82
|
+
redirect: "manual",
|
|
83
|
+
signal: AbortSignal.timeout(15000),
|
|
84
|
+
});
|
|
85
|
+
status = response.status;
|
|
86
|
+
await response.body?.cancel();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
console.error(`Could not reach ${url} (VPN?); nothing saved.`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
if (status === 401) {
|
|
93
|
+
console.error(`${url} rejected this key; nothing saved.`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
if (status !== 200) {
|
|
97
|
+
console.error(`${url} answered HTTP ${status}: it may not have the public API yet; nothing saved.`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
await saveKeyFile(config.keyFile, url, key);
|
|
101
|
+
console.log(`Key for ${url} saved to ${config.keyFile}. The MCP uses it from the next call; no restart needed.`);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
main().then((code) => (process.exitCode = code), () => {
|
|
105
|
+
console.error("Saving the key failed.");
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
});
|
package/dist/store.d.ts
CHANGED
|
@@ -70,7 +70,7 @@ export declare class StateStore {
|
|
|
70
70
|
* harnesses' calls "busy" about half the time while anything was deploying.
|
|
71
71
|
*/
|
|
72
72
|
lockWaitMs: number;
|
|
73
|
-
/** The Constructor API saved operations belong to; follows a
|
|
73
|
+
/** The Constructor API saved operations belong to; follows a key saved for another host. */
|
|
74
74
|
get endpoint(): string;
|
|
75
75
|
private init;
|
|
76
76
|
load(id: string): Promise<Operation | undefined>;
|
package/dist/store.js
CHANGED
|
@@ -13,7 +13,7 @@ export class StateStore {
|
|
|
13
13
|
* harnesses' calls "busy" about half the time while anything was deploying.
|
|
14
14
|
*/
|
|
15
15
|
lockWaitMs = 15000;
|
|
16
|
-
/** The Constructor API saved operations belong to; follows a
|
|
16
|
+
/** The Constructor API saved operations belong to; follows a key saved for another host. */
|
|
17
17
|
get endpoint() {
|
|
18
18
|
return this.config.baseUrl + this.config.apiPath;
|
|
19
19
|
}
|
package/dist/wait.js
CHANGED
|
@@ -10,7 +10,7 @@ import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { realpathSync } from "node:fs";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { configFromEnv
|
|
13
|
+
import { configFromEnv } from "./config.js";
|
|
14
14
|
import { PlatformApi } from "./api.js";
|
|
15
15
|
import { StateStore } from "./store.js";
|
|
16
16
|
import { DeploymentEngine, terminal } from "./deployment.js";
|
|
@@ -132,7 +132,7 @@ async function claim(dir, id) {
|
|
|
132
132
|
const USAGE = `Usage: constructor-research-wait [deployment_id ...] [--max-minutes N] [--hook claude-code|codex|opencode]
|
|
133
133
|
|
|
134
134
|
Waits until the deployments finish (all in-progress ones when no ID is given) and prints one line each.
|
|
135
|
-
Exit codes: 0 finished, 2 needs the user (failed,
|
|
135
|
+
Exit codes: 0 finished, 2 needs the user (failed, key, upload, confirmation), 3 still running at the
|
|
136
136
|
time limit, 4 nothing to wait for, 1 usage error.
|
|
137
137
|
|
|
138
138
|
--hook claude-code PostToolUse hook with asyncRewake: waits for the deployment in the tool result and
|
|
@@ -165,11 +165,8 @@ async function main(argv) {
|
|
|
165
165
|
return EXIT.error;
|
|
166
166
|
}
|
|
167
167
|
const config = configFromEnv();
|
|
168
|
-
// A hook runs for one harness: use that harness's session.
|
|
169
|
-
if (hook && !config.cookieFileFixed)
|
|
170
|
-
config.cookieFile = sessionFile(hook);
|
|
171
168
|
const api = new PlatformApi(config);
|
|
172
|
-
await api.
|
|
169
|
+
await api.refresh();
|
|
173
170
|
const store = new StateStore(config);
|
|
174
171
|
const engine = new DeploymentEngine(api, store);
|
|
175
172
|
engine.files = new FileManager(api, engine);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "npx -y -p constructor-research-mcp constructor-research-wait --hook claude-code",
|
|
9
|
+
"command": "npx -y -p constructor-research-mcp@beta constructor-research-wait --hook claude-code",
|
|
10
10
|
"asyncRewake": true
|
|
11
11
|
}
|
|
12
12
|
]
|
package/examples/claude.mcp.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"hooks": [
|
|
6
6
|
{
|
|
7
7
|
"type": "command",
|
|
8
|
-
"command": "npx -y -p constructor-research-mcp constructor-research-wait --hook codex",
|
|
8
|
+
"command": "npx -y -p constructor-research-mcp@beta constructor-research-wait --hook codex",
|
|
9
9
|
"timeout": 600,
|
|
10
10
|
"statusMessage": "Waiting for Constructor deployments"
|
|
11
11
|
}
|
package/examples/codex.toml
CHANGED
package/examples/opencode.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "constructor-research-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-beta.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local deployment MCP for Constructor Research",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
"bin": {
|
|
25
25
|
"constructor-research-mcp": "dist/server.js",
|
|
26
26
|
"constructor-research-wait": "dist/wait.js",
|
|
27
|
-
"constructor-research-
|
|
28
|
-
"constructor-research-set-token": "dist/set-token.js"
|
|
27
|
+
"constructor-research-set-key": "dist/set-key.js"
|
|
29
28
|
},
|
|
30
29
|
"files": [
|
|
31
30
|
"dist",
|
|
@@ -44,10 +43,9 @@
|
|
|
44
43
|
"pretest": "npm run build",
|
|
45
44
|
"format": "prettier --write src test examples README.md INSTALL.md",
|
|
46
45
|
"format:check": "prettier --check src test examples README.md INSTALL.md",
|
|
47
|
-
"set-token": "node dist/set-token.js",
|
|
48
46
|
"wait": "node dist/wait.js",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
47
|
+
"prepublishOnly": "npm run check && npm run format:check && npm test",
|
|
48
|
+
"set-key": "node dist/set-key.js"
|
|
51
49
|
},
|
|
52
50
|
"dependencies": {
|
|
53
51
|
"@modelcontextprotocol/sdk": "1.32.2",
|
|
@@ -58,5 +56,8 @@
|
|
|
58
56
|
"prettier": "3.9.10",
|
|
59
57
|
"tsx": "4.23.15",
|
|
60
58
|
"typescript": "7.0.2"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"tag": "beta"
|
|
61
62
|
}
|
|
62
63
|
}
|
package/dist/login.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
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/session.d.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
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>;
|