constructor-research-mcp 0.0.0-stage → 0.3.0
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 +145 -0
- package/LICENSE +21 -0
- package/README.md +320 -2
- package/dist/api.d.ts +66 -0
- package/dist/api.js +280 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +127 -0
- package/dist/deployment.d.ts +94 -0
- package/dist/deployment.js +1233 -0
- package/dist/files.d.ts +122 -0
- package/dist/files.js +822 -0
- package/dist/location.d.ts +17 -0
- package/dist/location.js +60 -0
- package/dist/login.d.ts +2 -0
- package/dist/login.js +25 -0
- package/dist/logs.d.ts +59 -0
- package/dist/logs.js +330 -0
- package/dist/runtime.d.ts +23 -0
- package/dist/runtime.js +238 -0
- package/dist/schemas.d.ts +232 -0
- package/dist/schemas.js +277 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +351 -0
- 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/dist/store.d.ts +85 -0
- package/dist/store.js +150 -0
- package/dist/wait.d.ts +26 -0
- package/dist/wait.js +241 -0
- package/examples/claude-hooks.json +16 -0
- package/examples/claude.mcp.json +8 -0
- package/examples/codex-hooks.json +16 -0
- package/examples/codex.toml +6 -0
- package/examples/deploy-app.json +39 -0
- package/examples/opencode-plugin.js +43 -0
- package/examples/opencode-v2.json +11 -0
- package/examples/opencode.json +9 -0
- package/package.json +60 -4
package/dist/wait.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Blocks until deployments finish, for agents that have nothing else to do and for
|
|
4
|
+
* client hooks that wake an agent. It watches the checkpoints the MCP writes; if no
|
|
5
|
+
* process has advanced a deployment for a minute (the MCP was closed), it continues
|
|
6
|
+
* the deployment itself.
|
|
7
|
+
*/
|
|
8
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
9
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { realpathSync } from "node:fs";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { configFromEnv, sessionFile } from "./config.js";
|
|
14
|
+
import { PlatformApi } from "./api.js";
|
|
15
|
+
import { StateStore } from "./store.js";
|
|
16
|
+
import { DeploymentEngine, terminal } from "./deployment.js";
|
|
17
|
+
import { DeploymentRuntime, paused } from "./runtime.js";
|
|
18
|
+
import { FileManager } from "./files.js";
|
|
19
|
+
import { LogFollower } from "./logs.js";
|
|
20
|
+
const ok = new Set(["started", "stopped", "built"]);
|
|
21
|
+
const ID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
22
|
+
export const EXIT = {
|
|
23
|
+
done: 0,
|
|
24
|
+
error: 1,
|
|
25
|
+
attention: 2,
|
|
26
|
+
running: 3,
|
|
27
|
+
nothing: 4,
|
|
28
|
+
};
|
|
29
|
+
const final = (status) => terminal.has(status) || paused.has(status);
|
|
30
|
+
/** Deployments still in progress that were touched in the last two hours. */
|
|
31
|
+
export async function active(store) {
|
|
32
|
+
const since = Date.now() - 2 * 60 * 60 * 1000;
|
|
33
|
+
const ids = [];
|
|
34
|
+
for (const id of await store.ids()) {
|
|
35
|
+
try {
|
|
36
|
+
const op = await store.load(id);
|
|
37
|
+
if (op &&
|
|
38
|
+
!final(op.status) &&
|
|
39
|
+
Math.max(Date.parse(op.updated_at), await store.lastBeat(id)) > since)
|
|
40
|
+
ids.push(id);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* Foreign or corrupt checkpoints are not ours to wait for. */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return ids;
|
|
47
|
+
}
|
|
48
|
+
export async function waitFor(runtime, ids, { maxMs, staleMs = 60000, pollMs = 5000 }) {
|
|
49
|
+
const store = runtime.engine.store;
|
|
50
|
+
const deadline = Date.now() + maxMs;
|
|
51
|
+
const pending = new Set(ids);
|
|
52
|
+
const results = [];
|
|
53
|
+
for (;;) {
|
|
54
|
+
for (const id of [...pending]) {
|
|
55
|
+
const op = await store.load(id);
|
|
56
|
+
if (!op) {
|
|
57
|
+
results.push({
|
|
58
|
+
deployment_id: id,
|
|
59
|
+
status: "not_found",
|
|
60
|
+
message: "No such deployment in the local state.",
|
|
61
|
+
});
|
|
62
|
+
pending.delete(id);
|
|
63
|
+
}
|
|
64
|
+
else if (final(op.status)) {
|
|
65
|
+
results.push(await runtime.get({ deployment_id: id }));
|
|
66
|
+
pending.delete(id);
|
|
67
|
+
}
|
|
68
|
+
else if (Date.now() -
|
|
69
|
+
Math.max(Date.parse(op.updated_at), await store.lastBeat(id)) >
|
|
70
|
+
staleMs)
|
|
71
|
+
await runtime.get({ deployment_id: id, resume: true, wait_seconds: 0 });
|
|
72
|
+
}
|
|
73
|
+
if (!pending.size || Date.now() >= deadline)
|
|
74
|
+
break;
|
|
75
|
+
await delay(Math.min(pollMs, Math.max(0, deadline - Date.now())));
|
|
76
|
+
}
|
|
77
|
+
const running = [...pending];
|
|
78
|
+
return {
|
|
79
|
+
results,
|
|
80
|
+
running,
|
|
81
|
+
code: results.some((r) => !ok.has(r.status))
|
|
82
|
+
? EXIT.attention
|
|
83
|
+
: running.length
|
|
84
|
+
? EXIT.running
|
|
85
|
+
: EXIT.done,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** One line per deployment, for an agent to read. */
|
|
89
|
+
export function summary({ results, running }) {
|
|
90
|
+
const lines = results.map((r) => {
|
|
91
|
+
const services = (r.services ?? [])
|
|
92
|
+
.map((s) => `${s.name} ${s.status ?? ""}${s.public_url ? ` ${s.public_url}` : ""}`.trim())
|
|
93
|
+
.join(", ");
|
|
94
|
+
return [
|
|
95
|
+
`Deployment ${r.deployment_id}: ${r.status}.`,
|
|
96
|
+
r.message,
|
|
97
|
+
services && `Services: ${services}.`,
|
|
98
|
+
r.next_action && `Next: ${r.next_action}`,
|
|
99
|
+
]
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.join(" ");
|
|
102
|
+
});
|
|
103
|
+
for (const id of running)
|
|
104
|
+
lines.push(`Deployment ${id}: still in progress. Run the wait command again to keep waiting.`);
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
async function stdin() {
|
|
108
|
+
if (process.stdin.isTTY)
|
|
109
|
+
return "";
|
|
110
|
+
let text = "";
|
|
111
|
+
for await (const chunk of process.stdin)
|
|
112
|
+
text += chunk;
|
|
113
|
+
return text;
|
|
114
|
+
}
|
|
115
|
+
/** Only one hook waiter per deployment, so the agent is woken once. */
|
|
116
|
+
async function claim(dir, id) {
|
|
117
|
+
const file = join(dir, `${id}.waiter`);
|
|
118
|
+
try {
|
|
119
|
+
await writeFile(file, String(process.pid), { flag: "wx", mode: 0o600 });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
try {
|
|
123
|
+
process.kill(Number(await readFile(file, "utf8")), 0);
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
await writeFile(file, String(process.pid), { mode: 0o600 });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return () => unlink(file).catch(() => { });
|
|
131
|
+
}
|
|
132
|
+
const USAGE = `Usage: constructor-research-wait [deployment_id ...] [--max-minutes N] [--hook claude-code|codex|opencode]
|
|
133
|
+
|
|
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, token, upload, confirmation), 3 still running at the
|
|
136
|
+
time limit, 4 nothing to wait for, 1 usage error.
|
|
137
|
+
|
|
138
|
+
--hook claude-code PostToolUse hook with asyncRewake: waits for the deployment in the tool result and
|
|
139
|
+
wakes Claude with the outcome (exit 2). opencode: the same, for the plugin.
|
|
140
|
+
--hook codex Stop hook: waits for in-progress deployments, then continues the turn with the outcome.`;
|
|
141
|
+
async function main(argv) {
|
|
142
|
+
const ids = [];
|
|
143
|
+
let hook;
|
|
144
|
+
let minutes;
|
|
145
|
+
for (let i = 0; i < argv.length; i++) {
|
|
146
|
+
const a = argv[i];
|
|
147
|
+
if (a === "--hook")
|
|
148
|
+
hook = argv[++i];
|
|
149
|
+
else if (a === "--max-minutes")
|
|
150
|
+
minutes = Number(argv[++i]);
|
|
151
|
+
else if (a === "-h" || a === "--help") {
|
|
152
|
+
console.log(USAGE);
|
|
153
|
+
return EXIT.done;
|
|
154
|
+
}
|
|
155
|
+
else if (/^[0-9a-f-]{36}$/i.test(a))
|
|
156
|
+
ids.push(a);
|
|
157
|
+
else {
|
|
158
|
+
console.error(USAGE);
|
|
159
|
+
return EXIT.error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if ((hook && !["claude-code", "codex", "opencode"].includes(hook)) ||
|
|
163
|
+
(minutes !== undefined && !(minutes > 0))) {
|
|
164
|
+
console.error(USAGE);
|
|
165
|
+
return EXIT.error;
|
|
166
|
+
}
|
|
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
|
+
const api = new PlatformApi(config);
|
|
172
|
+
await api.followSession();
|
|
173
|
+
const store = new StateStore(config);
|
|
174
|
+
const engine = new DeploymentEngine(api, store);
|
|
175
|
+
engine.files = new FileManager(api, engine);
|
|
176
|
+
engine.logs = new LogFollower(api);
|
|
177
|
+
const runtime = new DeploymentRuntime(engine);
|
|
178
|
+
const release = [];
|
|
179
|
+
try {
|
|
180
|
+
const toolHook = hook === "claude-code" || hook === "opencode";
|
|
181
|
+
if (toolHook) {
|
|
182
|
+
// Only deployments still running when the tool returned; a finished one was already reported.
|
|
183
|
+
for (const id of new Set((await stdin()).match(ID) ?? [])) {
|
|
184
|
+
const op = await store.load(id).catch(() => undefined);
|
|
185
|
+
const free = op && !final(op.status) && (await claim(config.stateDir, id));
|
|
186
|
+
if (free) {
|
|
187
|
+
ids.push(id);
|
|
188
|
+
release.push(free);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!ids.length)
|
|
192
|
+
return EXIT.done;
|
|
193
|
+
}
|
|
194
|
+
else if (hook === "codex")
|
|
195
|
+
await stdin();
|
|
196
|
+
// Deployments found by scanning may belong to other sessions: watch them, never drive them.
|
|
197
|
+
const named = ids.length > 0;
|
|
198
|
+
if (!named)
|
|
199
|
+
ids.push(...(await active(store)));
|
|
200
|
+
if (!ids.length) {
|
|
201
|
+
if (!hook)
|
|
202
|
+
console.log("No deployment is in progress.");
|
|
203
|
+
return hook ? EXIT.done : EXIT.nothing;
|
|
204
|
+
}
|
|
205
|
+
// Codex stops a hook after its timeout (600 s by default); stay under it.
|
|
206
|
+
const maxMs = (minutes ?? (hook === "codex" ? 9 : 60)) * 60000;
|
|
207
|
+
const result = await waitFor(runtime, ids, {
|
|
208
|
+
maxMs,
|
|
209
|
+
staleMs: named ? undefined : Infinity,
|
|
210
|
+
});
|
|
211
|
+
const text = summary(result);
|
|
212
|
+
if (toolHook) {
|
|
213
|
+
console.error(text);
|
|
214
|
+
return EXIT.attention;
|
|
215
|
+
}
|
|
216
|
+
if (hook === "codex") {
|
|
217
|
+
console.log(JSON.stringify({ decision: "block", reason: text }));
|
|
218
|
+
return EXIT.done;
|
|
219
|
+
}
|
|
220
|
+
console.log(text);
|
|
221
|
+
return result.code;
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await runtime.close();
|
|
225
|
+
await engine.logs.close();
|
|
226
|
+
await Promise.all(release.map((f) => f()));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const invoked = (() => {
|
|
230
|
+
try {
|
|
231
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
})();
|
|
237
|
+
if (invoked)
|
|
238
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
239
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
240
|
+
process.exit(EXIT.error);
|
|
241
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PostToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "mcp__constructor-research__(deploy_app|update_app|build_image|start_app|stop_app|get_deployment)",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "npx -y -p constructor-research-mcp constructor-research-wait --hook claude-code",
|
|
10
|
+
"asyncRewake": true
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"Stop": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "npx -y -p constructor-research-mcp constructor-research-wait --hook codex",
|
|
9
|
+
"timeout": 600,
|
|
10
|
+
"statusMessage": "Waiting for Constructor deployments"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"project_id": "PROJECT_ID",
|
|
3
|
+
"application_name": "task-tracker",
|
|
4
|
+
"files_uploaded": true,
|
|
5
|
+
"services": [
|
|
6
|
+
{
|
|
7
|
+
"name": "backend",
|
|
8
|
+
"build": {
|
|
9
|
+
"desk_id": "DISK_ID",
|
|
10
|
+
"dockerfile": "/backend/Dockerfile"
|
|
11
|
+
},
|
|
12
|
+
"ports": [
|
|
13
|
+
{
|
|
14
|
+
"number": 3000,
|
|
15
|
+
"expose_to_internet": true
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "frontend",
|
|
21
|
+
"build": {
|
|
22
|
+
"desk_id": "DISK_ID",
|
|
23
|
+
"dockerfile": "/frontend/Dockerfile"
|
|
24
|
+
},
|
|
25
|
+
"ports": [
|
|
26
|
+
{
|
|
27
|
+
"number": 5000,
|
|
28
|
+
"expose_to_internet": true
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"environment_variables": {
|
|
32
|
+
"BACKEND_URL": {
|
|
33
|
+
"service": "backend",
|
|
34
|
+
"exposure": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Wakes the OpenCode agent when a Constructor deployment it started finishes.
|
|
2
|
+
// Copy to .opencode/plugins/ or ~/.config/opencode/plugins/.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const WAIT = [
|
|
6
|
+
"-y",
|
|
7
|
+
"-p",
|
|
8
|
+
"constructor-research-mcp",
|
|
9
|
+
"constructor-research-wait",
|
|
10
|
+
];
|
|
11
|
+
// Any CONSTRUCTOR_* settings your MCP entry sets besides the defaults.
|
|
12
|
+
const ENV = {};
|
|
13
|
+
const TOOLS =
|
|
14
|
+
/(deploy_app|update_app|build_image|start_app|stop_app|get_deployment)$/;
|
|
15
|
+
|
|
16
|
+
export const ConstructorDeployWait = async ({ client }) => ({
|
|
17
|
+
"tool.execute.after": async (input, output) => {
|
|
18
|
+
if (!TOOLS.test(input.tool)) return;
|
|
19
|
+
const child = spawn("npx", [...WAIT, "--hook", "opencode"], {
|
|
20
|
+
env: { ...process.env, ...ENV },
|
|
21
|
+
shell: process.platform === "win32", // npx is npx.cmd there
|
|
22
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
23
|
+
});
|
|
24
|
+
let text = "";
|
|
25
|
+
child.stderr.on("data", (chunk) => (text += chunk));
|
|
26
|
+
child.stdin.end(JSON.stringify({ tool_response: output.output ?? "" }));
|
|
27
|
+
child.on("close", async (code) => {
|
|
28
|
+
// Exit 2 carries the outcome; 0 means nothing was in progress (or another waiter has it).
|
|
29
|
+
if (code !== 2 || !text.trim()) return;
|
|
30
|
+
await client.session.prompt({
|
|
31
|
+
path: { id: input.sessionID },
|
|
32
|
+
body: {
|
|
33
|
+
parts: [
|
|
34
|
+
{
|
|
35
|
+
type: "text",
|
|
36
|
+
text: `Constructor deployment update:\n${text.trim()}`,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "constructor-research-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Local deployment MCP for Constructor Research",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Constructor Tech",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/NRGGIT/constructor-research-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/NRGGIT/constructor-research-mcp#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/NRGGIT/constructor-research-mcp/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"constructor",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"codex",
|
|
22
|
+
"opencode"
|
|
23
|
+
],
|
|
24
|
+
"bin": {
|
|
25
|
+
"constructor-research-mcp": "dist/server.js",
|
|
26
|
+
"constructor-research-wait": "dist/wait.js",
|
|
27
|
+
"constructor-research-login": "dist/login.js",
|
|
28
|
+
"constructor-research-set-token": "dist/set-token.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"README.md",
|
|
33
|
+
"INSTALL.md",
|
|
34
|
+
"examples"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc",
|
|
41
|
+
"start": "node dist/server.js",
|
|
42
|
+
"test": "tsx --test test/*.test.ts",
|
|
43
|
+
"check": "tsc --noEmit",
|
|
44
|
+
"pretest": "npm run build",
|
|
45
|
+
"format": "prettier --write src test examples README.md INSTALL.md",
|
|
46
|
+
"format:check": "prettier --check src test examples README.md INSTALL.md",
|
|
47
|
+
"set-token": "node dist/set-token.js",
|
|
48
|
+
"wait": "node dist/wait.js",
|
|
49
|
+
"login": "node dist/login.js",
|
|
50
|
+
"prepublishOnly": "npm run check && npm run format:check && npm test"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@modelcontextprotocol/sdk": "1.32.2",
|
|
54
|
+
"zod": "4.6.7"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "26.6.5",
|
|
58
|
+
"prettier": "3.9.10",
|
|
59
|
+
"tsx": "4.23.15",
|
|
60
|
+
"typescript": "7.0.2"
|
|
61
|
+
}
|
|
62
|
+
}
|