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/schemas.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const id = z.string().regex(/^[a-zA-Z0-9_-]{4,128}$/);
|
|
3
|
+
const name = z.string().min(1).max(128);
|
|
4
|
+
const serviceName = z
|
|
5
|
+
.string()
|
|
6
|
+
.regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/, "Use a lowercase DNS label (letters, numbers, hyphens; max 63 characters).");
|
|
7
|
+
const storage = z
|
|
8
|
+
.number()
|
|
9
|
+
.int()
|
|
10
|
+
.min(10)
|
|
11
|
+
.max(1000)
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("Omit to use the API default (50 GB). Overrides must be 10–1000 GB.");
|
|
14
|
+
const dockerfile = z
|
|
15
|
+
.string()
|
|
16
|
+
.min(1)
|
|
17
|
+
.max(4096)
|
|
18
|
+
.refine((s) => !s.split("/").includes("..") && !/[\x00-\x1f]/.test(s), "Use a disk-relative Dockerfile path without parent traversal.");
|
|
19
|
+
const remotePath = z
|
|
20
|
+
.string()
|
|
21
|
+
.max(4096)
|
|
22
|
+
.refine((s) => !s.split("/").some((x) => x === ".." || x === ".") &&
|
|
23
|
+
!/[\x00-\x1f\\]/.test(s), "Use a disk-relative path without '.', '..' or control characters.");
|
|
24
|
+
const localPath = z
|
|
25
|
+
.string()
|
|
26
|
+
.min(1)
|
|
27
|
+
.max(4096)
|
|
28
|
+
.refine((s) => s.startsWith("/"), "Use an absolute local path.");
|
|
29
|
+
const build = z
|
|
30
|
+
.strictObject({
|
|
31
|
+
desk_id: id.optional(),
|
|
32
|
+
dockerfile: dockerfile
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Disk path of the Dockerfile. With local_path: path inside that folder (default Dockerfile)."),
|
|
35
|
+
local_path: localPath
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Local folder with the source and Dockerfile. The MCP uploads it to the disk first (new files without asking; replacing files needs confirm=true), then builds. Files matched by the folder's .dockerignore are not uploaded. A large folder may take several calls: on in_progress repeat the same deploy/update call."),
|
|
38
|
+
remote_path: remotePath
|
|
39
|
+
.optional()
|
|
40
|
+
.describe("Disk folder for local_path; defaults to the local folder's name."),
|
|
41
|
+
name: name.optional(),
|
|
42
|
+
ephemeral_build_disk_size_gb: storage,
|
|
43
|
+
})
|
|
44
|
+
.refine((b) => !!b.dockerfile || !!b.local_path, "build needs dockerfile, or local_path with the Dockerfile inside it.")
|
|
45
|
+
.refine((b) => !b.remote_path || !!b.local_path, "remote_path is only used with local_path.");
|
|
46
|
+
const wait = z
|
|
47
|
+
.number()
|
|
48
|
+
.min(0)
|
|
49
|
+
.max(20)
|
|
50
|
+
.default(20)
|
|
51
|
+
.describe("Wait up to this many seconds for completion; execution continues in the MCP after the response.");
|
|
52
|
+
const requestId = z
|
|
53
|
+
.string()
|
|
54
|
+
.min(1)
|
|
55
|
+
.max(128)
|
|
56
|
+
.optional()
|
|
57
|
+
.describe("Optional retry key. Omit it: the MCP generates deployment IDs automatically.");
|
|
58
|
+
export const buildInput = z.strictObject({
|
|
59
|
+
project_id: id,
|
|
60
|
+
desk_id: id,
|
|
61
|
+
dockerfile,
|
|
62
|
+
name,
|
|
63
|
+
ephemeral_build_disk_size_gb: storage,
|
|
64
|
+
request_id: requestId,
|
|
65
|
+
wait_seconds: wait,
|
|
66
|
+
});
|
|
67
|
+
export const listInput = z.strictObject({
|
|
68
|
+
project_id: id,
|
|
69
|
+
desk_id: id.optional(),
|
|
70
|
+
detailed: z.boolean().default(false),
|
|
71
|
+
});
|
|
72
|
+
const port = z.strictObject({
|
|
73
|
+
id: id.optional(),
|
|
74
|
+
number: z.number().int().min(1).max(65535),
|
|
75
|
+
expose_to_internet: z.boolean(),
|
|
76
|
+
});
|
|
77
|
+
const ports = z
|
|
78
|
+
.array(port)
|
|
79
|
+
.refine((p) => p.filter((x) => x.expose_to_internet).length <= 1, "Only one public port per service.")
|
|
80
|
+
.refine((p) => new Set(p.map((x) => x.number)).size === p.length, "Port numbers must be unique.");
|
|
81
|
+
const volume = z
|
|
82
|
+
.strictObject({
|
|
83
|
+
id: id.optional(),
|
|
84
|
+
name,
|
|
85
|
+
mount_point: z.string().startsWith("/"),
|
|
86
|
+
size_gb: z.number().int().min(10).max(2000),
|
|
87
|
+
})
|
|
88
|
+
.describe("Persistent disk for this service only (see the volume rules). Changing name, mount_point or size_gb wipes it. On update, omit volumes to keep them; a supplied list replaces the current one.");
|
|
89
|
+
const reference = z
|
|
90
|
+
.strictObject({
|
|
91
|
+
service: serviceName,
|
|
92
|
+
exposure: z.enum(["internal", "public"]).default("internal"),
|
|
93
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
94
|
+
scheme: z
|
|
95
|
+
.enum(["http", "https"])
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("Internal only: prefix added to the address. Omit it to get a bare host:port (for gRPC, databases, Temporal and similar); set http for an HTTP URL."),
|
|
98
|
+
})
|
|
99
|
+
.describe("Resolved by the MCP into the other service's address. internal: <host>:<port> inside the app (port required), e.g. svc-api.app-1234abcd.svc.cluster.local:7233, or http://... when scheme is set. public: the service's https URL.")
|
|
100
|
+
.refine((r) => r.exposure === "public" || r.port !== undefined, "Internal references require a port.")
|
|
101
|
+
.refine((r) => r.exposure !== "public" || r.scheme === undefined || r.scheme === "https", "Public references use HTTPS.");
|
|
102
|
+
export const serviceInput = z
|
|
103
|
+
.strictObject({
|
|
104
|
+
name: serviceName,
|
|
105
|
+
service_id: id.optional(),
|
|
106
|
+
image_id: id.optional(),
|
|
107
|
+
build: build.optional(),
|
|
108
|
+
hardware: z
|
|
109
|
+
.strictObject({
|
|
110
|
+
cpu: z.number().positive().max(256).optional(),
|
|
111
|
+
ram_gb: z.number().positive().max(4096).optional(),
|
|
112
|
+
gpu: z.boolean().optional(),
|
|
113
|
+
})
|
|
114
|
+
.optional()
|
|
115
|
+
.describe("Omit this: the default hardware is used, and existing services keep theirs. Set it only when the app clearly cannot run on the default (needs a GPU, or loads large models/datasets into memory). Values are minimums; the smallest available option that meets them is chosen. get_deployment with desk_url lists hardware_options."),
|
|
116
|
+
ports: ports.optional(),
|
|
117
|
+
volumes: z.array(volume).max(3).optional(),
|
|
118
|
+
environment_variables: z
|
|
119
|
+
.record(z.string().min(1), z.union([z.string(), reference]))
|
|
120
|
+
.optional()
|
|
121
|
+
.describe("Plain strings, or {service, port[, scheme]} to point at another service of the same app; never localhost."),
|
|
122
|
+
depends_on: z.array(serviceName).optional(),
|
|
123
|
+
})
|
|
124
|
+
.refine((s) => !(s.image_id && s.build), "Choose image_id or build, not both.");
|
|
125
|
+
export const deskUrl = z
|
|
126
|
+
.string()
|
|
127
|
+
.max(2048)
|
|
128
|
+
.describe("Link to the Constructor desk the user copied from the browser, e.g. https://constructor.app/research/projects/<project>/desks/<desk>/viewer/file. Supplies project_id and desk_id; ask the user for it instead of choosing a project or desk yourself.");
|
|
129
|
+
const deploymentShape = {
|
|
130
|
+
project_id: id.optional(),
|
|
131
|
+
desk_url: deskUrl
|
|
132
|
+
.optional()
|
|
133
|
+
.describe("Desk link from the user: sets project_id and the desk for builds without desk_id."),
|
|
134
|
+
project_name: name.optional(),
|
|
135
|
+
application_name: name.optional(),
|
|
136
|
+
services: z.array(serviceInput).min(1).optional(),
|
|
137
|
+
files_uploaded: z.boolean().default(false),
|
|
138
|
+
confirm: z
|
|
139
|
+
.boolean()
|
|
140
|
+
.default(false)
|
|
141
|
+
.describe("Set after the user approved replacing existing disk files listed by a previous confirm_required response."),
|
|
142
|
+
confirm_volume_wipe: z
|
|
143
|
+
.boolean()
|
|
144
|
+
.default(false)
|
|
145
|
+
.describe("Set only after the user approved losing the data in the volumes listed by a previous confirm_required response."),
|
|
146
|
+
request_id: requestId,
|
|
147
|
+
wait_seconds: wait,
|
|
148
|
+
deployment_id: z.string().uuid().optional(),
|
|
149
|
+
};
|
|
150
|
+
export const deployInput = z
|
|
151
|
+
.strictObject(deploymentShape)
|
|
152
|
+
.refine((x) => !!x.deployment_id || (!!x.application_name && !!x.services), "New deployments require application_name and services. To resume, provide deployment_id and optional service overrides.");
|
|
153
|
+
export const updateInput = z
|
|
154
|
+
.strictObject({ ...deploymentShape, application_id: id.optional() })
|
|
155
|
+
.refine((x) => !!x.deployment_id ||
|
|
156
|
+
((!!x.project_id || !!x.desk_url) && !!x.application_id && !!x.services), "Updates require desk_url or project_id, plus application_id and services, or deployment_id to resume.");
|
|
157
|
+
export const lifecycleInput = z.strictObject({
|
|
158
|
+
project_id: id,
|
|
159
|
+
application_id: id,
|
|
160
|
+
service_names: z.array(serviceName).min(1).optional(),
|
|
161
|
+
wait_seconds: wait,
|
|
162
|
+
});
|
|
163
|
+
export const getInput = z
|
|
164
|
+
.strictObject({
|
|
165
|
+
deployment_id: z.string().uuid().optional(),
|
|
166
|
+
project_id: id.optional(),
|
|
167
|
+
desk_url: deskUrl
|
|
168
|
+
.optional()
|
|
169
|
+
.describe("Desk link from the user; selects its project for the project view."),
|
|
170
|
+
application_id: id.optional(),
|
|
171
|
+
files_uploaded: z.boolean().optional(),
|
|
172
|
+
resume: z.boolean().default(true),
|
|
173
|
+
wait_seconds: wait,
|
|
174
|
+
})
|
|
175
|
+
.refine((x) => !x.application_id || !!x.project_id || !!x.desk_url, "application_id requires desk_url or project_id.")
|
|
176
|
+
.refine((x) => x.resume || x.files_uploaded === undefined, "files_uploaded cannot be set in observation mode.");
|
|
177
|
+
export const logsInput = z
|
|
178
|
+
.strictObject({
|
|
179
|
+
image_id: id
|
|
180
|
+
.optional()
|
|
181
|
+
.describe("Image (build) ID: failure.image_id, or an image from get_deployment/list_images."),
|
|
182
|
+
service_id: id
|
|
183
|
+
.optional()
|
|
184
|
+
.describe("Service ID from get_deployment (services[].id)."),
|
|
185
|
+
deployment_id: z
|
|
186
|
+
.string()
|
|
187
|
+
.uuid()
|
|
188
|
+
.optional()
|
|
189
|
+
.describe("All builds and services this deployment started."),
|
|
190
|
+
lines: z
|
|
191
|
+
.number()
|
|
192
|
+
.int()
|
|
193
|
+
.min(10)
|
|
194
|
+
.max(50)
|
|
195
|
+
.default(20)
|
|
196
|
+
.describe("How many of the last log lines to return."),
|
|
197
|
+
})
|
|
198
|
+
.refine((x) => [x.image_id, x.service_id, x.deployment_id].filter(Boolean).length === 1, "Pass one of image_id, service_id or deployment_id.");
|
|
199
|
+
export const listFilesInput = z.strictObject({
|
|
200
|
+
project_id: id,
|
|
201
|
+
desk_id: id,
|
|
202
|
+
path: remotePath.default("").describe("Folder on the disk; '' is the root."),
|
|
203
|
+
recursive: z.boolean().default(false),
|
|
204
|
+
max_entries: z.number().int().min(1).max(5000).default(500),
|
|
205
|
+
});
|
|
206
|
+
const requiredPath = remotePath.refine((s) => s.replace(/\//g, "") !== "", "A path below the disk root is required.");
|
|
207
|
+
export const createFolderInput = z.strictObject({
|
|
208
|
+
project_id: id,
|
|
209
|
+
desk_id: id,
|
|
210
|
+
path: requiredPath.describe("Folder to create; missing parent folders are created too."),
|
|
211
|
+
});
|
|
212
|
+
const confirm = z
|
|
213
|
+
.boolean()
|
|
214
|
+
.default(false)
|
|
215
|
+
.describe("false (default) returns the full list for the user to approve once; true performs it.");
|
|
216
|
+
export const movePathInput = z.strictObject({
|
|
217
|
+
project_id: id,
|
|
218
|
+
desk_id: id,
|
|
219
|
+
moves: z
|
|
220
|
+
.array(z.strictObject({
|
|
221
|
+
from: requiredPath.describe("Existing file or folder."),
|
|
222
|
+
to: requiredPath.describe("New path. Its parent folder must exist; an existing target is never overwritten."),
|
|
223
|
+
}))
|
|
224
|
+
.min(1)
|
|
225
|
+
.max(100)
|
|
226
|
+
.describe("All renames/moves for this request, approved together."),
|
|
227
|
+
confirm,
|
|
228
|
+
});
|
|
229
|
+
export const deletePathInput = z.strictObject({
|
|
230
|
+
project_id: id,
|
|
231
|
+
desk_id: id,
|
|
232
|
+
paths: z
|
|
233
|
+
.array(requiredPath)
|
|
234
|
+
.min(1)
|
|
235
|
+
.max(100)
|
|
236
|
+
.describe("All files/folders to delete for this request, approved together. Folders are deleted with all contents."),
|
|
237
|
+
confirm,
|
|
238
|
+
});
|
|
239
|
+
export const syncFilesInput = z.strictObject({
|
|
240
|
+
project_id: id,
|
|
241
|
+
desk_id: id,
|
|
242
|
+
local_path: localPath.describe("Absolute local file or folder. A folder's contents go into remote_path."),
|
|
243
|
+
remote_path: remotePath
|
|
244
|
+
.default("")
|
|
245
|
+
.describe("Destination folder on the disk; '' is the root."),
|
|
246
|
+
exclude: z
|
|
247
|
+
.array(z.string().min(1).max(256))
|
|
248
|
+
.max(100)
|
|
249
|
+
.default([])
|
|
250
|
+
.describe("Extra names or '*' patterns to skip, matched against file names and relative paths."),
|
|
251
|
+
dry_run: z
|
|
252
|
+
.boolean()
|
|
253
|
+
.default(false)
|
|
254
|
+
.describe("true only reports the plan, without uploading."),
|
|
255
|
+
confirm: z
|
|
256
|
+
.boolean()
|
|
257
|
+
.default(false)
|
|
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
|
+
});
|
|
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://learn.constructor.university. 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.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { configFromEnv, originOf } from "./config.js";
|
|
5
|
+
import { PlatformApi, ToolError } from "./api.js";
|
|
6
|
+
import { StateStore } from "./store.js";
|
|
7
|
+
import { DeploymentEngine, terminal } from "./deployment.js";
|
|
8
|
+
import { DeploymentRuntime } from "./runtime.js";
|
|
9
|
+
import { FileManager, dockerignore } from "./files.js";
|
|
10
|
+
import { basename } from "node:path";
|
|
11
|
+
import { LogFollower } from "./logs.js";
|
|
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
|
+
import { located, resolveDeskUrl } from "./location.js";
|
|
14
|
+
import { ZodError } from "zod";
|
|
15
|
+
import { buildInput, listInput, deployInput, updateInput, lifecycleInput, getInput, logsInput, listFilesInput, createFolderInput, syncFilesInput, movePathInput, deletePathInput, signInInput, } from "./schemas.js";
|
|
16
|
+
import { harnessOf, sessionFile } from "./config.js";
|
|
17
|
+
import { sessionHost } from "./session.js";
|
|
18
|
+
import { signIn } from "./signin.js";
|
|
19
|
+
async function main() {
|
|
20
|
+
const config = configFromEnv();
|
|
21
|
+
const api = new PlatformApi(config);
|
|
22
|
+
const engine = new DeploymentEngine(api, new StateStore(config));
|
|
23
|
+
const runtime = new DeploymentRuntime(engine);
|
|
24
|
+
const files = new FileManager(api, engine);
|
|
25
|
+
engine.files = files;
|
|
26
|
+
const logs = new LogFollower(api);
|
|
27
|
+
engine.logs = logs;
|
|
28
|
+
/** Verify Dockerfiles on their disks before any build; surface lint warnings to the agent. */
|
|
29
|
+
const checked = (fn, single = false) => async (a) => {
|
|
30
|
+
const begun = Date.now();
|
|
31
|
+
if (!single && a.services?.some((x) => x.build?.local_path)) {
|
|
32
|
+
// Upload local sources first, once per (disk, local folder, disk folder).
|
|
33
|
+
const done = new Set();
|
|
34
|
+
const services = [];
|
|
35
|
+
for (const x of a.services) {
|
|
36
|
+
const b = x.build;
|
|
37
|
+
if (!b?.local_path) {
|
|
38
|
+
services.push(x);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!b.desk_id || !a.project_id)
|
|
42
|
+
throw new ToolError("invalid_input", `Service ${x.name}: local_path needs a disk. Pass desk_url (the desk link from the user) or build.desk_id with project_id.`);
|
|
43
|
+
const remote = (b.remote_path ?? basename(b.local_path))
|
|
44
|
+
.split("/")
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join("/");
|
|
47
|
+
const key = JSON.stringify([b.desk_id, b.local_path, remote]);
|
|
48
|
+
if (!done.has(key)) {
|
|
49
|
+
const r = await files.sync({
|
|
50
|
+
project_id: a.project_id,
|
|
51
|
+
desk_id: b.desk_id,
|
|
52
|
+
local_path: b.local_path,
|
|
53
|
+
remote_path: remote,
|
|
54
|
+
exclude: await dockerignore(b.local_path),
|
|
55
|
+
confirm: a.confirm,
|
|
56
|
+
});
|
|
57
|
+
if (r.status !== "synced")
|
|
58
|
+
return {
|
|
59
|
+
...r,
|
|
60
|
+
message: `${r.message} Nothing was built yet. ${r.status === "confirm_required"
|
|
61
|
+
? "After the user approves, repeat the same call with confirm=true."
|
|
62
|
+
: r.status === "in_progress"
|
|
63
|
+
? "Repeat the same call to continue uploading."
|
|
64
|
+
: "Resolve the listed files, then repeat the call."}`,
|
|
65
|
+
};
|
|
66
|
+
done.add(key);
|
|
67
|
+
}
|
|
68
|
+
const inside = (b.dockerfile ?? "Dockerfile")
|
|
69
|
+
.split("/")
|
|
70
|
+
.filter(Boolean);
|
|
71
|
+
services.push({
|
|
72
|
+
...x,
|
|
73
|
+
build: {
|
|
74
|
+
...b,
|
|
75
|
+
remote_path: remote,
|
|
76
|
+
dockerfile: "/" +
|
|
77
|
+
[...remote.split("/").filter(Boolean), ...inside].join("/"),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
a = {
|
|
82
|
+
...a,
|
|
83
|
+
services,
|
|
84
|
+
wait_seconds: Math.min(a.wait_seconds ?? 20, Math.max(0, Math.floor(35 - (Date.now() - begun) / 1000))),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const sources = single
|
|
88
|
+
? [
|
|
89
|
+
{
|
|
90
|
+
service: a.name,
|
|
91
|
+
desk_id: a.desk_id,
|
|
92
|
+
dockerfile: a.dockerfile,
|
|
93
|
+
ports: [],
|
|
94
|
+
},
|
|
95
|
+
]
|
|
96
|
+
: (a.services ?? [])
|
|
97
|
+
.filter((x) => x.build?.desk_id)
|
|
98
|
+
.map((x) => ({
|
|
99
|
+
service: x.name,
|
|
100
|
+
desk_id: x.build.desk_id,
|
|
101
|
+
dockerfile: x.build.dockerfile,
|
|
102
|
+
ports: (x.ports ?? []).map((p) => p.number),
|
|
103
|
+
}));
|
|
104
|
+
if (!sources.length || !a.project_id)
|
|
105
|
+
return fn(a);
|
|
106
|
+
const pre = await files.preflight(a.project_id, sources);
|
|
107
|
+
if (pre.missing.length)
|
|
108
|
+
throw new ToolError("dockerfile_missing", `Dockerfile not found on the disk for ${pre.missing.join(", ")}. Upload the source with sync_files (check the path), then call again. Nothing was built.`);
|
|
109
|
+
const allOnDisk = !single &&
|
|
110
|
+
(a.services ?? []).every((x) => !x.build || x.build.desk_id);
|
|
111
|
+
const result = await fn(allOnDisk ? { ...a, files_uploaded: true } : a);
|
|
112
|
+
return pre.warnings.length
|
|
113
|
+
? {
|
|
114
|
+
...result,
|
|
115
|
+
message: `${result.message ?? ""} Dockerfile warnings to fix or tell the user about: ${pre.warnings.join(" ")}`.trim(),
|
|
116
|
+
dockerfile_warnings: pre.warnings,
|
|
117
|
+
}
|
|
118
|
+
: result;
|
|
119
|
+
};
|
|
120
|
+
const server = new McpServer({ name: "constructor-research", version: "0.2.0" }, {
|
|
121
|
+
instructions: `Sign-in: the MCP works with one Constructor, https://constructor.app unless the user signed in to another. When the user asks to sign in, or names their Constructor or gives a link from it (e.g. https://learn.constructor.university), call sign_in with url set to that link. Where to act: ask the user for the Constructor desk link (they open the desk in the browser and copy the address; it contains /projects/<project>/desks/<desk>) and pass it as desk_url. Do not pick a project or desk yourself; if the user has not said where to deploy or where files live, ask. Ask the user only before replacing, renaming or deleting files on a disk, once per request for the whole list (the tools return confirm_required); creating folders and uploading new files need no question. ${RULES} For a local app, pass build.local_path (the folder with the Dockerfile) and deploy_app uploads it first. Hardware: leave it out; the default is used. Set hardware minimums only when the app cannot run without a GPU or a lot of RAM. Use deploy_app/update_app once for the WHOLE application. The MCP builds, resolves service URLs, configures and starts services in the background. IDs are automatic. To wait, call get_deployment(wait_seconds=20) again as often as needed (a loop of such calls is fine); never perform the deployment steps yourself. 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
|
+
});
|
|
123
|
+
const register = (name, description, schema, fn, readOnly = false, budget = 20000, scope) => {
|
|
124
|
+
if (scope && scope !== "deploy")
|
|
125
|
+
schema = located(schema, scope);
|
|
126
|
+
server.registerTool(name, {
|
|
127
|
+
description,
|
|
128
|
+
inputSchema: schema,
|
|
129
|
+
annotations: {
|
|
130
|
+
readOnlyHint: readOnly,
|
|
131
|
+
destructiveHint: !readOnly,
|
|
132
|
+
idempotentHint: readOnly,
|
|
133
|
+
openWorldHint: true,
|
|
134
|
+
},
|
|
135
|
+
}, async (args) => {
|
|
136
|
+
try {
|
|
137
|
+
await api.followSession();
|
|
138
|
+
// Nobody signed in yet: a desk link names the Constructor to sign in to.
|
|
139
|
+
if (args?.desk_url &&
|
|
140
|
+
!config.baseUrlFixed &&
|
|
141
|
+
!(await sessionHost(config.cookieFile)))
|
|
142
|
+
try {
|
|
143
|
+
api.aimed = config.baseUrl = originOf(args.desk_url);
|
|
144
|
+
}
|
|
145
|
+
catch { }
|
|
146
|
+
// Do not start a change on a token about to expire; checking on a deployment is still fine.
|
|
147
|
+
const before = await api.authLeft().catch(() => undefined);
|
|
148
|
+
const what = (renewable) => renewable ? "The login session ends" : "The token expires";
|
|
149
|
+
if (!readOnly &&
|
|
150
|
+
name !== "get_deployment" &&
|
|
151
|
+
name !== "sign_in" &&
|
|
152
|
+
before !== undefined &&
|
|
153
|
+
before.minutes < 3)
|
|
154
|
+
throw new ToolError("needs_auth", api.authHelp(`${what(before.renewable)} in ${Math.max(0, Math.floor(before.minutes))} min, too soon to start this operation.`));
|
|
155
|
+
let result = await api.withBudget(budget, () => fn(scope
|
|
156
|
+
? resolveDeskUrl(args, config.baseUrl, scope, config.baseUrlFixed)
|
|
157
|
+
: 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
|
+
return {
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: "text",
|
|
176
|
+
text: result.text ??
|
|
177
|
+
(result.status
|
|
178
|
+
? `${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
|
+
: ""),
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
// A preformatted text (build logs) is not repeated in structuredContent.
|
|
186
|
+
structuredContent: result.text
|
|
187
|
+
? { ...result, text: undefined }
|
|
188
|
+
: result,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
const result = err instanceof ZodError
|
|
193
|
+
? {
|
|
194
|
+
status: "invalid_input",
|
|
195
|
+
message: err.issues.map((x) => x.message).join("; "),
|
|
196
|
+
}
|
|
197
|
+
: err instanceof ToolError
|
|
198
|
+
? {
|
|
199
|
+
status: err.code,
|
|
200
|
+
message: err.message,
|
|
201
|
+
http_status: err.status,
|
|
202
|
+
api_error_code: err.apiCode,
|
|
203
|
+
}
|
|
204
|
+
: {
|
|
205
|
+
status: "internal_error",
|
|
206
|
+
message: "The local operation failed. Check configuration and state-directory permissions; no API response or secret values are included in this error.",
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
isError: true,
|
|
210
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
211
|
+
structuredContent: result,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
register("build_image", "Build a disk Dockerfile. IDs are generated automatically; omit build storage for the default. Waits up to wait_seconds (default 20), then continues in the background. Check get_deployment for the result. The Dockerfile rules in deploy_app apply.", buildInput, checked((a) => runtime.buildImage(a), true), false, 20000, "desk");
|
|
217
|
+
register("list_images", "List images with native build statuses and Dockerfile paths. Optionally filter by disk. Compact results by default; detailed=true returns full image records.", listInput, (a) => engine.listImages(a), true, 20000, "desk");
|
|
218
|
+
register("list_files", "List files and folders on a project disk (the Constructor desk's project folder). Non-recursive by default; recursive=true walks subfolders up to max_entries.", listFilesInput, (a) => files.list(a), true, 60000, "desk");
|
|
219
|
+
register("create_folder", "Create a folder on a project disk, including missing parent folders. Existing folders are left unchanged.", createFolderInput, (a) => files.createFolder(a), false, 60000, "desk");
|
|
220
|
+
register("sync_files", "Upload a local file or folder to a project disk. Compares by size and SHA-256 and sends only new and changed files; never deletes. New files and folders upload without asking. If existing files would be replaced, nothing is uploaded and status is confirm_required: ask the user once for the whole list, then repeat with confirm=true. dry_run=true only previews. Skips .git, node_modules, .env, .venv, __pycache__, .DS_Store and files over 100 MB. If the result is in_progress, repeat the same call to continue.", syncFilesInput, (a) => files.sync(a), false, 90000, "desk");
|
|
221
|
+
register("move_path", "Rename or move files/folders on a project disk. Send every move for the request in one call: without confirm it returns the list (confirm_required) for the user to approve once; repeat with confirm=true. Never overwrites; the destination parent folder must exist.", movePathInput, (a) => files.move(a), false, 60000, "desk");
|
|
222
|
+
register("delete_path", "Delete files/folders (folders with all contents) on a project disk. Send every path for the request in one call: without confirm it returns what would be deleted (confirm_required) for the user to approve once; repeat with confirm=true. Cannot be undone.", deletePathInput, (a) => files.remove(a), false, 60000, "desk");
|
|
223
|
+
register("deploy_app", "Deploy the WHOLE app in one request: prepare project/disk, build or reuse all images, resolve internal/public service references, configure and start. Runs in the background. IDs are automatic. To resume a failure, send deployment_id and service overrides (for example replacement image_id); successful builds are preserved. On failure, failure.log_tail and get_logs show what the build or service printed." +
|
|
224
|
+
" " +
|
|
225
|
+
RULES, deployInput, checked((a) => runtime.deploy(a)), false, 60000, "deploy");
|
|
226
|
+
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
|
+
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
|
+
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 cookie replacement checking the job resumes it. With no arguments, returns the map of all deployments: every deployment-enabled project with its applications and services (names, native statuses, public URLs, ports). With desk_url or project_id, returns that project's desks, images, hardware and applications; add application_id for one application. resume=false observes only. Never returns environment-variable values.", getInput, (a) => runtime.get(a), false, 20000, "deploy");
|
|
230
|
+
let signing;
|
|
231
|
+
register("sign_in", "Signs the user in to their Constructor: https://constructor.app unless url names another one (any link from it, e.g. https://learn.constructor.university). 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);
|
|
285
|
+
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
|
+
const i = logsInput.parse(a);
|
|
287
|
+
let targets = [];
|
|
288
|
+
if (i.image_id)
|
|
289
|
+
targets = [{ kind: "build", id: i.image_id }];
|
|
290
|
+
else if (i.service_id)
|
|
291
|
+
targets = [{ kind: "service", id: i.service_id }];
|
|
292
|
+
else {
|
|
293
|
+
const op = await engine.store.load(i.deployment_id);
|
|
294
|
+
if (!op)
|
|
295
|
+
throw new ToolError("invalid_input", "Deployment ID not found.");
|
|
296
|
+
const named = (name) => op.kind === "build" ? undefined : name;
|
|
297
|
+
targets = Object.entries(op.images).map(([name, id]) => ({
|
|
298
|
+
kind: "build",
|
|
299
|
+
id,
|
|
300
|
+
name: named(name),
|
|
301
|
+
}));
|
|
302
|
+
const failed = op.failure?.image_id;
|
|
303
|
+
if (failed && !targets.some((t) => t.id === failed))
|
|
304
|
+
targets.push({
|
|
305
|
+
kind: "build",
|
|
306
|
+
id: failed,
|
|
307
|
+
name: op.failure?.service,
|
|
308
|
+
});
|
|
309
|
+
for (const [name, id] of Object.entries(op.services))
|
|
310
|
+
targets.push({ kind: "service", id, name });
|
|
311
|
+
}
|
|
312
|
+
if (!targets.length)
|
|
313
|
+
return {
|
|
314
|
+
status: "no_logs",
|
|
315
|
+
message: "This deployment has not started a build or service.",
|
|
316
|
+
};
|
|
317
|
+
const logs_ = await Promise.all(targets.map(async (t) => ({
|
|
318
|
+
name: t.name,
|
|
319
|
+
...(await logs.tail(t.id, t.kind, i.lines)),
|
|
320
|
+
})));
|
|
321
|
+
return {
|
|
322
|
+
logs: logs_,
|
|
323
|
+
text: logs_
|
|
324
|
+
.map((l) => `== ${l.kind} ${l.name ? `${l.name} (${l.id})` : l.id}: ${l.state}${l.message ? `. ${l.message}` : ""}${l.lines.length ? "\n" + l.lines.join("\n") : ""}`)
|
|
325
|
+
.join("\n\n"),
|
|
326
|
+
};
|
|
327
|
+
}, true, 20000, "deploy");
|
|
328
|
+
server.server.onclose = () => {
|
|
329
|
+
void runtime.close();
|
|
330
|
+
void logs.close();
|
|
331
|
+
};
|
|
332
|
+
for (const signal of ["SIGTERM", "SIGINT"])
|
|
333
|
+
process.once(signal, () => {
|
|
334
|
+
void Promise.all([runtime.close(), logs.close()]).then(() => server.close());
|
|
335
|
+
});
|
|
336
|
+
// Each harness keeps its own session, named after the client in the handshake.
|
|
337
|
+
let recovered = false;
|
|
338
|
+
server.server.oninitialized = () => {
|
|
339
|
+
if (!config.cookieFileFixed)
|
|
340
|
+
config.cookieFile = sessionFile(harnessOf(server.server.getClientVersion()?.name));
|
|
341
|
+
if (!recovered) {
|
|
342
|
+
recovered = true;
|
|
343
|
+
void api.followSession().finally(() => runtime.recover());
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
await server.connect(new StdioServerTransport());
|
|
347
|
+
}
|
|
348
|
+
main().catch(() => {
|
|
349
|
+
console.error("Constructor Research MCP could not start. Check environment configuration.");
|
|
350
|
+
process.exitCode = 1;
|
|
351
|
+
});
|
|
@@ -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>;
|